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:
@@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@@ -7,16 +8,14 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { useViewModel } from "@element-hq/web-shared-components";
|
||||
|
||||
import { _t } from "../../languageHandler";
|
||||
import UploadBigSvg from "../../../res/img/upload-big.svg";
|
||||
import { useRoomState } from "../../hooks/useRoomState.ts";
|
||||
import { useRoomUploadViewModel } from "../../viewmodels/room/RoomUploadViewModel";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
parent: HTMLElement | null;
|
||||
onFileDrop(this: void, dataTransfer: DataTransfer): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -24,15 +23,16 @@ interface IState {
|
||||
counter: number;
|
||||
}
|
||||
|
||||
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
||||
const FileDropTarget: React.FC<IProps> = ({ parent }) => {
|
||||
const [state, setState] = useState<IState>({
|
||||
dragging: false,
|
||||
counter: 0,
|
||||
});
|
||||
const hasPermission = useRoomState(room, (state) => state.maySendMessage(room.client.getUserId()!));
|
||||
const vm = useRoomUploadViewModel();
|
||||
const { mayUpload } = useViewModel(vm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPermission || !parent || parent.ondrop) return;
|
||||
if (!mayUpload || !parent || parent.ondrop) return;
|
||||
|
||||
const onDragEnter = (ev: DragEvent): void => {
|
||||
ev.stopPropagation();
|
||||
@@ -83,7 +83,7 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!ev.dataTransfer) return;
|
||||
onFileDrop(ev.dataTransfer);
|
||||
void vm.initiateViaDataTransfer(ev.dataTransfer);
|
||||
|
||||
setState((state) => ({
|
||||
dragging: false,
|
||||
@@ -106,9 +106,9 @@ const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop, room }) => {
|
||||
parent?.removeEventListener("dragenter", onDragEnter);
|
||||
parent?.removeEventListener("dragleave", onDragLeave);
|
||||
};
|
||||
}, [parent, onFileDrop, hasPermission]);
|
||||
}, [parent, mayUpload, vm]);
|
||||
|
||||
if (hasPermission && state.dragging) {
|
||||
if (mayUpload && state.dragging) {
|
||||
return (
|
||||
<div className="mx_FileDropTarget">
|
||||
<img src={UploadBigSvg} className="mx_FileDropTarget_image" alt="" />
|
||||
|
||||
@@ -142,6 +142,7 @@ import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
|
||||
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
|
||||
import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts";
|
||||
import { ModuleApi } from "../../modules/Api.ts";
|
||||
import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
||||
|
||||
const DEBUG = false;
|
||||
@@ -302,7 +303,6 @@ interface LocalRoomViewProps {
|
||||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
roomView: RefObject<HTMLElement | null>;
|
||||
onFileDrop: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
mainSplitContentType: MainSplitContentType;
|
||||
e2eStatus?: E2EStatus;
|
||||
}
|
||||
@@ -343,17 +343,19 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
|
||||
<div className="mx_RoomView mx_RoomView--local">
|
||||
<ErrorBoundary>
|
||||
<RoomHeader room={room} />
|
||||
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
|
||||
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} room={room} />
|
||||
<div className="mx_RoomView_timeline">
|
||||
<ScrollPanel className="mx_RoomView_messagePanel">
|
||||
{encryptionTile}
|
||||
<NewRoomIntro />
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
{statusBar}
|
||||
{composer}
|
||||
</main>
|
||||
<RoomUploadContextProvider>
|
||||
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
|
||||
<FileDropTarget parent={props.roomView.current} />
|
||||
<div className="mx_RoomView_timeline">
|
||||
<ScrollPanel className="mx_RoomView_messagePanel">
|
||||
{encryptionTile}
|
||||
<NewRoomIntro />
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
{statusBar}
|
||||
{composer}
|
||||
</main>
|
||||
</RoomUploadContextProvider>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
@@ -2121,19 +2123,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
});
|
||||
}
|
||||
|
||||
private onFileDrop = async (dataTransfer: DataTransfer): Promise<void> => {
|
||||
const roomId = this.getRoomId();
|
||||
if (!roomId || !this.context.client) return;
|
||||
await ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(dataTransfer.files),
|
||||
roomId,
|
||||
undefined,
|
||||
this.state.replyToEvent,
|
||||
this.context.client,
|
||||
TimelineRenderingType.Room,
|
||||
);
|
||||
};
|
||||
|
||||
private onMeasurement = (narrow: boolean): void => {
|
||||
this.setState({ narrow });
|
||||
};
|
||||
@@ -2169,7 +2158,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
resizeNotifier={this.context.resizeNotifier}
|
||||
permalinkCreator={this.permalinkCreator}
|
||||
roomView={this.roomView}
|
||||
onFileDrop={this.onFileDrop}
|
||||
mainSplitContentType={this.state.mainSplitContentType}
|
||||
/>
|
||||
</ScopedRoomContextProvider>
|
||||
@@ -2673,16 +2661,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
case MainSplitContentType.Timeline:
|
||||
mainSplitContentClassName = "mx_MainSplit_timeline";
|
||||
mainSplitBody = (
|
||||
<>
|
||||
<RoomUploadContextProvider>
|
||||
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
|
||||
{auxPanel}
|
||||
{pinnedMessageBanner}
|
||||
<main className={timelineClasses} data-testid="timeline">
|
||||
<FileDropTarget
|
||||
parent={this.roomView.current}
|
||||
onFileDrop={this.onFileDrop}
|
||||
room={this.state.room}
|
||||
/>
|
||||
<FileDropTarget parent={this.roomView.current} />
|
||||
{topUnreadMessagesBar}
|
||||
{jumpToBottom}
|
||||
{messagePanel}
|
||||
@@ -2691,7 +2675,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
{statusBarArea}
|
||||
{previewBar}
|
||||
{messageComposer}
|
||||
</>
|
||||
</RoomUploadContextProvider>
|
||||
);
|
||||
break;
|
||||
case MainSplitContentType.MaximisedWidget:
|
||||
|
||||
@@ -29,7 +29,6 @@ import TimelinePanel from "./TimelinePanel";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { type ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { type E2EStatus } from "../../utils/ShieldUtils";
|
||||
import EditorStateTransfer from "../../utils/EditorStateTransfer";
|
||||
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
@@ -51,6 +50,7 @@ import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/paylo
|
||||
import Heading from "../views/typography/Heading";
|
||||
import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
|
||||
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
|
||||
import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
|
||||
|
||||
interface IProps {
|
||||
@@ -329,22 +329,6 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||
}
|
||||
};
|
||||
|
||||
private onFileDrop = (dataTransfer: DataTransfer): void => {
|
||||
const roomId = this.props.mxEvent.getRoomId();
|
||||
if (roomId) {
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(dataTransfer.files),
|
||||
roomId,
|
||||
this.threadRelation,
|
||||
this.context.replyToEvent,
|
||||
MatrixClientPeg.safeGet(),
|
||||
TimelineRenderingType.Thread,
|
||||
);
|
||||
} else {
|
||||
console.warn("Unknwon roomId for event", this.props.mxEvent);
|
||||
}
|
||||
};
|
||||
|
||||
private get threadRelation(): IEventRelation {
|
||||
const relation: IEventRelation = {
|
||||
rel_type: THREAD_RELATION_TYPE.name,
|
||||
@@ -393,7 +377,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||
|
||||
timeline = (
|
||||
<>
|
||||
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
|
||||
<FileDropTarget parent={this.card.current} />
|
||||
<EventPresentationContextProvider layout={layout}>
|
||||
<TimelinePanel
|
||||
key={this.state.thread.id}
|
||||
@@ -437,38 +421,40 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
||||
liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()}
|
||||
narrow={this.state.narrow}
|
||||
>
|
||||
<BaseCard
|
||||
className={classNames("mx_ThreadView mx_ThreadPanel", {
|
||||
mx_ThreadView_narrow: this.state.narrow,
|
||||
})}
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer={true}
|
||||
header={this.renderThreadViewHeader()}
|
||||
ref={this.card}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onBack={(ev: ButtonEvent) => {
|
||||
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
|
||||
}}
|
||||
>
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
|
||||
<RoomUploadContextProvider threadRelation={this.threadRelation}>
|
||||
<BaseCard
|
||||
className={classNames("mx_ThreadView mx_ThreadPanel", {
|
||||
mx_ThreadView_narrow: this.state.narrow,
|
||||
})}
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer={true}
|
||||
header={this.renderThreadViewHeader()}
|
||||
ref={this.card}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onBack={(ev: ButtonEvent) => {
|
||||
PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev);
|
||||
}}
|
||||
>
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<div className="mx_ThreadView_timelinePanelWrapper">{timeline}</div>
|
||||
|
||||
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
||||
<UploadBar room={this.props.room} relation={threadRelation} />
|
||||
)}
|
||||
{ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
|
||||
<UploadBar room={this.props.room} relation={threadRelation} />
|
||||
)}
|
||||
|
||||
{this.state.thread?.timelineSet && (
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
relation={threadRelation}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
</BaseCard>
|
||||
{this.state.thread?.timelineSet && (
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
relation={threadRelation}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
</BaseCard>
|
||||
</RoomUploadContextProvider>
|
||||
</ScopedRoomContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
|
||||
import Measured from "../elements/Measured";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx";
|
||||
import { RoomUploadContextProvider } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider";
|
||||
|
||||
interface IProps {
|
||||
@@ -214,47 +215,49 @@ export default class TimelineCard extends React.Component<IProps, IState> {
|
||||
header={_t("right_panel|video_room_chat|title")}
|
||||
ref={this.card}
|
||||
>
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<div className="mx_TimelineCard_timeline">
|
||||
{jumpToBottom}
|
||||
<EventPresentationContextProvider layout={layout}>
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanel}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={false} // No RM support in the TimelineCard
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.props.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// The right panel timeline (and therefore threads) don't support IRC layout at this time
|
||||
layout={layout}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
<RoomUploadContextProvider>
|
||||
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
|
||||
<div className="mx_TimelineCard_timeline">
|
||||
{jumpToBottom}
|
||||
<EventPresentationContextProvider layout={layout}>
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanel}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={false} // No RM support in the TimelineCard
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.props.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// The right panel timeline (and therefore threads) don't support IRC layout at this time
|
||||
layout={layout}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.state.initialEventId}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onScroll={this.onScroll}
|
||||
/>
|
||||
</EventPresentationContextProvider>
|
||||
</div>
|
||||
|
||||
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
|
||||
|
||||
{showComposer && (
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
relation={this.props.composerRelation}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.state.initialEventId}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onScroll={this.onScroll}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
</EventPresentationContextProvider>
|
||||
</div>
|
||||
|
||||
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
|
||||
|
||||
{showComposer && (
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
relation={this.props.composerRelation}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</RoomUploadContextProvider>
|
||||
</BaseCard>
|
||||
</ScopedRoomContextProvider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
THREAD_RELATION_TYPE,
|
||||
M_POLL_START,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext, useRef } from "react";
|
||||
import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext } from "react";
|
||||
import {
|
||||
AttachmentIcon,
|
||||
MicOnIcon,
|
||||
@@ -27,22 +28,19 @@ import {
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { CollapsibleButton } from "./CollapsibleButton";
|
||||
import { type MenuProps } from "../../structures/ContextMenu";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { LocationButton } from "../location";
|
||||
import Modal from "../../../Modal";
|
||||
import PollCreateDialog from "../elements/PollCreateDialog";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import ContentMessages from "../../../ContentMessages";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
||||
import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu";
|
||||
import { EmojiButton } from "./EmojiButton";
|
||||
import { filterBoolean } from "../../../utils/arrays";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
||||
import { useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
interface IProps {
|
||||
addEmoji: (emoji: string) => boolean;
|
||||
@@ -126,7 +124,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<UploadButtonContextProvider roomId={room.roomId} relation={props.relation}>
|
||||
<>
|
||||
{mainButtons}
|
||||
{moreButtons.length > 0 && (
|
||||
<AccessibleButton
|
||||
@@ -149,7 +147,7 @@ const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
|
||||
</OverflowMenuContext.Provider>
|
||||
</IconizedContextMenu>
|
||||
)}
|
||||
</UploadButtonContextProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -168,79 +166,13 @@ function uploadButton(): ReactElement {
|
||||
return <UploadButton key="controls_upload" />;
|
||||
}
|
||||
|
||||
type UploadButtonFn = () => void;
|
||||
export const UploadButtonContext = createContext<UploadButtonFn | null>(null);
|
||||
|
||||
interface IUploadButtonProps {
|
||||
roomId: string;
|
||||
relation?: IEventRelation;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes.
|
||||
const UploadButtonContextProvider: React.FC<IUploadButtonProps> = ({ roomId, relation, children }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent");
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onUploadClick = (): void => {
|
||||
if (cli?.isGuest()) {
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
uploadInput.current?.click();
|
||||
};
|
||||
|
||||
useDispatcher(dis, (payload) => {
|
||||
if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") {
|
||||
onUploadClick();
|
||||
}
|
||||
});
|
||||
|
||||
const onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (ev.target.files?.length === 0) return;
|
||||
|
||||
// Take a copy, so we can safely reset the value of the form control
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(ev.target.files!),
|
||||
roomId,
|
||||
relation,
|
||||
roomContext.replyToEvent,
|
||||
cli,
|
||||
roomContext.timelineRenderingType,
|
||||
);
|
||||
|
||||
// This is the onChange handler for a file form control, but we're
|
||||
// not keeping any state, so reset the value of the form control
|
||||
// to empty.
|
||||
// NB. we need to set 'value': the 'files' property is immutable.
|
||||
ev.target.value = "";
|
||||
};
|
||||
|
||||
const uploadInputStyle = { display: "none" };
|
||||
return (
|
||||
<UploadButtonContext.Provider value={onUploadClick}>
|
||||
{children}
|
||||
|
||||
<input
|
||||
ref={uploadInput}
|
||||
type="file"
|
||||
style={uploadInputStyle}
|
||||
multiple
|
||||
onClick={chromeFileInputFix}
|
||||
onChange={onUploadFileInputChange}
|
||||
/>
|
||||
</UploadButtonContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Must be rendered within an UploadButtonContextProvider
|
||||
const UploadButton: React.FC = () => {
|
||||
const overflowMenuCloser = useContext(OverflowMenuContext);
|
||||
const uploadButtonFn = useContext(UploadButtonContext);
|
||||
const vm = useRoomUploadViewModel();
|
||||
|
||||
const onClick = (): void => {
|
||||
uploadButtonFn?.();
|
||||
vm.openUploadDialog();
|
||||
overflowMenuCloser?.(); // close overflow menu
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef, type KeyboardEvent, type SyntheticEvent } from "react";
|
||||
import React, {
|
||||
createRef,
|
||||
type KeyboardEvent,
|
||||
type SyntheticEvent,
|
||||
type RefAttributes,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
type MatrixEvent,
|
||||
type IEventRelation,
|
||||
@@ -37,8 +43,7 @@ import { CommandPartCreator, type Part, type PartCreator, type SerializedPart }
|
||||
import { findEditableEvent } from "../../../utils/EventUtils";
|
||||
import SendHistoryManager from "../../../SendHistoryManager";
|
||||
import { CommandCategories } from "../../../slash-commands/SlashCommands";
|
||||
import ContentMessages from "../../../ContentMessages";
|
||||
import { withMatrixClientHOC, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
||||
import { useMatrixClientContext, type MatrixClientProps } from "../../../contexts/MatrixClientContext";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { containsEmoji } from "../../../effects/utils";
|
||||
import { CHAT_EFFECTS } from "../../../effects";
|
||||
@@ -60,6 +65,7 @@ import { type IDiff } from "../../../editor/diff";
|
||||
import { getBlobSafeMimeType } from "../../../utils/blobs";
|
||||
import { EMOJI_REGEX } from "../../../HtmlUtils";
|
||||
import { attachMentions, attachRelation } from "../../../utils/messages";
|
||||
import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel";
|
||||
|
||||
// The prefix used when persisting editor drafts to localstorage.
|
||||
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
|
||||
@@ -124,6 +130,7 @@ export function isQuickReaction(model: EditorModel): boolean {
|
||||
|
||||
interface ISendMessageComposerProps extends MatrixClientProps {
|
||||
room: Room;
|
||||
uploadVm: RoomUploadViewModel;
|
||||
placeholder?: string;
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
@@ -561,14 +568,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
|
||||
// it puts the filename in as text/plain which we want to ignore.
|
||||
if (data.files.length && !data.types.includes("text/rtf")) {
|
||||
ContentMessages.sharedInstance().sendContentListToRoom(
|
||||
Array.from(data.files),
|
||||
this.props.room.roomId,
|
||||
this.props.relation,
|
||||
this.context.replyToEvent,
|
||||
this.props.mxClient,
|
||||
this.context.timelineRenderingType,
|
||||
);
|
||||
this.props.uploadVm.initiateViaDataTransfer(data);
|
||||
return true; // to skip internal onPaste handler
|
||||
}
|
||||
|
||||
@@ -601,13 +601,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
const parts = response.url.split("/");
|
||||
const filename = parts[parts.length - 1];
|
||||
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
|
||||
ContentMessages.sharedInstance().sendContentToRoom(
|
||||
file,
|
||||
this.props.room.roomId,
|
||||
this.props.relation,
|
||||
this.props.mxClient,
|
||||
this.context.replyToEvent,
|
||||
);
|
||||
this.props.uploadVm.initiateViaInputFiles([file]);
|
||||
},
|
||||
(error) => {
|
||||
console.log(error);
|
||||
@@ -660,5 +654,13 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
}
|
||||
}
|
||||
|
||||
const SendMessageComposerWithMatrixClient = withMatrixClientHOC(SendMessageComposer);
|
||||
export default SendMessageComposerWithMatrixClient;
|
||||
function SendMessageComposerWrapped(
|
||||
props: Omit<ISendMessageComposerProps, "mxClient" | "uploadVm"> &
|
||||
RefAttributes<InstanceType<typeof SendMessageComposer>>,
|
||||
): ReactElement {
|
||||
const client = useMatrixClientContext();
|
||||
const uploadVm = useRoomUploadViewModel();
|
||||
return <SendMessageComposer {...props} mxClient={client} uploadVm={uploadVm} />;
|
||||
}
|
||||
|
||||
export default SendMessageComposerWrapped;
|
||||
|
||||
+4
-2
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import classNames from "classnames";
|
||||
import { type IEventRelation } from "matrix-js-sdk/src/matrix";
|
||||
import React, { type JSX, type RefObject, type ReactNode } from "react";
|
||||
import React, { type JSX, type RefObject, type ReactNode, useContext } from "react";
|
||||
|
||||
import { useComposerFunctions } from "../hooks/useComposerFunctions";
|
||||
import { useIsFocused } from "../hooks/useIsFocused";
|
||||
@@ -19,6 +19,7 @@ import { type ComposerFunctions } from "../types";
|
||||
import { Editor } from "./Editor";
|
||||
import { WysiwygAutocomplete } from "./WysiwygAutocomplete";
|
||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||
import { RoomUploadContext } from "../../../../../viewmodels/room/RoomUploadViewModel";
|
||||
|
||||
interface PlainTextComposerProps {
|
||||
disabled?: boolean;
|
||||
@@ -46,6 +47,7 @@ export function PlainTextComposer({
|
||||
eventRelation,
|
||||
}: PlainTextComposerProps): JSX.Element {
|
||||
const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji");
|
||||
const uploadContext = useContext(RoomUploadContext);
|
||||
const {
|
||||
ref: editorRef,
|
||||
autocompleteRef,
|
||||
@@ -61,7 +63,7 @@ export function PlainTextComposer({
|
||||
handleMention,
|
||||
handleAtRoomMention,
|
||||
handleEmoji,
|
||||
} = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled);
|
||||
} = usePlainTextListeners(initialContent, onChange, onSend, isAutoReplaceEmojiEnabled, uploadContext || undefined);
|
||||
const composerFunctions = useComposerFunctions(editorRef, setContent);
|
||||
usePlainTextInitialization(initialContent, editorRef);
|
||||
useSetCursorPosition(disabled, editorRef);
|
||||
|
||||
@@ -60,7 +60,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
|
||||
const { room } = useScopedRoomContext("room");
|
||||
const autocompleteRef = useRef<Autocomplete | null>(null);
|
||||
|
||||
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation);
|
||||
const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent);
|
||||
|
||||
const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji");
|
||||
const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]);
|
||||
|
||||
+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 { useCallback } from "react";
|
||||
import { type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
|
||||
@@ -26,13 +26,14 @@ import { endEditing } from "../utils/editing";
|
||||
import type Autocomplete from "../../Autocomplete";
|
||||
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
|
||||
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
|
||||
import { useRoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
export function useInputEventProcessor(
|
||||
onSend: () => void,
|
||||
autocompleteRef: React.RefObject<Autocomplete | null>,
|
||||
initialContent?: string,
|
||||
eventRelation?: IEventRelation,
|
||||
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
|
||||
const roomUploadVm = useRoomUploadViewModel();
|
||||
const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType");
|
||||
const composerContext = useComposerContext();
|
||||
const mxClient = useMatrixClientContext();
|
||||
@@ -52,7 +53,7 @@ export function useInputEventProcessor(
|
||||
|
||||
if (isEventToHandleAsClipboardEvent(event)) {
|
||||
const data = event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer;
|
||||
const handled = handleClipboardEvent(event, data, roomContext, mxClient, eventRelation);
|
||||
const handled = handleClipboardEvent(event, data, roomUploadVm);
|
||||
return handled ? null : event;
|
||||
}
|
||||
|
||||
@@ -77,11 +78,11 @@ export function useInputEventProcessor(
|
||||
isCtrlEnterToSend,
|
||||
onSend,
|
||||
initialContent,
|
||||
roomUploadVm,
|
||||
roomContext,
|
||||
composerContext,
|
||||
mxClient,
|
||||
autocompleteRef,
|
||||
eventRelation,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+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 AllowedMentionAttributes, type MappedSuggestion } from "@vector-im/matrix-wysiwyg";
|
||||
import { type IEventRelation } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useSettingValue } from "../../../../../hooks/useSettings";
|
||||
import { IS_MAC, Key } from "../../../../../Keyboard";
|
||||
@@ -16,8 +15,7 @@ import type Autocomplete from "../../Autocomplete";
|
||||
import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils";
|
||||
import { useSuggestion } from "./useSuggestion";
|
||||
import { isNotNull, isNotUndefined } from "../../../../../Typeguards";
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
|
||||
import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx";
|
||||
|
||||
function isDivElement(target: EventTarget): target is HTMLDivElement {
|
||||
return target instanceof HTMLDivElement;
|
||||
@@ -46,8 +44,8 @@ export function usePlainTextListeners(
|
||||
initialContent?: string,
|
||||
onChange?: (content: string) => void,
|
||||
onSend?: () => void,
|
||||
eventRelation?: IEventRelation,
|
||||
isAutoReplaceEmojiEnabled?: boolean,
|
||||
roomUploadVM?: RoomUploadViewModel,
|
||||
): {
|
||||
ref: RefObject<HTMLDivElement | null>;
|
||||
autocompleteRef: RefObject<Autocomplete | null>;
|
||||
@@ -64,9 +62,6 @@ export function usePlainTextListeners(
|
||||
onSelect: (this: void, event: SyntheticEvent<HTMLDivElement>) => void;
|
||||
suggestion: MappedSuggestion | null;
|
||||
} {
|
||||
const roomContext = useScopedRoomContext("room", "timelineRenderingType", "replyToEvent");
|
||||
const mxClient = useMatrixClientContext();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const autocompleteRef = useRef<Autocomplete>(null);
|
||||
const [content, setContent] = useState<string | undefined>(initialContent);
|
||||
@@ -120,10 +115,10 @@ export function usePlainTextListeners(
|
||||
const { nativeEvent } = event;
|
||||
let imagePasteWasHandled = false;
|
||||
|
||||
if (isEventToHandleAsClipboardEvent(nativeEvent)) {
|
||||
if (roomUploadVM && isEventToHandleAsClipboardEvent(nativeEvent)) {
|
||||
const data =
|
||||
nativeEvent instanceof ClipboardEvent ? nativeEvent.clipboardData : nativeEvent.dataTransfer;
|
||||
imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomContext, mxClient, eventRelation);
|
||||
imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomUploadVM);
|
||||
}
|
||||
|
||||
// prevent default behaviour and skip call to onInput if the image paste event was handled
|
||||
@@ -133,7 +128,7 @@ export function usePlainTextListeners(
|
||||
onInput(event);
|
||||
}
|
||||
},
|
||||
[eventRelation, mxClient, onInput, roomContext],
|
||||
[onInput, roomUploadVM],
|
||||
);
|
||||
|
||||
const enterShouldSend = !useSettingValue("MessageComposerInput.ctrlEnterToSend");
|
||||
|
||||
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RefObject } from "react";
|
||||
import { type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { type WysiwygEvent } from "@vector-im/matrix-wysiwyg";
|
||||
|
||||
import { type TimelineRenderingType } from "../../../../../contexts/RoomContext";
|
||||
@@ -16,8 +15,8 @@ import type Autocomplete from "../../Autocomplete";
|
||||
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
|
||||
import { getBlobSafeMimeType } from "../../../../../utils/blobs";
|
||||
import ContentMessages from "../../../../../ContentMessages";
|
||||
import { isNotNull } from "../../../../../Typeguards";
|
||||
import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel";
|
||||
|
||||
export function focusComposer(
|
||||
composerElement: RefObject<HTMLElement | null>,
|
||||
@@ -123,13 +122,8 @@ export function handleEventWithAutocomplete(
|
||||
export function handleClipboardEvent(
|
||||
event: ClipboardEvent | InputEvent,
|
||||
data: DataTransfer | null,
|
||||
roomContext: Pick<IRoomState, "room" | "timelineRenderingType" | "replyToEvent">,
|
||||
mxClient: MatrixClient,
|
||||
eventRelation?: IEventRelation,
|
||||
vm: RoomUploadViewModel,
|
||||
): boolean {
|
||||
// Logic in this function follows that of `SendMessageComposer.onPaste`
|
||||
const { room, timelineRenderingType, replyToEvent } = roomContext;
|
||||
|
||||
function handleError(error: unknown): void {
|
||||
if (error instanceof Error) {
|
||||
console.log(error.message);
|
||||
@@ -138,7 +132,7 @@ export function handleClipboardEvent(
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type !== "paste" || data === null || room === undefined) {
|
||||
if (event.type !== "paste" || data === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -147,16 +141,7 @@ export function handleClipboardEvent(
|
||||
// We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer
|
||||
// it puts the filename in as text/plain which we want to ignore.
|
||||
if (data.files.length && !data.types.includes("text/rtf")) {
|
||||
ContentMessages.sharedInstance()
|
||||
.sendContentListToRoom(
|
||||
Array.from(data.files),
|
||||
room.roomId,
|
||||
eventRelation,
|
||||
roomContext.replyToEvent,
|
||||
mxClient,
|
||||
timelineRenderingType,
|
||||
)
|
||||
.catch(handleError);
|
||||
vm.initiateViaDataTransfer(data).catch(handleError);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -188,9 +173,7 @@ export function handleClipboardEvent(
|
||||
const parts = response.url.split("/");
|
||||
const filename = parts[parts.length - 1];
|
||||
const file = new File([imgBlob], filename + "." + ext, { type: safetype });
|
||||
ContentMessages.sharedInstance()
|
||||
.sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent)
|
||||
.catch(handleError);
|
||||
return vm.initiateViaInputFiles([file]);
|
||||
})
|
||||
.catch(handleError);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user