Merge branch 'develop' into hs/measured-fix

This commit is contained in:
Will Hunt 2026-05-12 16:01:21 +01:00 committed by GitHub
commit f296084fed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 2213 additions and 657 deletions

View File

@ -1,3 +1,36 @@
Changes in [1.12.18](https://github.com/element-hq/element-web/releases/tag/v1.12.18) (2026-05-12)
==================================================================================================
## ✨ Features
* Room list: add collapse/expand all sections ([#33318](https://github.com/element-hq/element-web/pull/33318)). Contributed by @florianduros.
* Show user status in timeline ([#32991](https://github.com/element-hq/element-web/pull/32991)). Contributed by @Half-Shot.
* Disable URL Preview setting if disabled on the homeserver ([#33279](https://github.com/element-hq/element-web/pull/33279)). Contributed by @Half-Shot.
* Go to welcome on logout ([#33306](https://github.com/element-hq/element-web/pull/33306)). Contributed by @t3chguy.
* Room list: edit or remove custom sections ([#33283](https://github.com/element-hq/element-web/pull/33283)). Contributed by @florianduros.
* Re-generate QR code if the channel expires before scan ([#33303](https://github.com/element-hq/element-web/pull/33303)). Contributed by @t3chguy.
* Update toast styles, improve incoming call notifications ([#33043](https://github.com/element-hq/element-web/pull/33043)). Contributed by @robintown.
* Add Module Composer API ([#33284](https://github.com/element-hq/element-web/pull/33284)). Contributed by @Half-Shot.
* Room list: exclude default section from room list item menu ([#33278](https://github.com/element-hq/element-web/pull/33278)). Contributed by @florianduros.
* Show 'Verify this device' toast even if there are no encrypted rooms yet ([#32891](https://github.com/element-hq/element-web/pull/32891)). Contributed by @andybalaam.
* Promote "Share encrypted history" from labs ([#33281](https://github.com/element-hq/element-web/pull/33281)). Contributed by @richvdh.
* Room list: assign room to section when section is created ([#33240](https://github.com/element-hq/element-web/pull/33240)). Contributed by @florianduros.
* Confirm before inviting unknown users to a DM/room ([#33171](https://github.com/element-hq/element-web/pull/33171)). Contributed by @richvdh.
* Room list: assign room to custom section ([#33238](https://github.com/element-hq/element-web/pull/33238)). Contributed by @florianduros.
* Redesign link previews ([#33061](https://github.com/element-hq/element-web/pull/33061)). Contributed by @Half-Shot.
* Room list: scroll to newly creation section ([#33210](https://github.com/element-hq/element-web/pull/33210)). Contributed by @florianduros.
## 🐛 Bug Fixes
* Update home page CSS ([#32723](https://github.com/element-hq/element-web/pull/32723)). Contributed by @wolterkam.
* Web: Fix typo in `152x152` icon source of `manifest.json` ([#33369](https://github.com/element-hq/element-web/pull/33369)). Contributed by @bartvdbraak.
* prevent replay hover from restarting playback ([#33364](https://github.com/element-hq/element-web/pull/33364)). Contributed by @ZacksBot.
* Properly save `undefined` id tokens from OIDC login ([#33345](https://github.com/element-hq/element-web/pull/33345)). Contributed by @gingershaped.
* Show the right cursor when hovering over a space ([#33351](https://github.com/element-hq/element-web/pull/33351)). Contributed by @robintown.
* Set `type` in auth dict for `m.oauth` UIA stage ([#33344](https://github.com/element-hq/element-web/pull/33344)). Contributed by @gingershaped.
* Remove duplicated UI in appearance settings ([#33336](https://github.com/element-hq/element-web/pull/33336)). Contributed by @t3chguy.
* Move playwright-common wait-on from devDependencies to dependencies ([#33272](https://github.com/element-hq/element-web/pull/33272)). Contributed by @t3chguy.
Changes in [1.12.17](https://github.com/element-hq/element-web/releases/tag/v1.12.17) (2026-04-30)
==================================================================================================
## 🐛 Bug Fixes

View File

@ -3,7 +3,7 @@
"productName": "Element",
"main": "lib/electron-main.js",
"exports": "./lib/electron-main.js",
"version": "1.12.17",
"version": "1.12.18",
"description": "Element: the future of secure communication",
"author": {
"name": "Element",

View File

@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.12.17",
"version": "1.12.18",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {

View File

@ -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();

View File

@ -169,8 +169,7 @@ export class ElementAppPage {
): ReturnType<Locator["setInputFiles"]> {
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);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -234,7 +234,6 @@
@import "./views/messages/_ReactionsRow.pcss";
@import "./views/messages/_TextualEvent.pcss";
@import "./views/messages/_ThreadActionBar.pcss";
@import "./views/messages/_ViewSourceEvent.pcss";
@import "./views/messages/_common_CryptoEvent.pcss";
@import "./views/polls/pollHistory/_PollHistory.pcss";
@import "./views/polls/pollHistory/_PollHistoryList.pcss";

View File

@ -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);
}
}

View File

@ -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/")) {

View File

@ -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<IProps> = ({ parent, onFileDrop, room }) => {
const FileDropTarget: React.FC<IProps> = ({ parent }) => {
const [state, setState] = useState<IState>({
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<IProps> = ({ 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<IProps> = ({ 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 (
<div className="mx_FileDropTarget">
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />

View File

@ -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<HTMLElement | null>;
onFileDrop: (dataTransfer: DataTransfer) => Promise<void>;
mainSplitContentType: MainSplitContentType;
e2eStatus?: E2EStatus;
}
@ -343,17 +343,19 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
<div className="mx_RoomView mx_RoomView--local">
<ErrorBoundary>
<RoomHeader room={room} />
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} room={room} />
<div className="mx_RoomView_timeline">
<ScrollPanel className="mx_RoomView_messagePanel">
{encryptionTile}
<NewRoomIntro />
</ScrollPanel>
</div>
{statusBar}
{composer}
</main>
<RoomUploadContextProvider>
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
<FileDropTarget parent={props.roomView.current} />
<div className="mx_RoomView_timeline">
<ScrollPanel className="mx_RoomView_messagePanel">
{encryptionTile}
<NewRoomIntro />
</ScrollPanel>
</div>
{statusBar}
{composer}
</main>
</RoomUploadContextProvider>
</ErrorBoundary>
</div>
);
@ -2121,19 +2123,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
});
}
private onFileDrop = async (dataTransfer: DataTransfer): Promise<void> => {
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<IRoomProps, IRoomState> {
resizeNotifier={this.context.resizeNotifier}
permalinkCreator={this.permalinkCreator}
roomView={this.roomView}
onFileDrop={this.onFileDrop}
mainSplitContentType={this.state.mainSplitContentType}
/>
</ScopedRoomContextProvider>
@ -2673,16 +2661,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
case MainSplitContentType.Timeline:
mainSplitContentClassName = "mx_MainSplit_timeline";
mainSplitBody = (
<>
<RoomUploadContextProvider>
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
{auxPanel}
{pinnedMessageBanner}
<main className={timelineClasses} data-testid="timeline">
<FileDropTarget
parent={this.roomView.current}
onFileDrop={this.onFileDrop}
room={this.state.room}
/>
<FileDropTarget parent={this.roomView.current} />
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}
@ -2691,7 +2675,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
{statusBarArea}
{previewBar}
{messageComposer}
</>
</RoomUploadContextProvider>
);
break;
case MainSplitContentType.MaximisedWidget:

View File

@ -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<IProps, IState> {
}
};
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<IProps, IState> {
timeline = (
<>
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
<FileDropTarget parent={this.card.current} />
<EventPresentationContextProvider layout={layout}>
<TimelinePanel
key={this.state.thread.id}
@ -437,38 +421,40 @@ export default class ThreadView extends React.Component<IProps, IState> {
liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()}
narrow={this.state.narrow}
>
<BaseCard
className={classNames("mx_ThreadView mx_ThreadPanel", {
mx_ThreadView_narrow: this.state.narrow,
})}
onClose={this.props.onClose}
withoutScrollContainer={true}
header={this.renderThreadViewHeader()}
ref={this.card}
onKeyDown={this.onKeyDown}
onBack={(ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
}}
>
<Measured breakpoint={400} sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
<RoomUploadContextProvider threadRelation={this.threadRelation}>
<BaseCard
className={classNames("mx_ThreadView mx_ThreadPanel", {
mx_ThreadView_narrow: this.state.narrow,
})}
onClose={this.props.onClose}
withoutScrollContainer={true}
header={this.renderThreadViewHeader()}
ref={this.card}
onKeyDown={this.onKeyDown}
onBack={(ev: ButtonEvent) => {
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
}}
>
<Measured breakpoint={400} sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} />
)}
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} />
)}
{this.state.thread?.timelineSet && (
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
relation={threadRelation}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
)}
</BaseCard>
{this.state.thread?.timelineSet && (
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
relation={threadRelation}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
)}
</BaseCard>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
);
}

View File

@ -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<IProps, IState> {
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 = <pre>{JSON.stringify(mxEvent, null, 4)}</pre>;
} else {
content = <code>{`{ "type": ${mxEvent.getType()} }`}</code>;
}
const classes = classNames("mx_ViewSourceEvent mx_EventTile_content", {
mx_ViewSourceEvent_expanded: expanded,
});
return (
<span className={classes}>
{content}
<AccessibleButton
kind="link"
title={_t("devtools|toggle_event")}
className="mx_ViewSourceEvent_toggle"
onClick={this.onToggle}
>
{expanded ? <CollapseIcon /> : <ExpandIcon />}
</AccessibleButton>
</span>
);
}
}

View File

@ -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<IProps, IState> {
header={_t("right_panel|video_room_chat|title")}
ref={this.card}
>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_TimelineCard_timeline">
{jumpToBottom}
<EventPresentationContextProvider layout={layout}>
<TimelinePanel
ref={this.timelinePanel}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={true}
manageReadMarkers={false} // No RM support in the TimelineCard
sendReadReceiptOnLoad={true}
timelineSet={this.props.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// The right panel timeline (and therefore threads) don't support IRC layout at this time
layout={layout}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
<RoomUploadContextProvider>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_TimelineCard_timeline">
{jumpToBottom}
<EventPresentationContextProvider layout={layout}>
<TimelinePanel
ref={this.timelinePanel}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={true}
manageReadMarkers={false} // No RM support in the TimelineCard
sendReadReceiptOnLoad={true}
timelineSet={this.props.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// The right panel timeline (and therefore threads) don't support IRC layout at this time
layout={layout}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.state.initialEventId}
highlightedEventId={highlightedEventId}
onScroll={this.onScroll}
/>
</EventPresentationContextProvider>
</div>
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
{showComposer && (
<MessageComposer
room={this.props.room}
relation={this.props.composerRelation}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.state.initialEventId}
highlightedEventId={highlightedEventId}
onScroll={this.onScroll}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
</EventPresentationContextProvider>
</div>
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
{showComposer && (
<MessageComposer
room={this.props.room}
relation={this.props.composerRelation}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state.replyToEvent}
permalinkCreator={this.props.permalinkCreator}
e2eStatus={this.props.e2eStatus}
compact={true}
/>
)}
)}
</RoomUploadContextProvider>
</BaseCard>
</ScopedRoomContextProvider>
);

View File

@ -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<IProps> = (props: IProps) => {
});
return (
<UploadButtonContextProvider roomId={room.roomId} relation={props.relation}>
<>
{mainButtons}
{moreButtons.length > 0 && (
<AccessibleButton
@ -149,7 +147,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
</OverflowMenuContext.Provider>
</IconizedContextMenu>
)}
</UploadButtonContextProvider>
</>
);
};
@ -168,79 +166,13 @@ function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />;
}
type UploadButtonFn = () => void;
export const UploadButtonContext = createContext<UploadButtonFn | null>(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<IUploadButtonProps> = ({ roomId, relation, children }) => {
const cli = useContext(MatrixClientContext);
const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent");
const uploadInput = useRef<HTMLInputElement>(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<HTMLInputElement>): 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 (
<UploadButtonContext.Provider value={onUploadClick}>
{children}
<input
ref={uploadInput}
type="file"
style={uploadInputStyle}
multiple
onClick={chromeFileInputFix}
onChange={onUploadFileInputChange}
/>
</UploadButtonContext.Provider>
);
};
// 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
};

View File

@ -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<ISendMessageComposerPro
// 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),
this.props.room.roomId,
this.props.relation,
this.context.replyToEvent,
this.props.mxClient,
this.context.timelineRenderingType,
);
this.props.uploadVm.initiateViaDataTransfer(data);
return true; // to skip internal onPaste handler
}
@ -601,13 +601,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
const parts = response.url.split("/");
const filename = parts[parts.length - 1];
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
ContentMessages.sharedInstance().sendContentToRoom(
file,
this.props.room.roomId,
this.props.relation,
this.props.mxClient,
this.context.replyToEvent,
);
this.props.uploadVm.initiateViaInputFiles([file]);
},
(error) => {
console.log(error);
@ -660,5 +654,13 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
}
}
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer);
export default SendMessageComposerWithMatrixClient;
function SendMessageComposerWrapped(
props: Omit<ISendMessageComposerProps, "mxClient" | "uploadVm"> &
RefAttributes<InstanceType<typeof SendMessageComposer>>,
): ReactElement {
const client = useMatrixClientContext();
const uploadVm = useRoomUploadViewModel();
return <SendMessageComposer {...props} mxClient={client} uploadVm={uploadVm} />;
}
export default SendMessageComposerWrapped;

View File

@ -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);

View File

@ -60,7 +60,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
const { room } = useScopedRoomContext("room");
const autocompleteRef = useRef<Autocomplete | null>(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]);

View File

@ -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<Autocomplete | null>,
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,
],
);
}

View File

@ -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<HTMLDivElement | null>;
autocompleteRef: RefObject<Autocomplete | null>;
@ -64,9 +62,6 @@ export function usePlainTextListeners(
onSelect: (this: void, event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
} {
const roomContext = useScopedRoomContext("room", "timelineRenderingType", "replyToEvent");
const mxClient = useMatrixClientContext();
const ref = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<Autocomplete>(null);
const [content, setContent] = useState<string | undefined>(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");

View File

@ -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<HTMLElement | null>,
@ -123,13 +122,8 @@ export function handleEventWithAutocomplete(
export function handleClipboardEvent(
event: ClipboardEvent | InputEvent,
data: DataTransfer | null,
roomContext: Pick<IRoomState, "room" | "timelineRenderingType" | "replyToEvent">,
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);
})

View File

@ -25,6 +25,7 @@ import {
MKeyVerificationRequestView,
RoomAvatarEventView,
TextualEventView,
ViewSourceEventView,
useCreateAutoDisposedViewModel,
} from "@element-hq/web-shared-components";
@ -44,7 +45,6 @@ import { useMatrixClientContext } from "../contexts/MatrixClientContext";
import { WidgetType } from "../widgets/WidgetType";
import { hasText } from "../TextForEvent";
import { getMessageModerationState, MessageModerationState } from "../utils/EventUtils";
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
import { ModuleApi } from "../modules/Api";
@ -54,6 +54,7 @@ import { MKeyVerificationRequestViewModel } from "../viewmodels/room/timeline/ev
import { RoomAvatarEventViewModel } from "../viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel";
import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel";
import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel";
import { ViewSourceEventViewModel } from "../viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel";
import { ElementCallEventType } from "../call-types";
import { CallStartedTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel";
@ -127,6 +128,24 @@ function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
}
const HiddenEventFactory: Factory = (ref, props) => <HiddenBodyWrappedView ref={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 (
<ViewSourceEventView
vm={vm}
ref={ref}
className="mx_ViewSourceEvent mx_EventTile_content"
expandedClassName="mx_ViewSourceEvent_expanded"
/>
);
}
function MJitsiWidgetEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(() => new MJitsiWidgetEventViewModel({ mxEvent, cli }));
@ -178,8 +197,8 @@ export const CallStartedEventFactory: Factory = (ref, props) => {
};
// These factories are exported for reference comparison against pickFactory()
export const JSONEventFactory: Factory = (ref, props) => <ViewSourceEventWrappedView ref={ref} {...props} />;
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEventWrappedView ref={ref} {...props} />;
export const JSONEventFactory: Factory = (ref, props) => <ViewSourceEvent ref={ref} {...props} />;
export const RoomCreateEventFactory: Factory = (_ref, props) => <RoomPredecessorTile {...props} />;
const EVENT_TILE_TYPES = new Map<string, Factory>([

View File

@ -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",

View File

@ -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<void>;
initiateViaDataTransfer(dataTransfer: DataTransfer): Promise<void>;
openUploadDialog(): void;
}
export class RoomUploadViewModel
extends BaseViewModel<RoomUploadViewSnapshot, Record<string, never>>
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<void> => {
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<void> => {
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<RoomUploadViewModel | null>(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<HTMLInputElement>(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<HTMLInputElement> = 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 (
<RoomUploadContext.Provider value={vm}>
<>
{children}
<input
ref={uploadInput}
type="file"
data-testid="room-upload-context-input"
style={{ display: "none" }}
multiple
onClick={chromeFileInputFix}
onChange={onInputChange}
/>
</>
</RoomUploadContext.Provider>
);
}

View File

@ -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<ViewSourceEventViewSnapshot, ViewSourceEventViewModelProps>
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<ViewSourceEventViewModelProps>): 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<HTMLButtonElement>): 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;
}
}

View File

@ -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 = {

View File

@ -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<RoomUploadViewActions>;
}) {
const mockVm = useMockedViewModel<RoomUploadViewSnapshot, RoomUploadViewActions>(
snapshot,
actions as RoomUploadViewActions,
);
return (
<RoomUploadContext.Provider value={mockVm as RoomUploadViewModel}>
<FileDropTarget parent={element} />
</RoomUploadContext.Provider>
);
}
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(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />);
const { asFragment } = render(
<FileDropTargetWrapped
element={element}
snapshot={{ mayUpload: true }}
actions={{ initiateViaDataTransfer: onFileDrop }}
/>,
);
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(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />);
const { asFragment } = render(
<FileDropTargetWrapped
element={element}
snapshot={{ mayUpload: true }}
actions={{ initiateViaDataTransfer: onFileDrop }}
/>,
);
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(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />);
const { asFragment } = render(
<FileDropTargetWrapped
element={element}
snapshot={{ mayUpload: false }}
actions={{ initiateViaDataTransfer: onFileDrop }}
/>,
);
fireEvent.dragEnter(element, {
dataTransfer: {
types: ["Files"],

View File

@ -270,6 +270,12 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div>
</div>
</main>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
`;
@ -638,16 +644,17 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
/>
</svg>
</div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
</div>
</main>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
`;
@ -1010,16 +1017,17 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
/>
</svg>
</div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
</div>
</main>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
`;
@ -1320,6 +1328,12 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
/>
</div>
</div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
@ -1621,15 +1635,16 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = `
/>
</svg>
</div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
</div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
@ -2096,15 +2111,16 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
/>
</svg>
</div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
</div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
@ -2571,15 +2587,16 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
/>
</svg>
</div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
</div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
@ -2794,6 +2811,12 @@ exports[`RoomView should not display the timeline when the room encryption is lo
/>
</div>
</div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
@ -3288,15 +3311,16 @@ exports[`RoomView should not display the timeline when the room encryption is lo
/>
</svg>
</div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
</div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
@ -3844,15 +3868,16 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
/>
</svg>
</div>
<input
multiple=""
style="display: none;"
type="file"
/>
</div>
</div>
</div>
</div>
<input
data-testid="room-upload-context-input"
multiple=""
style="display: none;"
type="file"
/>
</div>
</aside>
<div>

View File

@ -15,6 +15,8 @@ import {
type IEventDecryptionResult,
type MatrixClient,
MatrixEvent,
MatrixEventEvent,
MsgType,
NotificationCountType,
PendingEventOrdering,
Room,
@ -44,6 +46,59 @@ import PinningUtils from "../../../../../src/utils/PinningUtils";
import { Layout } from "../../../../../src/settings/enums/Layout";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import PlatformPeg from "../../../../../src/PlatformPeg";
function getTile(container: HTMLElement): HTMLElement {
const tile = container.querySelector(".mx_EventTile");
expect(tile).not.toBeNull();
return tile as HTMLElement;
}
function getLine(container: HTMLElement): HTMLElement {
const line = container.querySelector(".mx_EventTile_line");
expect(line).not.toBeNull();
return line as HTMLElement;
}
function expectTileClass(container: HTMLElement, className: string): void {
expect(getTile(container)).toHaveClass(className);
}
function makeReplyEvent(roomId: string): MatrixEvent {
const parentEvent = mkMessage({
room: roomId,
user: "@alice:example.org",
msg: "Original message",
event: true,
});
return mkMessage({
room: roomId,
user: "@bob:example.org",
msg: "Reply message",
event: true,
relatesTo: {
"m.in_reply_to": {
event_id: parentEvent.getId(),
},
},
});
}
function makeThreadReplyEvent(roomId: string): MatrixEvent {
return mkMessage({
room: roomId,
user: "@alice:example.org",
msg: "Hello world!",
ts: 1234,
event: true,
relatesTo: {
rel_type: "m.thread",
event_id: "$thread-root",
},
});
}
describe("EventTile", () => {
const ROOM_ID = "!roomId:example.org";
@ -83,11 +138,35 @@ describe("EventTile", () => {
return render(<WrappedEventTile roomContext={context} eventTilePropertyOverrides={overrides} />);
}
function makeOwnMessage(overrides: Partial<Parameters<typeof mkMessage>[0]> = {}): MatrixEvent {
return mkMessage({
...overrides,
room: overrides.room ?? room.roomId,
user: overrides.user ?? client.getSafeUserId(),
msg: overrides.msg ?? "Hello world!",
event: overrides.event ?? true,
});
}
function makeTimestampedMessage(overrides: Partial<Parameters<typeof mkMessage>[0]> = {}): MatrixEvent {
return mkMessage({
...overrides,
room: overrides.room ?? room.roomId,
user: overrides.user ?? "@alice:example.org",
msg: overrides.msg ?? "Hello world!",
ts: overrides.ts ?? 1234,
event: overrides.event ?? true,
});
}
beforeEach(() => {
jest.clearAllMocks();
stubClient();
client = MatrixClientPeg.safeGet();
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
getUserIdForRoomId: jest.fn().mockReturnValue(undefined),
} as unknown as DMRoomMap);
room = new Room(ROOM_ID, client, client.getSafeUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
@ -110,6 +189,550 @@ describe("EventTile", () => {
jest.restoreAllMocks();
});
describe("layout and tile attributes", () => {
it.each([
["last", { last: true }, "mx_EventTile_last"],
["lastInSection", { lastInSection: true }, "mx_EventTile_lastInSection"],
["contextual", { contextual: true }, "mx_EventTile_contextual"],
["isSelectedEvent", { isSelectedEvent: true }, "mx_EventTile_selected"],
["hideSender", { hideSender: true }, "mx_EventTile_noSender"],
["isTwelveHour", { isTwelveHour: true }, "mx_EventTile_12hr"],
] as const)("adds the %s class", (_propName, overrides, className) => {
const { container } = getComponent(overrides);
expectTileClass(container, className);
});
it("marks events from other users as non-self events", () => {
const { container } = getComponent();
expect(getTile(container)).toHaveAttribute("data-self", "false");
});
it("marks events from the current user as self events", () => {
const ownEvent = makeOwnMessage();
const { container } = getComponent({ mxEvent: ownEvent });
expect(getTile(container)).toHaveAttribute("data-self", "true");
});
it("exposes the rendered event id in room timelines", () => {
const { container } = getComponent();
expect(getTile(container)).toHaveAttribute("data-event-id", mxEvent.getId());
});
it("renders the event line inside the tile", () => {
const { container } = getComponent();
expect(getTile(container)).toContainElement(getLine(container));
});
it("does not expose a scroll token for local echo events", () => {
const localEcho = makeOwnMessage();
localEcho.setStatus(EventStatus.SENDING);
const { container } = getComponent({ mxEvent: localEcho, eventSendStatus: EventStatus.SENDING });
expect(getTile(container)).not.toHaveAttribute("data-scroll-tokens");
});
});
describe("rendering root attributes", () => {
type RootAttribute =
| "data-scroll-tokens"
| "data-layout"
| "data-shape"
| "data-self"
| "data-event-id"
| "data-has-reply";
it.each([
[
TimelineRenderingType.Room,
["data-scroll-tokens", "data-layout", "data-self", "data-event-id", "data-has-reply"],
["data-shape"],
],
[
TimelineRenderingType.Thread,
["data-scroll-tokens", "data-layout", "data-self", "data-event-id", "data-has-reply"],
["data-shape"],
],
[
TimelineRenderingType.ThreadsList,
["data-scroll-tokens", "data-layout", "data-shape", "data-self", "data-has-reply"],
["data-event-id"],
],
[
TimelineRenderingType.Notification,
["data-scroll-tokens", "data-layout", "data-shape", "data-self", "data-has-reply"],
["data-event-id"],
],
[
TimelineRenderingType.File,
["data-scroll-tokens"],
["data-layout", "data-shape", "data-self", "data-event-id", "data-has-reply"],
],
] as const)(
"sets root attributes for %s rendering",
(renderingType, expectedPresentAttributes, expectedAbsentAttributes) => {
const { container } = getComponent({}, renderingType);
const tile = getTile(container);
const expectedValues: Record<RootAttribute, string> = {
"data-scroll-tokens": mxEvent.getId()!,
"data-layout": Layout.Group,
"data-shape": renderingType,
"data-self": "false",
"data-event-id": mxEvent.getId()!,
"data-has-reply": "false",
};
for (const attribute of expectedPresentAttributes) {
expect(tile).toHaveAttribute(attribute, expectedValues[attribute]);
}
for (const attribute of expectedAbsentAttributes) {
expect(tile).not.toHaveAttribute(attribute);
}
},
);
});
describe("message type classes", () => {
it("adds media and image classes for image messages", () => {
const imageEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
room: room.roomId,
user: "@alice:example.org",
content: {
msgtype: MsgType.Image,
body: "image.png",
url: "mxc://example.org/image",
info: {
mimetype: "image/png",
w: 100,
h: 100,
size: 1234,
},
},
});
const { container } = getComponent({ mxEvent: imageEvent });
expect(getLine(container)).toHaveClass("mx_EventTile_mediaLine");
expect(getLine(container)).toHaveClass("mx_EventTile_image");
});
it("adds emote classes for emote messages", () => {
const emoteEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
room: room.roomId,
user: "@alice:example.org",
content: {
msgtype: MsgType.Emote,
body: "waves",
},
});
const { container } = getComponent({ mxEvent: emoteEvent });
expect(getTile(container)).toHaveClass("mx_EventTile_emote");
expect(getLine(container)).toHaveClass("mx_EventTile_emote");
});
it("adds media and sticker classes for sticker events", () => {
const stickerEvent = mkEvent({
event: true,
type: EventType.Sticker,
room: room.roomId,
user: "@alice:example.org",
content: {
body: "sticker.png",
url: "mxc://example.org/sticker",
info: {
mimetype: "image/png",
w: 100,
h: 100,
size: 1234,
},
},
});
const { container } = getComponent({ mxEvent: stickerEvent });
expect(getLine(container)).toHaveClass("mx_EventTile_mediaLine");
expect(getLine(container)).toHaveClass("mx_EventTile_sticker");
});
});
describe("timestamps", () => {
beforeEach(() => {
mxEvent = makeTimestampedMessage();
});
it("hides the timestamp by default in room timelines", () => {
const { container } = getComponent();
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
});
it("shows the timestamp when the tile is hovered", () => {
const { container } = getComponent();
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
fireEvent.mouseEnter(getTile(container));
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
});
it("shows the timestamp when focus is within the tile", () => {
const { container } = getComponent();
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
fireEvent.focus(getTile(container));
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
});
it("shows the timestamp for the last event", () => {
const { container } = getComponent({ last: true });
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
});
it("shows the timestamp when timestamps are always shown", () => {
const { container } = getComponent({ alwaysShowTimestamps: true });
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
});
it("hides the timestamp when timestamps are disabled for the tile", () => {
const { container } = getComponent({ alwaysShowTimestamps: true, hideTimestamp: true });
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
});
it("renders a placeholder timestamp in IRC layout", () => {
const { container } = getComponent({ layout: Layout.IRC });
const timestamp = container.querySelector(".mx_MessageTimestamp");
expect(timestamp).not.toBeNull();
expect(timestamp?.tagName).toBe("SPAN");
});
it("dispatches a room view when the linked timestamp is clicked", () => {
jest.spyOn(dis, "dispatch").mockImplementation(() => {});
const permalinkCreator = new RoomPermalinkCreator(room);
const { container } = getComponent({ alwaysShowTimestamps: true, permalinkCreator });
const timestamp = container.querySelector<HTMLAnchorElement>("a.mx_MessageTimestamp");
expect(timestamp).not.toBeNull();
fireEvent.click(timestamp!);
expect(dis.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewRoom,
event_id: mxEvent.getId(),
highlighted: true,
room_id: room.roomId,
}),
);
});
});
describe("sender and avatar rendering", () => {
it("shows sender and avatar in room timelines", () => {
const { container } = getComponent();
expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull();
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
});
it("hides sender and avatar for continuation events in room timelines", () => {
const { container } = getComponent({ continuation: true });
expectTileClass(container, "mx_EventTile_continuation");
expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull();
expect(container.querySelector(".mx_EventTile_avatar")).toBeNull();
});
it("hides sender but keeps avatar when sender display is disabled", () => {
const { container } = getComponent({ hideSender: true });
expectTileClass(container, "mx_EventTile_noSender");
expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull();
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
});
it("renders sender details as a permalink in file timelines", () => {
const { container } = getComponent({}, TimelineRenderingType.File);
const senderDetailsLink = container.querySelector(".mx_EventTile_senderDetailsLink");
expect(senderDetailsLink).not.toBeNull();
expect(senderDetailsLink).toContainElement(container.querySelector(".mx_DisambiguatedProfile"));
expect(senderDetailsLink).toContainElement(container.querySelector(".mx_EventTile_avatar"));
});
it("renders sender details in thread timelines", () => {
const { container } = getComponent({}, TimelineRenderingType.Thread);
const senderDetails = container.querySelector(".mx_EventTile_senderDetails");
expect(senderDetails).not.toBeNull();
expect(senderDetails).toContainElement(container.querySelector(".mx_DisambiguatedProfile"));
expect(senderDetails).toContainElement(container.querySelector(".mx_EventTile_avatar"));
});
});
describe("read receipt option", () => {
it("shows a sent receipt for the current user's last successful event", () => {
const ownEvent = makeOwnMessage();
const { getByRole } = getComponent({ mxEvent: ownEvent, lastSuccessful: true });
expect(getByRole("status")).toHaveAccessibleName("Your message was sent");
});
it.each([
[EventStatus.SENDING, "Sending your message…"],
[EventStatus.ENCRYPTING, "Encrypting your message…"],
[EventStatus.NOT_SENT, "Failed to send"],
])("shows the %s receipt for the current user's pending event", (eventSendStatus, label) => {
const ownEvent = makeOwnMessage();
ownEvent.setStatus(eventSendStatus);
const { getByRole } = getComponent({ mxEvent: ownEvent, eventSendStatus });
expect(getByRole("status")).toHaveAccessibleName(label);
});
it("does not show a sent receipt in the threads list", () => {
const ownEvent = makeOwnMessage();
const { queryByRole } = getComponent(
{ mxEvent: ownEvent, lastSuccessful: true },
TimelineRenderingType.ThreadsList,
);
expect(queryByRole("status", { name: "Your message was sent" })).toBeNull();
});
it("shows normal read receipts instead of the sent receipt when other users have read the event", () => {
const ownEvent = makeOwnMessage();
const { getByRole, queryByRole } = getComponent({
mxEvent: ownEvent,
lastSuccessful: true,
showReadReceipts: true,
readReceipts: [
{
userId: "@bob:example.org",
roomMember: null,
ts: 1234,
},
],
});
expect(queryByRole("status", { name: "Your message was sent" })).toBeNull();
expect(getByRole("group", { name: "Seen by 1 person" })).toBeInTheDocument();
});
});
describe("reactions and footer", () => {
it("gets annotation relations when reactions are enabled", () => {
const getRelationsForEvent = jest.fn().mockReturnValue(null);
getComponent({ showReactions: true, getRelationsForEvent });
expect(getRelationsForEvent).toHaveBeenCalledWith(mxEvent.getId(), "m.annotation", "m.reaction");
});
it("does not get annotation relations when reactions are disabled", () => {
const getRelationsForEvent = jest.fn().mockReturnValue(null);
getComponent({ getRelationsForEvent });
expect(getRelationsForEvent).not.toHaveBeenCalled();
});
it("refreshes annotation relations when reaction relations are created", () => {
const getRelationsForEvent = jest.fn().mockReturnValue(null);
getComponent({ showReactions: true, getRelationsForEvent });
getRelationsForEvent.mockClear();
act(() => {
mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.annotation", "m.reaction");
});
expect(getRelationsForEvent).toHaveBeenCalledWith(mxEvent.getId(), "m.annotation", "m.reaction");
});
it("does not refresh annotation relations for unrelated relations", () => {
const getRelationsForEvent = jest.fn().mockReturnValue(null);
getComponent({ showReactions: true, getRelationsForEvent });
getRelationsForEvent.mockClear();
act(() => {
mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.reference", "m.room.message");
});
expect(getRelationsForEvent).not.toHaveBeenCalled();
});
it("does not render reactions for redacted events", () => {
const getRelationsForEvent = jest.fn().mockReturnValue(null);
const { container } = getComponent({ showReactions: true, getRelationsForEvent, isRedacted: true });
expect(container.querySelector(".mx_ReactionsRow")).toBeNull();
});
it("renders a footer for pinned messages", () => {
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
const { container } = getComponent();
expect(container.querySelector(".mx_EventTile_footer")).not.toBeNull();
expect(screen.getByText("Pinned message")).toBeInTheDocument();
});
});
describe("action bar", () => {
it("does not render the message action bar by default", () => {
const { container } = getComponent();
expect(container.querySelector(".mx_MessageActionBar")).toBeNull();
});
it("renders the message action bar when the tile is hovered", () => {
const { container } = getComponent();
fireEvent.mouseEnter(getTile(container));
expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull();
});
it("renders the message action bar when the tile receives keyboard focus", () => {
const matches = HTMLElement.prototype.matches;
jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function (this: HTMLElement, selector) {
if (selector === ":focus-visible") return true;
return matches.call(this, selector);
});
const { container } = getComponent();
fireEvent.focus(getTile(container));
expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull();
});
it("hides the keyboard-focused message action bar when focus leaves the tile", () => {
const matches = HTMLElement.prototype.matches;
jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function (this: HTMLElement, selector) {
if (selector === ":focus-visible") return true;
return matches.call(this, selector);
});
const { container } = getComponent();
const tile = getTile(container);
fireEvent.focus(tile);
expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull();
fireEvent.blur(tile);
expect(container.querySelector(".mx_MessageActionBar")).toBeNull();
});
it("does not render the message action bar on hover when exporting", () => {
const { container } = getComponent({ forExport: true });
fireEvent.mouseEnter(getTile(container));
expect(container.querySelector(".mx_MessageActionBar")).toBeNull();
});
it("does not render the message action bar on hover while editing", () => {
const { container } = getComponent({ editState: {} as EventTileProps["editState"] });
fireEvent.mouseEnter(getTile(container));
expect(container.querySelector(".mx_MessageActionBar")).toBeNull();
});
});
describe("context menu", () => {
it("renders the message context menu when the event line is right-clicked", async () => {
const { container } = getComponent();
fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 });
expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument();
});
it("marks the tile selected when the context menu is open", async () => {
const { container } = getComponent();
const tile = getTile(container);
fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 });
expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument();
expect(tile).toHaveClass("mx_EventTile_selected");
});
it("shows the timestamp while the context menu is open", async () => {
mxEvent = makeTimestampedMessage();
const { container } = getComponent();
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 });
expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument();
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
});
it("does not render the message context menu while editing", () => {
const { container } = getComponent({ editState: {} as EventTileProps["editState"] });
expect(container.querySelector(".mx_EventTile_line")).toBeNull();
expect(screen.queryByTestId("mx_MessageContextMenu")).toBeNull();
});
it("does not override the native browser context menu for links", () => {
const { container } = getComponent();
jest.spyOn(PlatformPeg, "get").mockReturnValue({
allowOverridingNativeContextMenus: () => false,
} as ReturnType<typeof PlatformPeg.get>);
const link = document.createElement("a");
link.href = "https://example.org/";
getLine(container).appendChild(link);
const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 1, clientY: 2 });
link.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(screen.queryByTestId("mx_MessageContextMenu")).toBeNull();
});
});
describe("reply chain", () => {
it("marks non-reply events as having no reply", () => {
const { container } = getComponent();
expect(getTile(container)).toHaveAttribute("data-has-reply", "false");
expect(container.querySelector(".mx_ReplyChain_wrapper")).toBeNull();
});
it("marks reply events as having a reply chain", () => {
const replyEvent = makeReplyEvent(room.roomId);
const { container } = getComponent({ mxEvent: replyEvent });
expect(getTile(container)).toHaveAttribute("data-has-reply", "true");
expect(container.querySelector(".mx_ReplyChain_wrapper")).not.toBeNull();
});
it("does not render the reply chain for redacted reply events", () => {
const replyEvent = makeReplyEvent(room.roomId);
jest.spyOn(replyEvent, "isRedacted").mockReturnValue(true);
const { container } = getComponent({ mxEvent: replyEvent });
expect(getTile(container)).toHaveAttribute("data-has-reply", "false");
expect(container.querySelector(".mx_ReplyChain_wrapper")).toBeNull();
});
});
describe("EventTile thread summary", () => {
beforeEach(() => {
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
@ -150,6 +773,43 @@ describe("EventTile", () => {
});
});
describe("search thread info", () => {
it("renders search thread info for events in a thread", () => {
const threadEvent = makeThreadReplyEvent(room.roomId);
const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Search);
expect(container.querySelector(".mx_ThreadSummary_icon")).not.toBeNull();
expect(container.querySelector(".mx_ThreadSummary_icon")).toHaveTextContent("From a thread");
});
it("renders search thread info as a link when a highlight link is provided", () => {
const threadEvent = makeThreadReplyEvent(room.roomId);
const { container } = getComponent(
{ mxEvent: threadEvent, highlightLink: "https://example.org/thread" },
TimelineRenderingType.Search,
);
const threadInfo = container.querySelector<HTMLAnchorElement>("a.mx_ThreadSummary_icon");
expect(threadInfo).not.toBeNull();
expect(threadInfo).toHaveAttribute("href", "https://example.org/thread");
});
it("renders search thread info as text when no highlight link is provided", () => {
const threadEvent = makeThreadReplyEvent(room.roomId);
const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Search);
const threadInfo = container.querySelector(".mx_ThreadSummary_icon");
expect(threadInfo?.tagName).toBe("P");
});
it("does not render search thread info outside search timelines", () => {
const threadEvent = makeThreadReplyEvent(room.roomId);
const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Room);
expect(container.querySelector(".mx_ThreadSummary_icon")).toBeNull();
});
});
describe("EventTile renderingType: ThreadsList", () => {
it("shows an unread notification badge", () => {
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
@ -246,13 +906,6 @@ describe("EventTile", () => {
});
describe("EventTile in the right panel", () => {
beforeAll(() => {
const dmRoomMap: DMRoomMap = {
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
});
it("renders the room name for notifications", () => {
const { container } = getComponent({}, TimelineRenderingType.Notification);
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent(
@ -600,6 +1253,43 @@ describe("EventTile", () => {
expect(isHighlighted(container)).toBeFalsy();
});
it("does not highlight when exporting", () => {
mocked(client.getPushActionsForEvent).mockReturnValue({
notify: true,
tweaks: { [TweakName.Highlight]: true },
});
const { container } = getComponent({ forExport: true });
expect(client.getPushActionsForEvent).not.toHaveBeenCalled();
expect(isHighlighted(container)).toBeFalsy();
});
it.each([TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])(
"does not highlight in %s timelines",
(renderingType) => {
mocked(client.getPushActionsForEvent).mockReturnValue({
notify: true,
tweaks: { [TweakName.Highlight]: true },
});
const { container } = getComponent({}, renderingType);
expect(client.getPushActionsForEvent).not.toHaveBeenCalled();
expect(isHighlighted(container)).toBeFalsy();
},
);
it("does not highlight events sent by the current user", () => {
mocked(client.getPushActionsForEvent).mockReturnValue({
notify: true,
tweaks: { [TweakName.Highlight]: true },
});
const ownEvent = makeOwnMessage();
const { container } = getComponent({ mxEvent: ownEvent });
expect(client.getPushActionsForEvent).toHaveBeenCalledWith(ownEvent);
expect(isHighlighted(container)).toBeFalsy();
});
it("highlights when message's push actions have a highlight tweak", () => {
mocked(client.getPushActionsForEvent).mockReturnValue({
notify: true,

View File

@ -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<void> => {
await userEvent.click(screen.getByLabelText("More options"));
@ -469,7 +473,9 @@ function wrapAndRender(
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...context}>
<MessageComposer {...defaultProps} {...props} />
<RoomUploadContext.Provider value={{} as RoomUploadViewModel}>
<MessageComposer {...defaultProps} {...props} />
</RoomUploadContext.Provider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
);

View File

@ -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(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>{component}</ScopedRoomContextProvider>
<ScopedRoomContextProvider {...defaultRoomContext}>
<RoomUploadContextProvider>{component}</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
}

View File

@ -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("<SendMessageComposer/>", () => {
};
const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => (
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}>
<SendMessageComposer {...defaultProps} {...props} />
<ScopedRoomContextProvider room={mockRoom} {...roomContext}>
<RoomUploadContextProvider>
<SendMessageComposer {...defaultProps} {...props} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
);
@ -435,7 +438,11 @@ describe("<SendMessageComposer/>", () => {
const { container } = render(
<MatrixClientContext.Provider value={cli}>
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
<ScopedRoomContextProvider {...({ room } as unknown as RoomContextType)}>
<RoomUploadContextProvider>
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);

View File

@ -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(
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}>
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
<RoomUploadContextProvider>
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@ -62,7 +65,9 @@ describe("EditWysiwygComposer", () => {
rerender(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext} room={undefined}>
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
<RoomUploadContextProvider>
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@ -273,7 +278,9 @@ describe("EditWysiwygComposer", () => {
render(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
<RoomUploadContextProvider>
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
</RoomUploadContextProvider>
<Emoji menuPosition={{ chevronFace: ChevronFace.Top }} />
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,

View File

@ -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(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>
<SendWysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
isRichTextEnabled={isRichTextEnabled}
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
placeholder={placeholder}
e2eStatus={e2eStatus}
/>
<RoomUploadContextProvider>
<SendWysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
isRichTextEnabled={isRichTextEnabled}
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
placeholder={placeholder}
e2eStatus={e2eStatus}
/>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);

View File

@ -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(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>
<WysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
initialContent={initialContent}
/>
<RoomUploadContextProvider>
<WysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
initialContent={initialContent}
/>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
@ -561,19 +564,21 @@ describe("WysiwygComposer", () => {
return render(
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}>
<ComposerContext.Provider
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
>
<WysiwygComposer
onChange={jest.fn()}
onSend={jest.fn()}
initialContent={
roomContext.room && _editorStateTransfer
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
: undefined
}
/>
</ComposerContext.Provider>
<RoomUploadContextProvider>
<ComposerContext.Provider
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
>
<WysiwygComposer
onChange={jest.fn()}
onSend={jest.fn()}
initialContent={
roomContext.room && _editorStateTransfer
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
: undefined
}
/>
</ComposerContext.Provider>
</RoomUploadContextProvider>
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);

View File

@ -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<RoomUploadViewModel> as MockedObject<RoomUploadViewModel>;
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("<div>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(`<img src="blob:" />`),
},
});
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(`<img src="blob:" />`),
},
});
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(`<img src="blob:" />`),
},
});
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(`<img src="blob:" />`),
},
});
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);

View File

@ -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<string, unknown> = {}): MatrixEvent =>
new MatrixEvent({
type,
event_id: "$event:example.org",
sender: "@alice:example.org",
content,
});
const createClickEvent = (): MouseEvent<HTMLButtonElement> =>
({
preventDefault: jest.fn(),
}) as unknown as MouseEvent<HTMLButtonElement>;
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));
});
});

View File

@ -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<MatrixClient>;
let room: MockedObject<Room>;
let dis: MockedObject<MatrixDispatcher>;
beforeEach(() => {
jest.clearAllMocks();
client = stubClient() as MockedObject<MatrixClient>;
room = mkStubRoom("!room", undefined, undefined) as MockedObject<Room>;
dis = {
dispatch: jest.fn(),
} as Partial<MatrixDispatcher> as MockedObject<MatrixDispatcher>;
});
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,
);
});
});
});

View File

@ -41,6 +41,9 @@
"preferences": "Preferences",
"state_encryption_enabled": "Experimental state encryption enabled"
},
"devtools": {
"toggle_event": "toggle event"
},
"keyboard": {
"shift": "Shift"
},

View File

@ -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";

View File

@ -0,0 +1,64 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.content {
display: flex;
color: var(--cpd-color-text-secondary);
font-size: var(--cpd-font-size-body-xs);
width: 100%;
overflow-x: auto;
line-height: normal;
}
.source {
flex: 1;
}
pre.source {
line-height: 1.2;
margin: 3.5px 0;
}
.toggle {
--ViewSourceEvent_toggle-size: 16px;
appearance: none;
border: 0;
padding: 0;
background: none;
color: var(--cpd-color-icon-accent-primary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
visibility: hidden;
width: var(--ViewSourceEvent_toggle-size);
min-width: var(--ViewSourceEvent_toggle-size);
height: var(--ViewSourceEvent_toggle-size);
}
.content:hover .toggle,
.toggle:focus-visible {
visibility: visible;
}
.toggle:focus-visible {
outline: 2px solid var(--cpd-color-border-focused);
outline-offset: 2px;
border-radius: var(--cpd-space-1x);
}
.toggle svg {
width: var(--ViewSourceEvent_toggle-size);
height: var(--ViewSourceEvent_toggle-size);
}
.expanded .toggle {
align-self: flex-end;
}

View File

@ -0,0 +1,77 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import {
ViewSourceEventView,
type ViewSourceEventViewActions,
type ViewSourceEventViewSnapshot,
} from "./ViewSourceEventView";
type ViewSourceEventViewProps = ViewSourceEventViewSnapshot &
ViewSourceEventViewActions & {
className?: string;
expandedClassName?: string;
};
const source = JSON.stringify(
{
type: "m.room.message",
sender: "@alice:example.org",
content: {
msgtype: "m.text",
body: "Hello",
},
},
null,
4,
);
const ViewSourceEventViewWrapperImpl = ({
onToggle,
className,
expandedClassName,
...snapshot
}: ViewSourceEventViewProps): JSX.Element => {
const vm = useMockedViewModel(snapshot, { onToggle });
return <ViewSourceEventView vm={vm} className={className} expandedClassName={expandedClassName} />;
};
const ViewSourceEventViewWrapper = withViewDocs(ViewSourceEventViewWrapperImpl, ViewSourceEventView);
const meta = {
title: "Timeline/Timeline Event/ViewSourceEventView",
component: ViewSourceEventViewWrapper,
tags: ["autodocs"],
args: {
expanded: false,
preview: '{ "type": m.room.message }',
source,
onToggle: fn(),
className: "",
expandedClassName: "",
},
} satisfies Meta<typeof ViewSourceEventViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Expanded: Story = {
args: {
expanded: true,
},
};

View File

@ -0,0 +1,107 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { composeStories } from "@storybook/react-vite";
import { fireEvent, render, screen } from "@test-utils";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { MockViewModel } from "../../../../../core/viewmodel";
import {
ViewSourceEventView,
type ViewSourceEventViewActions,
type ViewSourceEventViewModel,
type ViewSourceEventViewSnapshot,
} from "./ViewSourceEventView";
import * as stories from "./ViewSourceEventView.stories";
const { Default, Expanded } = composeStories(stories);
class TestViewSourceEventViewModel
extends MockViewModel<ViewSourceEventViewSnapshot>
implements ViewSourceEventViewActions
{
public constructor(
snapshot: ViewSourceEventViewSnapshot,
public onToggle: ViewSourceEventViewActions["onToggle"],
) {
super(snapshot);
}
}
const createVm = (
snapshot: Partial<ViewSourceEventViewSnapshot> = {},
onToggle: ViewSourceEventViewActions["onToggle"] = vi.fn(),
): ViewSourceEventViewModel =>
new TestViewSourceEventViewModel(
{
expanded: false,
preview: '{ "type": m.room.message }',
source: '{\n "type": "m.room.message"\n}',
...snapshot,
},
onToggle,
) as ViewSourceEventViewModel;
describe("ViewSourceEventView", () => {
const getToggleButton = (container: HTMLElement): HTMLButtonElement => {
const button = container.querySelector<HTMLButtonElement>('button[aria-label="toggle event"]');
if (!button) {
throw new Error("Expected view source toggle button to be rendered");
}
return button;
};
it("renders the default story", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
expect(screen.getByText('{ "type": m.room.message }')).toBeInTheDocument();
expect(getToggleButton(container)).toBeInTheDocument();
});
it("renders the expanded story", () => {
const { container } = render(<Expanded />);
expect(container).toMatchSnapshot();
expect(screen.getByText(/"sender": "@alice:example\.org"/)).toBeInTheDocument();
});
it("invokes the toggle action", () => {
const onToggle = vi.fn();
const vm = createVm({}, onToggle);
const { container } = render(<ViewSourceEventView vm={vm} />);
fireEvent.click(getToggleButton(container));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("applies custom class names to the root element", () => {
const vm = createVm({ expanded: true });
const { container } = render(
<ViewSourceEventView vm={vm} className="custom-source" expandedClassName="custom-expanded" />,
);
expect(container.firstChild).toHaveClass("custom-source", "custom-expanded");
});
it("forwards the provided ref to the root span", () => {
const ref = React.createRef<HTMLSpanElement>();
const vm = createVm();
render(<ViewSourceEventView vm={vm} ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
});
});

View File

@ -0,0 +1,98 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type MouseEventHandler, type Ref } from "react";
import classNames from "classnames";
import { CollapseIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import styles from "./ViewSourceEventView.module.css";
export interface ViewSourceEventViewSnapshot {
/**
* Whether the full event source is visible.
*/
expanded: boolean;
/**
* Collapsed one-line event summary.
*/
preview: string;
/**
* Pretty-printed event source.
*/
source: string;
}
export interface ViewSourceEventViewActions {
/**
* Invoked when the user expands or collapses the event source.
*/
onToggle: MouseEventHandler<HTMLButtonElement>;
}
export type ViewSourceEventViewModel = ViewModel<ViewSourceEventViewSnapshot, ViewSourceEventViewActions>;
interface ViewSourceEventViewProps {
/**
* ViewModel providing the event source snapshot and actions.
*/
vm: ViewSourceEventViewModel;
/**
* Optional CSS class names applied to the root element.
*/
className?: string;
/**
* Optional CSS class name applied to the root element while expanded.
*/
expandedClassName?: string;
/**
* Optional ref forwarded to the root element.
*/
ref?: Ref<HTMLSpanElement>;
}
/**
* Renders a collapsible event source preview for hidden timeline events.
*/
export function ViewSourceEventView({
vm,
className,
expandedClassName,
ref,
}: Readonly<ViewSourceEventViewProps>): JSX.Element {
const { expanded, preview, source } = useViewModel(vm);
const _t = useI18n().translate;
const toggleLabel = _t("devtools|toggle_event");
const classes = classNames(
styles.content,
className,
{
[styles.expanded]: expanded,
},
expanded && expandedClassName,
);
return (
<span className={classes} ref={ref}>
{expanded ? (
<pre className={styles.source}>{source}</pre>
) : (
<code className={styles.source}>{preview}</code>
)}
<Tooltip description={toggleLabel} placement="top">
<button type="button" aria-label={toggleLabel} className={styles.toggle} onClick={vm.onToggle}>
{expanded ? <CollapseIcon /> : <ExpandIcon />}
</button>
</Tooltip>
</span>
);
}

View File

@ -0,0 +1,70 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ViewSourceEventView > renders the default story 1`] = `
<div>
<span
class="ViewSourceEventView-module_content"
>
<code
class="ViewSourceEventView-module_source"
>
{ "type": m.room.message }
</code>
<button
aria-label="toggle event"
class="ViewSourceEventView-module_toggle"
type="button"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 3.997a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 20 3h-8a1 1 0 1 0 0 2h5.586L5 17.586V12a1 1 0 1 0-2 0v8.003a1 1 0 0 0 .29.702l.005.004c.18.18.43.291.705.291h8a1 1 0 1 0 0-2H6.414L19 6.414V12a1 1 0 1 0 2 0z"
/>
</svg>
</button>
</span>
</div>
`;
exports[`ViewSourceEventView > renders the expanded story 1`] = `
<div>
<span
class="ViewSourceEventView-module_content ViewSourceEventView-module_expanded"
>
<pre
class="ViewSourceEventView-module_source"
>
{
"type": "m.room.message",
"sender": "@alice:example.org",
"content": {
"msgtype": "m.text",
"body": "Hello"
}
}
</pre>
<button
aria-label="toggle event"
class="ViewSourceEventView-module_toggle"
type="button"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</button>
</span>
</div>
`;

View File

@ -0,0 +1,10 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export * from "./ViewSourceEventView";

25
pnpm-lock.yaml generated
View File

@ -463,7 +463,7 @@ importers:
version: 1.0.3
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9
matrix-widget-api:
specifier: ^1.17.0
version: 1.17.0
@ -1530,7 +1530,6 @@ packages:
'@babel/plugin-proposal-private-methods@7.18.6':
resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==}
engines: {node: '>=6.9.0'}
deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.
peerDependencies:
'@babel/core': ^7.0.0-0
@ -2822,7 +2821,6 @@ packages:
'@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
deprecated: Use @eslint/config-array instead
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
@ -2830,7 +2828,6 @@ packages:
'@humanwhocodes/object-schema@2.0.3':
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
deprecated: Use @eslint/object-schema instead
'@iconify-json/simple-icons@1.2.75':
resolution: {integrity: sha512-KvcCUbvcBWb0sbqLIxHoY8z5/piXY08wcY9gfMhF+ph3AfzGMaSmZFkUY71HSXAljQngXkgs4bdKdekO0HQWvg==}
@ -5752,7 +5749,6 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@ -6649,7 +6645,6 @@ packages:
boolean@3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
brace-expansion@1.1.13:
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
@ -8183,7 +8178,6 @@ packages:
eslint@8.57.1:
resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
espree@10.4.0:
@ -8611,7 +8605,6 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@13.0.6:
@ -8620,7 +8613,6 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-agent@3.0.0:
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
@ -8985,7 +8977,6 @@ packages:
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@ -9927,9 +9918,9 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32}
version: 41.4.0
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9}
version: 41.5.0
engines: {node: '>=22.0.0'}
matrix-web-i18n@3.6.0:
@ -11365,7 +11356,6 @@ packages:
react-beautiful-dnd@13.1.1:
resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==}
deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672'
peerDependencies:
react: ^16.8.5 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0
@ -11701,12 +11691,10 @@ packages:
rimraf@2.6.3:
resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@6.1.3:
@ -12903,7 +12891,6 @@ packages:
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@11.1.1:
@ -12916,7 +12903,6 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-to-istanbul@9.3.0:
@ -13270,7 +13256,6 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
@ -23486,7 +23471,7 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9:
dependencies:
'@babel/runtime': 7.29.2
'@matrix-org/matrix-sdk-crypto-wasm': 18.2.0