mirror of
https://github.com/Novaloop-AG/healthchat-web.git
synced 2026-11-04 01:19:41 +00:00
Rework Upload internals to use MVVM (#33307)
* Initial reword of upload to MVVM. * Update tests * More incremental improvements * Refactor tests to use helper method for composer uploads. * Add drag and drop tests * lint * Add commentary * fixup test * More precise selector * Retarget uploads * lint * fixup * one more type * update snap * Fixup composerUploadFiles * fix import * lint * Copy and paste fixes too * Add tests for pasting * Add tests for pasting files. * Remove redundant fn * rm comment * tidy up * Test cleanup * More clean up * another fix * Use condensed version * Cleanup tests * more cleaning * last bity * s/throw Error/throw new Error/
This commit is contained in:
@@ -751,7 +751,7 @@ test.describe("Timeline", () => {
|
|||||||
await expect(page.locator(".mx_EventTile[data-layout=irc] .mx_ViewSourceEvent_expanded")).toBeVisible();
|
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 page.goto(`/#/room/${room.roomId}`);
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
@@ -760,12 +760,7 @@ test.describe("Timeline", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// Upload a file from the message composer
|
// Upload a file from the message composer
|
||||||
await page
|
await app.composerUploadFiles("room", getSampleFilePath("matrix-org-client-versions.json"));
|
||||||
.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();
|
|
||||||
|
|
||||||
// Wait until the file is sent
|
// Wait until the file is sent
|
||||||
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
|
await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible();
|
||||||
|
|||||||
@@ -169,8 +169,7 @@ export class ElementAppPage {
|
|||||||
): ReturnType<Locator["setInputFiles"]> {
|
): ReturnType<Locator["setInputFiles"]> {
|
||||||
const input = this.page
|
const input = this.page
|
||||||
.locator(location === "room" ? ".mx_RoomView_body" : ".mx_RightPanel")
|
.locator(location === "room" ? ".mx_RoomView_body" : ".mx_RightPanel")
|
||||||
.getByRole("region", { name: "Message composer" })
|
.getByTestId("room-upload-context-input");
|
||||||
.locator("input[type='file']");
|
|
||||||
return input.setInputFiles(...params);
|
return input.setInputFiles(...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -607,7 +607,7 @@ export default class ContentMessages {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
// Otherwise we failed to thumbnail, fall back to uploading an m.file
|
// 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;
|
content.msgtype = MsgType.File;
|
||||||
}
|
}
|
||||||
} else if (file.type.startsWith("audio/")) {
|
} else if (file.type.startsWith("audio/")) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024 New Vector Ltd.
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
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 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 { _t } from "../../languageHandler";
|
||||||
import UploadBigSvg from "../../../res/img/upload-big.svg";
|
import UploadBigSvg from "../../../res/img/upload-big.svg";
|
||||||
import { useRoomState } from "../../hooks/useRoomState.ts";
|
import { useRoomUploadViewModel } from "../../viewmodels/room/RoomUploadViewModel";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
|
||||||
parent: HTMLElement | null;
|
parent: HTMLElement | null;
|
||||||
onFileDrop(this: void, dataTransfer: DataTransfer): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
@@ -24,15 +23,16 @@ interface IState {
|
|||||||
counter: number;
|
counter: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
const FileDropTarget: React.FC<IProps> = ({ parent }) => {
|
||||||
const [state, setState] = useState<IState>({
|
const [state, setState] = useState<IState>({
|
||||||
dragging: false,
|
dragging: false,
|
||||||
counter: 0,
|
counter: 0,
|
||||||
});
|
});
|
||||||
const hasPermission = useRoomState(room, (state) => state.maySendMessage(room.client.getUserId()!));
|
const vm = useRoomUploadViewModel();
|
||||||
|
const { mayUpload } = useViewModel(vm);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasPermission || !parent || parent.ondrop) return;
|
if (!mayUpload || !parent || parent.ondrop) return;
|
||||||
|
|
||||||
const onDragEnter = (ev: DragEvent): void => {
|
const onDragEnter = (ev: DragEvent): void => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
@@ -83,7 +83,7 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (!ev.dataTransfer) return;
|
if (!ev.dataTransfer) return;
|
||||||
onFileDrop(ev.dataTransfer);
|
void vm.initiateViaDataTransfer(ev.dataTransfer);
|
||||||
|
|
||||||
setState((state) => ({
|
setState((state) => ({
|
||||||
dragging: false,
|
dragging: false,
|
||||||
@@ -106,9 +106,9 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
|||||||
parent?.removeEventListener("dragenter", onDragEnter);
|
parent?.removeEventListener("dragenter", onDragEnter);
|
||||||
parent?.removeEventListener("dragleave", onDragLeave);
|
parent?.removeEventListener("dragleave", onDragLeave);
|
||||||
};
|
};
|
||||||
}, [parent, onFileDrop, hasPermission]);
|
}, [parent, mayUpload, vm]);
|
||||||
|
|
||||||
if (hasPermission && state.dragging) {
|
if (mayUpload && state.dragging) {
|
||||||
return (
|
return (
|
||||||
<div className="mx_FileDropTarget">
|
<div className="mx_FileDropTarget">
|
||||||
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />
|
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
|
|||||||
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
|
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
|
||||||
import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts";
|
import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts";
|
||||||
import { ModuleApi } from "../../modules/Api.ts";
|
import { ModuleApi } from "../../modules/Api.ts";
|
||||||
|
import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
||||||
|
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
@@ -302,7 +303,6 @@ interface LocalRoomViewProps {
|
|||||||
resizeNotifier: ResizeNotifier;
|
resizeNotifier: ResizeNotifier;
|
||||||
permalinkCreator: RoomPermalinkCreator;
|
permalinkCreator: RoomPermalinkCreator;
|
||||||
roomView: RefObject<HTMLElement | null>;
|
roomView: RefObject<HTMLElement | null>;
|
||||||
onFileDrop: (dataTransfer: DataTransfer) => Promise<void>;
|
|
||||||
mainSplitContentType: MainSplitContentType;
|
mainSplitContentType: MainSplitContentType;
|
||||||
e2eStatus?: E2EStatus;
|
e2eStatus?: E2EStatus;
|
||||||
}
|
}
|
||||||
@@ -343,17 +343,19 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
|
|||||||
<div className="mx_RoomView mx_RoomView--local">
|
<div className="mx_RoomView mx_RoomView--local">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RoomHeader room={room} />
|
<RoomHeader room={room} />
|
||||||
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
|
<RoomUploadContextProvider>
|
||||||
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} room={room} />
|
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
|
||||||
<div className="mx_RoomView_timeline">
|
<FileDropTarget parent={props.roomView.current} />
|
||||||
<ScrollPanel className="mx_RoomView_messagePanel">
|
<div className="mx_RoomView_timeline">
|
||||||
{encryptionTile}
|
<ScrollPanel className="mx_RoomView_messagePanel">
|
||||||
<NewRoomIntro />
|
{encryptionTile}
|
||||||
</ScrollPanel>
|
<NewRoomIntro />
|
||||||
</div>
|
</ScrollPanel>
|
||||||
{statusBar}
|
</div>
|
||||||
{composer}
|
{statusBar}
|
||||||
</main>
|
{composer}
|
||||||
|
</main>
|
||||||
|
</RoomUploadContextProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</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 => {
|
private onMeasurement = (narrow: boolean): void => {
|
||||||
this.setState({ narrow });
|
this.setState({ narrow });
|
||||||
};
|
};
|
||||||
@@ -2169,7 +2158,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
resizeNotifier={this.context.resizeNotifier}
|
resizeNotifier={this.context.resizeNotifier}
|
||||||
permalinkCreator={this.permalinkCreator}
|
permalinkCreator={this.permalinkCreator}
|
||||||
roomView={this.roomView}
|
roomView={this.roomView}
|
||||||
onFileDrop={this.onFileDrop}
|
|
||||||
mainSplitContentType={this.state.mainSplitContentType}
|
mainSplitContentType={this.state.mainSplitContentType}
|
||||||
/>
|
/>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
@@ -2673,16 +2661,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
case MainSplitContentType.Timeline:
|
case MainSplitContentType.Timeline:
|
||||||
mainSplitContentClassName = "mx_MainSplit_timeline";
|
mainSplitContentClassName = "mx_MainSplit_timeline";
|
||||||
mainSplitBody = (
|
mainSplitBody = (
|
||||||
<>
|
<RoomUploadContextProvider>
|
||||||
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
|
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
|
||||||
{auxPanel}
|
{auxPanel}
|
||||||
{pinnedMessageBanner}
|
{pinnedMessageBanner}
|
||||||
<main className={timelineClasses} data-testid="timeline">
|
<main className={timelineClasses} data-testid="timeline">
|
||||||
<FileDropTarget
|
<FileDropTarget parent={this.roomView.current} />
|
||||||
parent={this.roomView.current}
|
|
||||||
onFileDrop={this.onFileDrop}
|
|
||||||
room={this.state.room}
|
|
||||||
/>
|
|
||||||
{topUnreadMessagesBar}
|
{topUnreadMessagesBar}
|
||||||
{jumpToBottom}
|
{jumpToBottom}
|
||||||
{messagePanel}
|
{messagePanel}
|
||||||
@@ -2691,7 +2675,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
{statusBarArea}
|
{statusBarArea}
|
||||||
{previewBar}
|
{previewBar}
|
||||||
{messageComposer}
|
{messageComposer}
|
||||||
</>
|
</RoomUploadContextProvider>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case MainSplitContentType.MaximisedWidget:
|
case MainSplitContentType.MaximisedWidget:
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import TimelinePanel from "./TimelinePanel";
|
|||||||
import dis from "../../dispatcher/dispatcher";
|
import dis from "../../dispatcher/dispatcher";
|
||||||
import { type ActionPayload } from "../../dispatcher/payloads";
|
import { type ActionPayload } from "../../dispatcher/payloads";
|
||||||
import { Action } from "../../dispatcher/actions";
|
import { Action } from "../../dispatcher/actions";
|
||||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
|
||||||
import { type E2EStatus } from "../../utils/ShieldUtils";
|
import { type E2EStatus } from "../../utils/ShieldUtils";
|
||||||
import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
||||||
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
|
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||||
@@ -51,6 +50,7 @@ import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/paylo
|
|||||||
import Heading from "../views/typography/Heading";
|
import Heading from "../views/typography/Heading";
|
||||||
import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
|
import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
|
||||||
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
|
||||||
|
import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
||||||
|
|
||||||
interface IProps {
|
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 {
|
private get threadRelation(): IEventRelation {
|
||||||
const relation: IEventRelation = {
|
const relation: IEventRelation = {
|
||||||
rel_type: THREAD_RELATION_TYPE.name,
|
rel_type: THREAD_RELATION_TYPE.name,
|
||||||
@@ -393,7 +377,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
|||||||
|
|
||||||
timeline = (
|
timeline = (
|
||||||
<>
|
<>
|
||||||
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
|
<FileDropTarget parent={this.card.current} />
|
||||||
<EventPresentationContextProvider layout={layout}>
|
<EventPresentationContextProvider layout={layout}>
|
||||||
<TimelinePanel
|
<TimelinePanel
|
||||||
key={this.state.thread.id}
|
key={this.state.thread.id}
|
||||||
@@ -437,38 +421,40 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
|||||||
liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()}
|
liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()}
|
||||||
narrow={this.state.narrow}
|
narrow={this.state.narrow}
|
||||||
>
|
>
|
||||||
<BaseCard
|
<RoomUploadContextProvider threadRelation={this.threadRelation}>
|
||||||
className={classNames("mx_ThreadView mx_ThreadPanel", {
|
<BaseCard
|
||||||
mx_ThreadView_narrow: this.state.narrow,
|
className={classNames("mx_ThreadView mx_ThreadPanel", {
|
||||||
})}
|
mx_ThreadView_narrow: this.state.narrow,
|
||||||
onClose={this.props.onClose}
|
})}
|
||||||
withoutScrollContainer={true}
|
onClose={this.props.onClose}
|
||||||
header={this.renderThreadViewHeader()}
|
withoutScrollContainer={true}
|
||||||
ref={this.card}
|
header={this.renderThreadViewHeader()}
|
||||||
onKeyDown={this.onKeyDown}
|
ref={this.card}
|
||||||
onBack={(ev: ButtonEvent) => {
|
onKeyDown={this.onKeyDown}
|
||||||
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
|
onBack={(ev: ButtonEvent) => {
|
||||||
}}
|
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
|
||||||
>
|
}}
|
||||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
>
|
||||||
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
|
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||||
|
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
|
||||||
|
|
||||||
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
||||||
<UploadBar room={this.props.room} relation={threadRelation} />
|
<UploadBar room={this.props.room} relation={threadRelation} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{this.state.thread?.timelineSet && (
|
{this.state.thread?.timelineSet && (
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
room={this.props.room}
|
room={this.props.room}
|
||||||
resizeNotifier={this.props.resizeNotifier}
|
resizeNotifier={this.props.resizeNotifier}
|
||||||
relation={threadRelation}
|
relation={threadRelation}
|
||||||
replyToEvent={this.state.replyToEvent}
|
replyToEvent={this.state.replyToEvent}
|
||||||
permalinkCreator={this.props.permalinkCreator}
|
permalinkCreator={this.props.permalinkCreator}
|
||||||
e2eStatus={this.props.e2eStatus}
|
e2eStatus={this.props.e2eStatus}
|
||||||
compact={true}
|
compact={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</BaseCard>
|
</BaseCard>
|
||||||
|
</RoomUploadContextProvider>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
|
|||||||
import Measured from "../elements/Measured";
|
import Measured from "../elements/Measured";
|
||||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||||
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx";
|
||||||
|
import { RoomUploadContextProvider } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider";
|
import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -214,47 +215,49 @@ export default class TimelineCard extends React.Component<IProps, IState> {
|
|||||||
header={_t("right_panel|video_room_chat|title")}
|
header={_t("right_panel|video_room_chat|title")}
|
||||||
ref={this.card}
|
ref={this.card}
|
||||||
>
|
>
|
||||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
<RoomUploadContextProvider>
|
||||||
<div className="mx_TimelineCard_timeline">
|
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||||
{jumpToBottom}
|
<div className="mx_TimelineCard_timeline">
|
||||||
<EventPresentationContextProvider layout={layout}>
|
{jumpToBottom}
|
||||||
<TimelinePanel
|
<EventPresentationContextProvider layout={layout}>
|
||||||
ref={this.timelinePanel}
|
<TimelinePanel
|
||||||
showReadReceipts={this.state.showReadReceipts}
|
ref={this.timelinePanel}
|
||||||
manageReadReceipts={true}
|
showReadReceipts={this.state.showReadReceipts}
|
||||||
manageReadMarkers={false} // No RM support in the TimelineCard
|
manageReadReceipts={true}
|
||||||
sendReadReceiptOnLoad={true}
|
manageReadMarkers={false} // No RM support in the TimelineCard
|
||||||
timelineSet={this.props.timelineSet}
|
sendReadReceiptOnLoad={true}
|
||||||
showUrlPreview={this.context.showUrlPreview}
|
timelineSet={this.props.timelineSet}
|
||||||
// The right panel timeline (and therefore threads) don't support IRC layout at this time
|
showUrlPreview={this.context.showUrlPreview}
|
||||||
layout={layout}
|
// The right panel timeline (and therefore threads) don't support IRC layout at this time
|
||||||
hideThreadedMessages={false}
|
layout={layout}
|
||||||
hidden={false}
|
hideThreadedMessages={false}
|
||||||
showReactions={true}
|
hidden={false}
|
||||||
className="mx_RoomView_messagePanel"
|
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}
|
permalinkCreator={this.props.permalinkCreator}
|
||||||
membersLoaded={true}
|
e2eStatus={this.props.e2eStatus}
|
||||||
editState={this.state.editState}
|
compact={true}
|
||||||
eventId={this.state.initialEventId}
|
|
||||||
highlightedEventId={highlightedEventId}
|
|
||||||
onScroll={this.onScroll}
|
|
||||||
/>
|
/>
|
||||||
</EventPresentationContextProvider>
|
)}
|
||||||
</div>
|
</RoomUploadContextProvider>
|
||||||
|
|
||||||
{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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</BaseCard>
|
</BaseCard>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024 New Vector Ltd.
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
THREAD_RELATION_TYPE,
|
THREAD_RELATION_TYPE,
|
||||||
M_POLL_START,
|
M_POLL_START,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} 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 {
|
import {
|
||||||
AttachmentIcon,
|
AttachmentIcon,
|
||||||
MicOnIcon,
|
MicOnIcon,
|
||||||
@@ -27,22 +28,19 @@ import {
|
|||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { CollapsibleButton } from "./CollapsibleButton";
|
import { CollapsibleButton } from "./CollapsibleButton";
|
||||||
import { type MenuProps } from "../../structures/ContextMenu";
|
import { type MenuProps } from "../../structures/ContextMenu";
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
|
||||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||||
import { LocationButton } from "../location";
|
import { LocationButton } from "../location";
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import PollCreateDialog from "../elements/PollCreateDialog";
|
import PollCreateDialog from "../elements/PollCreateDialog";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import ContentMessages from "../../../ContentMessages";
|
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
|
||||||
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
|
||||||
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
|
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
|
||||||
import { EmojiButton } from "./EmojiButton";
|
import { EmojiButton } from "./EmojiButton";
|
||||||
import { filterBoolean } from "../../../utils/arrays";
|
import { filterBoolean } from "../../../utils/arrays";
|
||||||
import { useSettingValue } from "../../../hooks/useSettings";
|
import { useSettingValue } from "../../../hooks/useSettings";
|
||||||
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
|
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
|
||||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
||||||
|
import { useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
addEmoji: (emoji: string) => boolean;
|
addEmoji: (emoji: string) => boolean;
|
||||||
@@ -126,7 +124,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UploadButtonContextProvider roomId={room.roomId} relation={props.relation}>
|
<>
|
||||||
{mainButtons}
|
{mainButtons}
|
||||||
{moreButtons.length > 0 && (
|
{moreButtons.length > 0 && (
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
@@ -149,7 +147,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
|||||||
</OverflowMenuContext.Provider>
|
</OverflowMenuContext.Provider>
|
||||||
</IconizedContextMenu>
|
</IconizedContextMenu>
|
||||||
)}
|
)}
|
||||||
</UploadButtonContextProvider>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -168,79 +166,13 @@ function uploadButton(): ReactElement {
|
|||||||
return <UploadButton key="controls_upload" />;
|
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
|
// Must be rendered within an UploadButtonContextProvider
|
||||||
const UploadButton: React.FC = () => {
|
const UploadButton: React.FC = () => {
|
||||||
const overflowMenuCloser = useContext(OverflowMenuContext);
|
const overflowMenuCloser = useContext(OverflowMenuContext);
|
||||||
const uploadButtonFn = useContext(UploadButtonContext);
|
const vm = useRoomUploadViewModel();
|
||||||
|
|
||||||
const onClick = (): void => {
|
const onClick = (): void => {
|
||||||
uploadButtonFn?.();
|
vm.openUploadDialog();
|
||||||
overflowMenuCloser?.(); // close overflow menu
|
overflowMenuCloser?.(); // close overflow menu
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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 {
|
import {
|
||||||
type MatrixEvent,
|
type MatrixEvent,
|
||||||
type IEventRelation,
|
type IEventRelation,
|
||||||
@@ -37,8 +43,7 @@ import { CommandPartCreator, type Part, type PartCreator, type SerializedPart }
|
|||||||
import { findEditableEvent } from "../../../utils/EventUtils";
|
import { findEditableEvent } from "../../../utils/EventUtils";
|
||||||
import SendHistoryManager from "../../../SendHistoryManager";
|
import SendHistoryManager from "../../../SendHistoryManager";
|
||||||
import { CommandCategories } from "../../../slash-commands/SlashCommands";
|
import { CommandCategories } from "../../../slash-commands/SlashCommands";
|
||||||
import ContentMessages from "../../../ContentMessages";
|
import { useMatrixClientContext, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
||||||
import { withMatrixClientHOC, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
|
||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import { containsEmoji } from "../../../effects/utils";
|
import { containsEmoji } from "../../../effects/utils";
|
||||||
import { CHAT_EFFECTS } from "../../../effects";
|
import { CHAT_EFFECTS } from "../../../effects";
|
||||||
@@ -60,6 +65,7 @@ import { type IDiff } from "../../../editor/diff";
|
|||||||
import { getBlobSafeMimeType } from "../../../utils/blobs";
|
import { getBlobSafeMimeType } from "../../../utils/blobs";
|
||||||
import { EMOJI_REGEX } from "../../../HtmlUtils";
|
import { EMOJI_REGEX } from "../../../HtmlUtils";
|
||||||
import { attachMentions, attachRelation } from "../../../utils/messages";
|
import { attachMentions, attachRelation } from "../../../utils/messages";
|
||||||
|
import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel";
|
||||||
|
|
||||||
// The prefix used when persisting editor drafts to localstorage.
|
// The prefix used when persisting editor drafts to localstorage.
|
||||||
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
|
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
|
||||||
@@ -124,6 +130,7 @@ export function isQuickReaction(model: EditorModel): boolean {
|
|||||||
|
|
||||||
interface ISendMessageComposerProps extends MatrixClientProps {
|
interface ISendMessageComposerProps extends MatrixClientProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
uploadVm: RoomUploadViewModel;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
relation?: IEventRelation;
|
relation?: IEventRelation;
|
||||||
replyToEvent?: MatrixEvent;
|
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
|
// 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.
|
// it puts the filename in as text/plain which we want to ignore.
|
||||||
if (data.files.length && !data.types.includes("text/rtf")) {
|
if (data.files.length && !data.types.includes("text/rtf")) {
|
||||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
this.props.uploadVm.initiateViaDataTransfer(data);
|
||||||
Array.from(data.files),
|
|
||||||
this.props.room.roomId,
|
|
||||||
this.props.relation,
|
|
||||||
this.context.replyToEvent,
|
|
||||||
this.props.mxClient,
|
|
||||||
this.context.timelineRenderingType,
|
|
||||||
);
|
|
||||||
return true; // to skip internal onPaste handler
|
return true; // to skip internal onPaste handler
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,13 +601,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
|||||||
const parts = response.url.split("/");
|
const parts = response.url.split("/");
|
||||||
const filename = parts[parts.length - 1];
|
const filename = parts[parts.length - 1];
|
||||||
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
|
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
|
||||||
ContentMessages.sharedInstance().sendContentToRoom(
|
this.props.uploadVm.initiateViaInputFiles([file]);
|
||||||
file,
|
|
||||||
this.props.room.roomId,
|
|
||||||
this.props.relation,
|
|
||||||
this.props.mxClient,
|
|
||||||
this.context.replyToEvent,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
@@ -660,5 +654,13 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer);
|
function SendMessageComposerWrapped(
|
||||||
export default SendMessageComposerWithMatrixClient;
|
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;
|
||||||
|
|||||||
+4
-2
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { type IEventRelation } from "matrix-js-sdk/src/matrix";
|
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 { useComposerFunctions } from "../hooks/useComposerFunctions";
|
||||||
import { useIsFocused } from "../hooks/useIsFocused";
|
import { useIsFocused } from "../hooks/useIsFocused";
|
||||||
@@ -19,6 +19,7 @@ import { type ComposerFunctions } from "../types";
|
|||||||
import { Editor } from "./Editor";
|
import { Editor } from "./Editor";
|
||||||
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
|
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
|
||||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||||
|
import { RoomUploadContext } from "../../../../../viewmodels/room/RoomUploadViewModel";
|
||||||
|
|
||||||
interface PlainTextComposerProps {
|
interface PlainTextComposerProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -46,6 +47,7 @@ export function PlainTextComposer({
|
|||||||
eventRelation,
|
eventRelation,
|
||||||
}: PlainTextComposerProps): JSX.Element {
|
}: PlainTextComposerProps): JSX.Element {
|
||||||
const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji");
|
const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji");
|
||||||
|
const uploadContext = useContext(RoomUploadContext);
|
||||||
const {
|
const {
|
||||||
ref: editorRef,
|
ref: editorRef,
|
||||||
autocompleteRef,
|
autocompleteRef,
|
||||||
@@ -61,7 +63,7 @@ export function PlainTextComposer({
|
|||||||
handleMention,
|
handleMention,
|
||||||
handleAtRoomMention,
|
handleAtRoomMention,
|
||||||
handleEmoji,
|
handleEmoji,
|
||||||
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled);
|
} = usePlainTextListeners(initialContent, onChange, onSend, isAutoReplaceEmojiEnabled, uploadContext || undefined);
|
||||||
const composerFunctions = useComposerFunctions(editorRef, setContent);
|
const composerFunctions = useComposerFunctions(editorRef, setContent);
|
||||||
usePlainTextInitialization(initialContent, editorRef);
|
usePlainTextInitialization(initialContent, editorRef);
|
||||||
useSetCursorPosition(disabled, editorRef);
|
useSetCursorPosition(disabled, editorRef);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
|
|||||||
const { room } = useScopedRoomContext("room");
|
const { room } = useScopedRoomContext("room");
|
||||||
const autocompleteRef = useRef<Autocomplete | null>(null);
|
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 isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji");
|
||||||
const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]);
|
const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]);
|
||||||
|
|||||||
+5
-4
@@ -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 { type Wysiwyg, type WysiwygEvent } from "@vector-im/matrix-wysiwyg";
|
||||||
import { useCallback } from "react";
|
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 { useSettingValue } from "../../../../../hooks/useSettings";
|
||||||
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
|
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
|
||||||
@@ -26,13 +26,14 @@ import { endEditing } from "../utils/editing";
|
|||||||
import type Autocomplete from "../../Autocomplete";
|
import type Autocomplete from "../../Autocomplete";
|
||||||
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
|
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
|
||||||
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
|
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
|
||||||
|
import { useRoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
|
|
||||||
export function useInputEventProcessor(
|
export function useInputEventProcessor(
|
||||||
onSend: () => void,
|
onSend: () => void,
|
||||||
autocompleteRef: React.RefObject<Autocomplete | null>,
|
autocompleteRef: React.RefObject<Autocomplete | null>,
|
||||||
initialContent?: string,
|
initialContent?: string,
|
||||||
eventRelation?: IEventRelation,
|
|
||||||
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
|
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
|
||||||
|
const roomUploadVm = useRoomUploadViewModel();
|
||||||
const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType");
|
const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType");
|
||||||
const composerContext = useComposerContext();
|
const composerContext = useComposerContext();
|
||||||
const mxClient = useMatrixClientContext();
|
const mxClient = useMatrixClientContext();
|
||||||
@@ -52,7 +53,7 @@ export function useInputEventProcessor(
|
|||||||
|
|
||||||
if (isEventToHandleAsClipboardEvent(event)) {
|
if (isEventToHandleAsClipboardEvent(event)) {
|
||||||
const data = event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer;
|
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;
|
return handled ? null : event;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +78,11 @@ export function useInputEventProcessor(
|
|||||||
isCtrlEnterToSend,
|
isCtrlEnterToSend,
|
||||||
onSend,
|
onSend,
|
||||||
initialContent,
|
initialContent,
|
||||||
|
roomUploadVm,
|
||||||
roomContext,
|
roomContext,
|
||||||
composerContext,
|
composerContext,
|
||||||
mxClient,
|
mxClient,
|
||||||
autocompleteRef,
|
autocompleteRef,
|
||||||
eventRelation,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-10
@@ -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 KeyboardEvent, type RefObject, type SyntheticEvent, useCallback, useRef, useState } from "react";
|
||||||
import { type AllowedMentionAttributes, type MappedSuggestion } from "@vector-im/matrix-wysiwyg";
|
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 { useSettingValue } from "../../../../../hooks/useSettings";
|
||||||
import { IS_MAC, Key } from "../../../../../Keyboard";
|
import { IS_MAC, Key } from "../../../../../Keyboard";
|
||||||
@@ -16,8 +15,7 @@ import type Autocomplete from "../../Autocomplete";
|
|||||||
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
|
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
|
||||||
import { useSuggestion } from "./useSuggestion";
|
import { useSuggestion } from "./useSuggestion";
|
||||||
import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
|
import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
|
||||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
|
|
||||||
|
|
||||||
function isDivElement(target: EventTarget): target is HTMLDivElement {
|
function isDivElement(target: EventTarget): target is HTMLDivElement {
|
||||||
return target instanceof HTMLDivElement;
|
return target instanceof HTMLDivElement;
|
||||||
@@ -46,8 +44,8 @@ export function usePlainTextListeners(
|
|||||||
initialContent?: string,
|
initialContent?: string,
|
||||||
onChange?: (content: string) => void,
|
onChange?: (content: string) => void,
|
||||||
onSend?: () => void,
|
onSend?: () => void,
|
||||||
eventRelation?: IEventRelation,
|
|
||||||
isAutoReplaceEmojiEnabled?: boolean,
|
isAutoReplaceEmojiEnabled?: boolean,
|
||||||
|
roomUploadVM?: RoomUploadViewModel,
|
||||||
): {
|
): {
|
||||||
ref: RefObject<HTMLDivElement | null>;
|
ref: RefObject<HTMLDivElement | null>;
|
||||||
autocompleteRef: RefObject<Autocomplete | null>;
|
autocompleteRef: RefObject<Autocomplete | null>;
|
||||||
@@ -64,9 +62,6 @@ export function usePlainTextListeners(
|
|||||||
onSelect: (this: void, event: SyntheticEvent<HTMLDivElement>) => void;
|
onSelect: (this: void, event: SyntheticEvent<HTMLDivElement>) => void;
|
||||||
suggestion: MappedSuggestion | null;
|
suggestion: MappedSuggestion | null;
|
||||||
} {
|
} {
|
||||||
const roomContext = useScopedRoomContext("room", "timelineRenderingType", "replyToEvent");
|
|
||||||
const mxClient = useMatrixClientContext();
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const autocompleteRef = useRef<Autocomplete>(null);
|
const autocompleteRef = useRef<Autocomplete>(null);
|
||||||
const [content, setContent] = useState<string | undefined>(initialContent);
|
const [content, setContent] = useState<string | undefined>(initialContent);
|
||||||
@@ -120,10 +115,10 @@ export function usePlainTextListeners(
|
|||||||
const { nativeEvent } = event;
|
const { nativeEvent } = event;
|
||||||
let imagePasteWasHandled = false;
|
let imagePasteWasHandled = false;
|
||||||
|
|
||||||
if (isEventToHandleAsClipboardEvent(nativeEvent)) {
|
if (roomUploadVM && isEventToHandleAsClipboardEvent(nativeEvent)) {
|
||||||
const data =
|
const data =
|
||||||
nativeEvent instanceof ClipboardEvent ? nativeEvent.clipboardData : nativeEvent.dataTransfer;
|
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
|
// prevent default behaviour and skip call to onInput if the image paste event was handled
|
||||||
@@ -133,7 +128,7 @@ export function usePlainTextListeners(
|
|||||||
onInput(event);
|
onInput(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[eventRelation, mxClient, onInput, roomContext],
|
[onInput, roomUploadVM],
|
||||||
);
|
);
|
||||||
|
|
||||||
const enterShouldSend = !useSettingValue("MessageComposerInput.ctrlEnterToSend");
|
const enterShouldSend = !useSettingValue("MessageComposerInput.ctrlEnterToSend");
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type RefObject } from "react";
|
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 WysiwygEvent } from "@vector-im/matrix-wysiwyg";
|
||||||
|
|
||||||
import { type TimelineRenderingType } from "../../../../../contexts/RoomContext";
|
import { type TimelineRenderingType } from "../../../../../contexts/RoomContext";
|
||||||
@@ -16,8 +15,8 @@ import type Autocomplete from "../../Autocomplete";
|
|||||||
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
|
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
|
||||||
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
|
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
|
||||||
import { getBlobSafeMimeType } from "../../../../../utils/blobs";
|
import { getBlobSafeMimeType } from "../../../../../utils/blobs";
|
||||||
import ContentMessages from "../../../../../ContentMessages";
|
|
||||||
import { isNotNull } from "../../../../../Typeguards";
|
import { isNotNull } from "../../../../../Typeguards";
|
||||||
|
import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel";
|
||||||
|
|
||||||
export function focusComposer(
|
export function focusComposer(
|
||||||
composerElement: RefObject<HTMLElement | null>,
|
composerElement: RefObject<HTMLElement | null>,
|
||||||
@@ -123,13 +122,8 @@ export function handleEventWithAutocomplete(
|
|||||||
export function handleClipboardEvent(
|
export function handleClipboardEvent(
|
||||||
event: ClipboardEvent | InputEvent,
|
event: ClipboardEvent | InputEvent,
|
||||||
data: DataTransfer | null,
|
data: DataTransfer | null,
|
||||||
roomContext: Pick<IRoomState, "room" | "timelineRenderingType" | "replyToEvent">,
|
vm: RoomUploadViewModel,
|
||||||
mxClient: MatrixClient,
|
|
||||||
eventRelation?: IEventRelation,
|
|
||||||
): boolean {
|
): boolean {
|
||||||
// Logic in this function follows that of `SendMessageComposer.onPaste`
|
|
||||||
const { room, timelineRenderingType, replyToEvent } = roomContext;
|
|
||||||
|
|
||||||
function handleError(error: unknown): void {
|
function handleError(error: unknown): void {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
console.log(error.message);
|
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;
|
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
|
// 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.
|
// it puts the filename in as text/plain which we want to ignore.
|
||||||
if (data.files.length && !data.types.includes("text/rtf")) {
|
if (data.files.length && !data.types.includes("text/rtf")) {
|
||||||
ContentMessages.sharedInstance()
|
vm.initiateViaDataTransfer(data).catch(handleError);
|
||||||
.sendContentListToRoom(
|
|
||||||
Array.from(data.files),
|
|
||||||
room.roomId,
|
|
||||||
eventRelation,
|
|
||||||
roomContext.replyToEvent,
|
|
||||||
mxClient,
|
|
||||||
timelineRenderingType,
|
|
||||||
)
|
|
||||||
.catch(handleError);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +173,7 @@ export function handleClipboardEvent(
|
|||||||
const parts = response.url.split("/");
|
const parts = response.url.split("/");
|
||||||
const filename = parts[parts.length - 1];
|
const filename = parts[parts.length - 1];
|
||||||
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
|
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
|
||||||
ContentMessages.sharedInstance()
|
return vm.initiateViaInputFiles([file]);
|
||||||
.sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent)
|
|
||||||
.catch(handleError);
|
|
||||||
})
|
})
|
||||||
.catch(handleError);
|
.catch(handleError);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -659,8 +659,8 @@ export function mkMessage({
|
|||||||
|
|
||||||
export function mkStubRoom(
|
export function mkStubRoom(
|
||||||
roomId: string | null | undefined = null,
|
roomId: string | null | undefined = null,
|
||||||
name: string | undefined,
|
name?: string | undefined,
|
||||||
client: MatrixClient | undefined,
|
client?: MatrixClient | undefined,
|
||||||
state?: RoomState | undefined,
|
state?: RoomState | undefined,
|
||||||
): Room {
|
): Room {
|
||||||
const stubTimeline = {
|
const stubTimeline = {
|
||||||
|
|||||||
@@ -6,35 +6,62 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { mocked } from "jest-mock";
|
|
||||||
import { render, fireEvent } from "jest-matrix-react";
|
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 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", () => {
|
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", () => {
|
it("should render nothing when idle", () => {
|
||||||
const element = document.createElement("div");
|
const element = document.createElement("div");
|
||||||
const onFileDrop = jest.fn();
|
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();
|
expect(asFragment()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render drop file prompt on mouse over with file if permissions allow", () => {
|
it("should render drop file prompt on mouse over with file if permissions allow", () => {
|
||||||
const element = document.createElement("div");
|
const element = document.createElement("div");
|
||||||
const onFileDrop = jest.fn();
|
const onFileDrop = jest.fn();
|
||||||
mocked(room.currentState.maySendMessage).mockReturnValue(true);
|
const { asFragment } = render(
|
||||||
|
<FileDropTargetWrapped
|
||||||
const { asFragment } = render(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />);
|
element={element}
|
||||||
|
snapshot={{ mayUpload: true }}
|
||||||
|
actions={{ initiateViaDataTransfer: onFileDrop }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
fireEvent.dragEnter(element, {
|
fireEvent.dragEnter(element, {
|
||||||
dataTransfer: {
|
dataTransfer: {
|
||||||
types: ["Files"],
|
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", () => {
|
it("should not render drop file prompt on mouse over with file if permissions do not allow", () => {
|
||||||
const element = document.createElement("div");
|
const element = document.createElement("div");
|
||||||
const onFileDrop = jest.fn();
|
const onFileDrop = jest.fn();
|
||||||
mocked(room.currentState.maySendMessage).mockReturnValue(false);
|
const { asFragment } = render(
|
||||||
|
<FileDropTargetWrapped
|
||||||
const { asFragment } = render(<FileDropTarget room={room} onFileDrop={onFileDrop} parent={element} />);
|
element={element}
|
||||||
|
snapshot={{ mayUpload: false }}
|
||||||
|
actions={{ initiateViaDataTransfer: onFileDrop }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
fireEvent.dragEnter(element, {
|
fireEvent.dragEnter(element, {
|
||||||
dataTransfer: {
|
dataTransfer: {
|
||||||
types: ["Files"],
|
types: ["Files"],
|
||||||
|
|||||||
+60
-35
@@ -270,6 +270,12 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -638,16 +644,17 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
multiple=""
|
|
||||||
style="display: none;"
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1010,16 +1017,17 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
multiple=""
|
|
||||||
style="display: none;"
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1320,6 +1328,12 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1621,15 +1635,16 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
multiple=""
|
|
||||||
style="display: none;"
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2096,15 +2111,16 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
multiple=""
|
|
||||||
style="display: none;"
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2571,15 +2587,16 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
multiple=""
|
|
||||||
style="display: none;"
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2794,6 +2811,12 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3288,15 +3311,16 @@ exports[`RoomView should not display the timeline when the room encryption is lo
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
multiple=""
|
|
||||||
style="display: none;"
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3844,15 +3868,16 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
multiple=""
|
|
||||||
style="display: none;"
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
data-testid="room-upload-context-input"
|
||||||
|
multiple=""
|
||||||
|
style="display: none;"
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore";
|
|||||||
import { Action } from "../../../../../src/dispatcher/actions";
|
import { Action } from "../../../../../src/dispatcher/actions";
|
||||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||||
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
|
||||||
|
import {
|
||||||
|
RoomUploadContext,
|
||||||
|
type RoomUploadViewModel,
|
||||||
|
} from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
|
|
||||||
const openStickerPicker = async (): Promise<void> => {
|
const openStickerPicker = async (): Promise<void> => {
|
||||||
await userEvent.click(screen.getByLabelText("More options"));
|
await userEvent.click(screen.getByLabelText("More options"));
|
||||||
@@ -469,7 +473,9 @@ function wrapAndRender(
|
|||||||
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
|
const getRawComponent = (props = {}, context = roomContext, client = mockClient) => (
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<ScopedRoomContextProvider {...context}>
|
<ScopedRoomContextProvider {...context}>
|
||||||
<MessageComposer {...defaultProps} {...props} />
|
<RoomUploadContext.Provider value={{} as RoomUploadViewModel}>
|
||||||
|
<MessageComposer {...defaultProps} {...props} />
|
||||||
|
</RoomUploadContext.Provider>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>
|
</MatrixClientContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-u
|
|||||||
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||||
import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons";
|
import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons";
|
||||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
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", () => {
|
describe("MessageComposerButtons", () => {
|
||||||
// @ts-ignore - we're deliberately not implementing the whole interface here, but
|
// @ts-ignore - we're deliberately not implementing the whole interface here, but
|
||||||
@@ -54,7 +55,9 @@ describe("MessageComposerButtons", () => {
|
|||||||
|
|
||||||
return render(
|
return render(
|
||||||
<MatrixClientContext.Provider value={mockClient}>
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
<ScopedRoomContextProvider {...defaultRoomContext}>{component}</ScopedRoomContextProvider>
|
<ScopedRoomContextProvider {...defaultRoomContext}>
|
||||||
|
<RoomUploadContextProvider>{component}</RoomUploadContextProvider>
|
||||||
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room";
|
|||||||
import { addTextToComposer } from "../../../../test-utils/composer";
|
import { addTextToComposer } from "../../../../test-utils/composer";
|
||||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||||
import { SdkContextClass } from "../../../../../src/contexts/SDKContext.ts";
|
import { SdkContextClass } from "../../../../../src/contexts/SDKContext.ts";
|
||||||
|
import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
|
|
||||||
jest.mock("../../../../../src/utils/local-room", () => ({
|
jest.mock("../../../../../src/utils/local-room", () => ({
|
||||||
doMaybeLocalRoomAction: jest.fn(),
|
doMaybeLocalRoomAction: jest.fn(),
|
||||||
@@ -187,8 +188,10 @@ describe("<SendMessageComposer/>", () => {
|
|||||||
};
|
};
|
||||||
const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => (
|
const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => (
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<ScopedRoomContextProvider {...roomContext}>
|
<ScopedRoomContextProvider room={mockRoom} {...roomContext}>
|
||||||
<SendMessageComposer {...defaultProps} {...props} />
|
<RoomUploadContextProvider>
|
||||||
|
<SendMessageComposer {...defaultProps} {...props} />
|
||||||
|
</RoomUploadContextProvider>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>
|
</MatrixClientContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -435,7 +438,11 @@ describe("<SendMessageComposer/>", () => {
|
|||||||
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<MatrixClientContext.Provider value={cli}>
|
<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>,
|
</MatrixClientContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -27,6 +27,7 @@ import { type ActionPayload } from "../../../../../../src/dispatcher/payloads";
|
|||||||
import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton";
|
import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton";
|
||||||
import { createMocks } from "./utils";
|
import { createMocks } from "./utils";
|
||||||
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||||
|
import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
|
|
||||||
beforeAll(initOnce, 10000);
|
beforeAll(initOnce, 10000);
|
||||||
|
|
||||||
@@ -46,7 +47,9 @@ describe("EditWysiwygComposer", () => {
|
|||||||
return render(
|
return render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<ScopedRoomContextProvider {...roomContext}>
|
<ScopedRoomContextProvider {...roomContext}>
|
||||||
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
|
<RoomUploadContextProvider>
|
||||||
|
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
|
||||||
|
</RoomUploadContextProvider>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
);
|
);
|
||||||
@@ -62,7 +65,9 @@ describe("EditWysiwygComposer", () => {
|
|||||||
rerender(
|
rerender(
|
||||||
<MatrixClientContext.Provider value={mockClient}>
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
<ScopedRoomContextProvider {...defaultRoomContext} room={undefined}>
|
<ScopedRoomContextProvider {...defaultRoomContext} room={undefined}>
|
||||||
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
|
<RoomUploadContextProvider>
|
||||||
|
<EditWysiwygComposer disabled={false} editorStateTransfer={editorStateTransfer} />
|
||||||
|
</RoomUploadContextProvider>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
);
|
);
|
||||||
@@ -273,7 +278,9 @@ describe("EditWysiwygComposer", () => {
|
|||||||
render(
|
render(
|
||||||
<MatrixClientContext.Provider value={mockClient}>
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
<ScopedRoomContextProvider {...defaultRoomContext}>
|
<ScopedRoomContextProvider {...defaultRoomContext}>
|
||||||
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
|
<RoomUploadContextProvider>
|
||||||
|
<EditWysiwygComposer editorStateTransfer={editorStateTransfer} />
|
||||||
|
</RoomUploadContextProvider>
|
||||||
<Emoji menuPosition={{ chevronFace: ChevronFace.Top }} />
|
<Emoji menuPosition={{ chevronFace: ChevronFace.Top }} />
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
|
|||||||
+12
-9
@@ -25,6 +25,7 @@ import { setSelection } from "../../../../../../src/components/views/rooms/wysiw
|
|||||||
import { createMocks } from "./utils";
|
import { createMocks } from "./utils";
|
||||||
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||||
import { E2EStatus } from "../../../../../../src/utils/ShieldUtils.ts";
|
import { E2EStatus } from "../../../../../../src/utils/ShieldUtils.ts";
|
||||||
|
import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
|
|
||||||
jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({
|
jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({
|
||||||
EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
|
EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => {
|
||||||
@@ -77,15 +78,17 @@ describe("SendWysiwygComposer", () => {
|
|||||||
return render(
|
return render(
|
||||||
<MatrixClientContext.Provider value={mockClient}>
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
<ScopedRoomContextProvider {...defaultRoomContext}>
|
<ScopedRoomContextProvider {...defaultRoomContext}>
|
||||||
<SendWysiwygComposer
|
<RoomUploadContextProvider>
|
||||||
onChange={onChange}
|
<SendWysiwygComposer
|
||||||
onSend={onSend}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
onSend={onSend}
|
||||||
isRichTextEnabled={isRichTextEnabled}
|
disabled={disabled}
|
||||||
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
|
isRichTextEnabled={isRichTextEnabled}
|
||||||
placeholder={placeholder}
|
menuPosition={aboveLeftOf({ top: 0, bottom: 0, right: 0 })}
|
||||||
e2eStatus={e2eStatus}
|
placeholder={placeholder}
|
||||||
/>
|
e2eStatus={e2eStatus}
|
||||||
|
/>
|
||||||
|
</RoomUploadContextProvider>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|||||||
+24
-19
@@ -33,6 +33,7 @@ import type AutocompleteProvider from "../../../../../../../src/autocomplete/Aut
|
|||||||
import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks";
|
import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks";
|
||||||
import { type PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor";
|
import { type PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor";
|
||||||
import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx";
|
import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||||
|
import { RoomUploadContextProvider } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx";
|
||||||
|
|
||||||
beforeAll(initOnce, 10000);
|
beforeAll(initOnce, 10000);
|
||||||
|
|
||||||
@@ -42,12 +43,14 @@ describe("WysiwygComposer", () => {
|
|||||||
return render(
|
return render(
|
||||||
<MatrixClientContext.Provider value={mockClient}>
|
<MatrixClientContext.Provider value={mockClient}>
|
||||||
<ScopedRoomContextProvider {...defaultRoomContext}>
|
<ScopedRoomContextProvider {...defaultRoomContext}>
|
||||||
<WysiwygComposer
|
<RoomUploadContextProvider>
|
||||||
onChange={onChange}
|
<WysiwygComposer
|
||||||
onSend={onSend}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
onSend={onSend}
|
||||||
initialContent={initialContent}
|
disabled={disabled}
|
||||||
/>
|
initialContent={initialContent}
|
||||||
|
/>
|
||||||
|
</RoomUploadContextProvider>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
);
|
);
|
||||||
@@ -561,19 +564,21 @@ describe("WysiwygComposer", () => {
|
|||||||
return render(
|
return render(
|
||||||
<MatrixClientContext.Provider value={client}>
|
<MatrixClientContext.Provider value={client}>
|
||||||
<ScopedRoomContextProvider {...roomContext}>
|
<ScopedRoomContextProvider {...roomContext}>
|
||||||
<ComposerContext.Provider
|
<RoomUploadContextProvider>
|
||||||
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
|
<ComposerContext.Provider
|
||||||
>
|
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
|
||||||
<WysiwygComposer
|
>
|
||||||
onChange={jest.fn()}
|
<WysiwygComposer
|
||||||
onSend={jest.fn()}
|
onChange={jest.fn()}
|
||||||
initialContent={
|
onSend={jest.fn()}
|
||||||
roomContext.room && _editorStateTransfer
|
initialContent={
|
||||||
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
|
roomContext.room && _editorStateTransfer
|
||||||
: undefined
|
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
|
||||||
}
|
: undefined
|
||||||
/>
|
}
|
||||||
</ComposerContext.Provider>
|
/>
|
||||||
|
</ComposerContext.Provider>
|
||||||
|
</RoomUploadContextProvider>
|
||||||
</ScopedRoomContextProvider>
|
</ScopedRoomContextProvider>
|
||||||
</MatrixClientContext.Provider>,
|
</MatrixClientContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|||||||
+29
-125
@@ -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
|
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.
|
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 { waitFor } from "jest-matrix-react";
|
||||||
import fetchMock from "@fetch-mock/jest";
|
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 {
|
import {
|
||||||
handleClipboardEvent,
|
handleClipboardEvent,
|
||||||
isEventToHandleAsClipboardEvent,
|
isEventToHandleAsClipboardEvent,
|
||||||
} from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/utils";
|
} 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 mockUploadVM = {
|
||||||
const mockRoom = mkStubRoom("mock room", "mock room", mockClient);
|
initiateViaDataTransfer: jest.fn().mockResolvedValue(undefined),
|
||||||
const mockRoomState = {
|
initiateViaInputFiles: jest.fn().mockResolvedValue(undefined),
|
||||||
room: mockRoom,
|
} as Partial<RoomUploadViewModel> as MockedObject<RoomUploadViewModel>;
|
||||||
timelineRenderingType: TimelineRenderingType.Room,
|
|
||||||
replyToEvent: {} as unknown as MatrixEvent,
|
|
||||||
} as unknown as IRoomState;
|
|
||||||
|
|
||||||
const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom");
|
|
||||||
const sendContentToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentToRoom");
|
|
||||||
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
describe("handleClipboardEvent", () => {
|
describe("handleClipboardEvent", () => {
|
||||||
@@ -45,29 +37,16 @@ describe("handleClipboardEvent", () => {
|
|||||||
|
|
||||||
it("returns false if it is not a paste event", () => {
|
it("returns false if it is not a paste event", () => {
|
||||||
const originalEvent = createMockClipboardEvent({ type: "copy" });
|
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(output).toBe(false);
|
||||||
|
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false if clipboard data is null", () => {
|
it("returns false if clipboard data is null", () => {
|
||||||
const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null });
|
const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null });
|
||||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(output).toBe(false);
|
expect(output).toBe(false);
|
||||||
|
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false if room clipboardData files and types are empty", () => {
|
it("returns false if room clipboardData files and types are empty", () => {
|
||||||
@@ -75,8 +54,9 @@ describe("handleClipboardEvent", () => {
|
|||||||
type: "paste",
|
type: "paste",
|
||||||
clipboardData: { files: [], types: [] },
|
clipboardData: { files: [], types: [] },
|
||||||
});
|
});
|
||||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||||
expect(output).toBe(false);
|
expect(output).toBe(false);
|
||||||
|
expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles event and calls sendContentListToRoom when data files are present", () => {
|
it("handles event and calls sendContentListToRoom when data files are present", () => {
|
||||||
@@ -84,65 +64,23 @@ describe("handleClipboardEvent", () => {
|
|||||||
type: "paste",
|
type: "paste",
|
||||||
clipboardData: { files: ["something here"], types: [] },
|
clipboardData: { files: ["something here"], types: [] },
|
||||||
});
|
});
|
||||||
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient);
|
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||||
const mockReplyToEvent = {} as unknown as MatrixEvent;
|
expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1);
|
||||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledWith(originalEvent.clipboardData);
|
||||||
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,
|
|
||||||
);
|
|
||||||
expect(output).toBe(true);
|
expect(output).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls the error handler when sentContentListToRoom errors", async () => {
|
it("calls the error handler when sentContentListToRoom errors", async () => {
|
||||||
const mockErrorMessage = "something went wrong";
|
const mockErrorMessage = "something went wrong";
|
||||||
sendContentListToRoomSpy.mockRejectedValueOnce(new Error(mockErrorMessage));
|
mockUploadVM.initiateViaDataTransfer.mockRejectedValueOnce(new Error(mockErrorMessage));
|
||||||
|
|
||||||
const originalEvent = createMockClipboardEvent({
|
const originalEvent = createMockClipboardEvent({
|
||||||
type: "paste",
|
type: "paste",
|
||||||
clipboardData: { files: ["something here"], types: [] },
|
clipboardData: { files: ["something here"], types: [] },
|
||||||
});
|
});
|
||||||
const mockEventRelation = {} as unknown as IEventRelation;
|
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||||
const output = handleClipboardEvent(
|
|
||||||
originalEvent,
|
|
||||||
originalEvent.clipboardData,
|
|
||||||
mockRoomState,
|
|
||||||
mockClient,
|
|
||||||
mockEventRelation,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1);
|
expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||||
});
|
});
|
||||||
@@ -158,15 +96,7 @@ describe("handleClipboardEvent", () => {
|
|||||||
getData: jest.fn().mockReturnValue("<div>invalid html"),
|
getData: jest.fn().mockReturnValue("<div>invalid html"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mockEventRelation = {} as unknown as IEventRelation;
|
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||||
const output = handleClipboardEvent(
|
|
||||||
originalEvent,
|
|
||||||
originalEvent.clipboardData,
|
|
||||||
mockRoomState,
|
|
||||||
mockClient,
|
|
||||||
mockEventRelation,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content");
|
expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content");
|
||||||
expect(output).toBe(false);
|
expect(output).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -180,10 +110,10 @@ describe("handleClipboardEvent", () => {
|
|||||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mockEventRelation = {} as unknown as IEventRelation;
|
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||||
handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient, mockEventRelation);
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveFetchedTimes(1, "blob:");
|
expect(fetchMock).toHaveFetchedTimes(1, "blob:");
|
||||||
|
expect(output).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls error handler when fetch fails", async () => {
|
it("calls error handler when fetch fails", async () => {
|
||||||
@@ -197,14 +127,7 @@ describe("handleClipboardEvent", () => {
|
|||||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mockEventRelation = {} as unknown as IEventRelation;
|
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||||
const output = handleClipboardEvent(
|
|
||||||
originalEvent,
|
|
||||||
originalEvent.clipboardData,
|
|
||||||
mockRoomState,
|
|
||||||
mockClient,
|
|
||||||
mockEventRelation,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||||
@@ -212,7 +135,7 @@ describe("handleClipboardEvent", () => {
|
|||||||
expect(output).toBe(true);
|
expect(output).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls sendContentToRoom when parsing is successful", async () => {
|
it("calls initiateViaInputFiles when parsing is successful", async () => {
|
||||||
fetchMock.get("test/file", {
|
fetchMock.get("test/file", {
|
||||||
blob: () => {
|
blob: () => {
|
||||||
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
||||||
@@ -227,23 +150,11 @@ describe("handleClipboardEvent", () => {
|
|||||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mockEventRelation = {} as unknown as IEventRelation;
|
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||||
const output = handleClipboardEvent(
|
|
||||||
originalEvent,
|
|
||||||
originalEvent.clipboardData,
|
|
||||||
mockRoomState,
|
|
||||||
mockClient,
|
|
||||||
mockEventRelation,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendContentToRoomSpy).toHaveBeenCalledWith(
|
expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledTimes(1);
|
||||||
expect.any(File),
|
expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledWith([expect.any(File)]);
|
||||||
mockRoom.roomId,
|
|
||||||
mockEventRelation,
|
|
||||||
mockClient,
|
|
||||||
mockRoomState.replyToEvent,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
expect(output).toBe(true);
|
expect(output).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -254,8 +165,8 @@ describe("handleClipboardEvent", () => {
|
|||||||
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
return Promise.resolve({ type: "image/jpeg" } as Blob);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mockErrorMessage = "sendContentToRoom failed";
|
const mockErrorMessage = "initiateViaInputFiles failed";
|
||||||
sendContentToRoomSpy.mockRejectedValueOnce(mockErrorMessage);
|
mockUploadVM.initiateViaInputFiles.mockRejectedValueOnce(mockErrorMessage);
|
||||||
|
|
||||||
const originalEvent = createMockClipboardEvent({
|
const originalEvent = createMockClipboardEvent({
|
||||||
type: "paste",
|
type: "paste",
|
||||||
@@ -265,14 +176,7 @@ describe("handleClipboardEvent", () => {
|
|||||||
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
getData: jest.fn().mockReturnValue(`<img src="blob:" />`),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mockEventRelation = {} as unknown as IEventRelation;
|
const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM);
|
||||||
const output = handleClipboardEvent(
|
|
||||||
originalEvent,
|
|
||||||
originalEvent.clipboardData,
|
|
||||||
mockRoomState,
|
|
||||||
mockClient,
|
|
||||||
mockEventRelation,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
expect(logSpy).toHaveBeenCalledWith(mockErrorMessage);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user