Wire up drag-drop file uploads for the thread view (#7860)

This commit is contained in:
Michael Telatynski 2022-02-22 11:14:56 +00:00 committed by GitHub
parent 42e9ea4540
commit 8fccef86d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 616 additions and 482 deletions

View file

@ -0,0 +1,120 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useState } from "react";
import { _t } from "../../languageHandler";
interface IProps {
parent: HTMLElement;
onFileDrop(dataTransfer: DataTransfer): void;
}
interface IState {
dragging: boolean;
counter: number;
}
const FileDropTarget: React.FC<IProps> = ({ parent, onFileDrop }) => {
const [state, setState] = useState<IState>({
dragging: false,
counter: 0,
});
useEffect(() => {
if (!parent || parent.ondrop) return;
const onDragEnter = (ev: DragEvent) => {
ev.stopPropagation();
ev.preventDefault();
setState(state => ({
// We always increment the counter no matter the types, because dragging is
// still happening. If we didn't, the drag counter would get out of sync.
counter: state.counter + 1,
// See:
// https://docs.w3cub.com/dom/datatransfer/types
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
dragging: (
ev.dataTransfer.types.includes("Files") ||
ev.dataTransfer.types.includes("application/x-moz-file")
) ? true : state.dragging,
}));
};
const onDragLeave = (ev: DragEvent) => {
ev.stopPropagation();
ev.preventDefault();
setState(state => ({
counter: state.counter - 1,
dragging: state.counter <= 1 ? false : state.dragging,
}));
};
const onDragOver = (ev: DragEvent) => {
ev.stopPropagation();
ev.preventDefault();
ev.dataTransfer.dropEffect = "none";
// See:
// https://docs.w3cub.com/dom/datatransfer/types
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
ev.dataTransfer.dropEffect = "copy";
}
};
const onDrop = (ev: DragEvent) => {
ev.stopPropagation();
ev.preventDefault();
onFileDrop(ev.dataTransfer);
setState(state => ({
dragging: false,
counter: state.counter - 1,
}));
};
parent.addEventListener("drop", onDrop);
parent.addEventListener("dragover", onDragOver);
parent.addEventListener("dragenter", onDragEnter);
parent.addEventListener("dragleave", onDragLeave);
return () => {
// disconnect the D&D event listeners from the room view. This
// is really just for hygiene - we're going to be
// deleted anyway, so it doesn't matter if the event listeners
// don't get cleaned up.
parent.removeEventListener("drop", onDrop);
parent.removeEventListener("dragover", onDragOver);
parent.removeEventListener("dragenter", onDragEnter);
parent.removeEventListener("dragleave", onDragLeave);
};
}, [parent, onFileDrop]);
if (state.dragging) {
return <div className="mx_FileDropTarget">
<img src={require("../../../res/img/upload-big.svg")} className="mx_FileDropTarget_image" alt="" />
{ _t("Drop file here to upload") }
</div>;
}
return null;
};
export default FileDropTarget;

View file

@ -20,7 +20,6 @@ import MatrixClientContext from "../../contexts/MatrixClientContext";
import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import { IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
@ -28,6 +27,7 @@ import { linkifyElement } from "../../HtmlUtils";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { IGroupSummary } from "../../@types/groups";
interface IProps {
groupId: string;

View file

@ -396,8 +396,7 @@ class LoggedInView extends React.Component<IProps, IState> {
inputableElement.focus();
} else {
const inThread = !!document.activeElement.closest(".mx_ThreadView");
// refocusing during a paste event will make the
// paste end up in the newly focused element,
// refocusing during a paste event will make the paste end up in the newly focused element,
// so dispatch synchronously before paste happens
dis.dispatch({
action: Action.FocusSendMessageComposer,

View file

@ -19,7 +19,6 @@ limitations under the License.
// TODO: This component is enormous! There's several things which could stand-alone:
// - Search results component
// - Drag and drop
import React, { createRef } from 'react';
import classNames from 'classnames';
@ -104,6 +103,7 @@ import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { JoinRoomPayload } from "../../dispatcher/payloads/JoinRoomPayload";
import { DoAfterSyncPreparedPayload } from '../../dispatcher/payloads/DoAfterSyncPreparedPayload';
import FileDropTarget from './FileDropTarget';
const DEBUG = false;
let debuglog = function(msg: string) {};
@ -153,7 +153,6 @@ export interface IRoomState {
isInitialEventHighlighted?: boolean;
replyToEvent?: MatrixEvent;
numUnreadMessages: number;
draggingFile: boolean;
searching: boolean;
searchTerm?: string;
searchScope?: SearchScope;
@ -205,7 +204,6 @@ export interface IRoomState {
rejectError?: Error;
hasPinnedWidgets?: boolean;
mainSplitContentType?: MainSplitContentType;
dragCounter: number;
// whether or not a spaces context switch brought us here,
// if it did we don't want the room to be marked as read as soon as it is loaded.
wasContextSwitch?: boolean;
@ -242,7 +240,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
shouldPeek: true,
membersLoaded: !llMembers,
numUnreadMessages: 0,
draggingFile: false,
searching: false,
searchResults: null,
callState: null,
@ -272,7 +269,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
showDisplaynameChanges: true,
matrixClientIsReady: this.context && this.context.isInitialSyncComplete(),
mainSplitContentType: MainSplitContentType.Timeline,
dragCounter: 0,
timelineRenderingType: TimelineRenderingType.Room,
liveTimeline: undefined,
};
@ -670,16 +666,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
componentDidUpdate() {
if (this.roomView.current) {
const roomView = this.roomView.current;
if (!roomView.ondrop) {
roomView.addEventListener('drop', this.onDrop);
roomView.addEventListener('dragover', this.onDragOver);
roomView.addEventListener('dragenter', this.onDragEnter);
roomView.addEventListener('dragleave', this.onDragLeave);
}
}
// Note: We check the ref here with a flag because componentDidMount, despite
// documentation, does not define our messagePanel ref. It looks like our spinner
// in render() prevents the ref from being set on first mount, so we try and
@ -714,17 +700,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// stop tracking room changes to format permalinks
this.stopAllPermalinkCreators();
if (this.roomView.current) {
// disconnect the D&D event listeners from the room view. This
// is really just for hygiene - we're going to be
// deleted anyway, so it doesn't matter if the event listeners
// don't get cleaned up.
const roomView = this.roomView.current;
roomView.removeEventListener('drop', this.onDrop);
roomView.removeEventListener('dragover', this.onDragOver);
roomView.removeEventListener('dragenter', this.onDragEnter);
roomView.removeEventListener('dragleave', this.onDragLeave);
}
dis.unregister(this.dispatcherRef);
if (this.context) {
this.context.removeListener("Room", this.onRoom);
@ -813,10 +788,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.jumpToReadMarker();
handled = true;
break;
case KeyBindingAction.UploadFile:
dis.dispatch({ action: "upload_file" }, true);
case KeyBindingAction.UploadFile: {
dis.dispatch({
action: "upload_file",
context: TimelineRenderingType.Room,
}, true);
handled = true;
break;
}
}
if (handled) {
@ -1311,65 +1290,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.updateTopUnreadMessagesBar();
};
private onDragEnter = ev => {
ev.stopPropagation();
ev.preventDefault();
// We always increment the counter no matter the types, because dragging is
// still happening. If we didn't, the drag counter would get out of sync.
this.setState({ dragCounter: this.state.dragCounter + 1 });
// See:
// https://docs.w3cub.com/dom/datatransfer/types
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
this.setState({ draggingFile: true });
}
};
private onDragLeave = ev => {
ev.stopPropagation();
ev.preventDefault();
this.setState({
dragCounter: this.state.dragCounter - 1,
});
if (this.state.dragCounter === 0) {
this.setState({
draggingFile: false,
});
}
};
private onDragOver = ev => {
ev.stopPropagation();
ev.preventDefault();
ev.dataTransfer.dropEffect = 'none';
// See:
// https://docs.w3cub.com/dom/datatransfer/types
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) {
ev.dataTransfer.dropEffect = 'copy';
}
};
private onDrop = ev => {
ev.stopPropagation();
ev.preventDefault();
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, null, this.context,
);
dis.fire(Action.FocusSendMessageComposer);
this.setState({
draggingFile: false,
dragCounter: this.state.dragCounter - 1,
});
};
private injectSticker(url: string, info: object, text: string, threadId: string | null) {
if (this.context.isGuest()) {
dis.dispatch({ action: 'require_registration' });
@ -1802,6 +1722,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
});
}
private onFileDrop = (dataTransfer: DataTransfer) => ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(dataTransfer.files),
this.state.room?.roomId ?? this.state.roomId,
null,
this.context,
TimelineRenderingType.Room,
);
render() {
if (!this.state.room) {
const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading;
@ -1902,19 +1830,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
}
let fileDropTarget = null;
if (this.state.draggingFile) {
fileDropTarget = (
<div className="mx_RoomView_fileDropTarget">
<img
src={require("../../../res/img/upload-big.svg")}
className="mx_RoomView_fileDropTarget_image"
/>
{ _t("Drop file here to upload") }
</div>
);
}
// We have successfully loaded this room, and are not previewing.
// Display the "normal" room view.
@ -2171,7 +2086,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
let mainSplitBody = <React.Fragment>
{ auxPanel }
<div className={timelineClasses}>
{ fileDropTarget }
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }

View file

@ -74,7 +74,6 @@ import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
@ -85,6 +84,7 @@ import { UIComponent } from "../../settings/UIFeature";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import PosthogTrackers from "../../PosthogTrackers";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { CreateEventField, IGroupSummary } from "../../@types/groups";
interface IProps {
space: Room;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { createRef, KeyboardEvent } from 'react';
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { RelationType } from 'matrix-js-sdk/src/@types/event';
import { Room } from 'matrix-js-sdk/src/models/room';
@ -46,6 +46,9 @@ import ThreadListContextMenu from '../views/context_menus/ThreadListContextMenu'
import RightPanelStore from '../../stores/right-panel/RightPanelStore';
import SettingsStore from "../../settings/SettingsStore";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import FileDropTarget from "./FileDropTarget";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
interface IProps {
room: Room;
@ -68,9 +71,11 @@ interface IState {
@replaceableComponent("structures.ThreadView")
export default class ThreadView extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
private dispatcherRef: string;
private timelinePanelRef: React.RefObject<TimelinePanel> = React.createRef();
private timelinePanelRef = createRef<TimelinePanel>();
private cardRef = createRef<HTMLDivElement>();
private readonly layoutWatcherRef: string;
constructor(props: IProps) {
@ -206,6 +211,27 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
};
private onKeyDown = (ev: KeyboardEvent) => {
let handled = false;
const action = getKeyBindingsManager().getRoomAction(ev);
switch (action) {
case KeyBindingAction.UploadFile: {
dis.dispatch({
action: "upload_file",
context: TimelineRenderingType.Thread,
}, true);
handled = true;
break;
}
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private renderThreadViewHeader = (): JSX.Element => {
return <div className="mx_ThreadPanel__header">
<span>{ _t("Thread") }</span>
@ -240,18 +266,32 @@ export default class ThreadView extends React.Component<IProps, IState> {
return timelineWindow.paginate(direction, limit);
};
public render(): JSX.Element {
const highlightedEventId = this.props.isInitialEventHighlighted
? this.props.initialEvent?.getId()
: null;
private onFileDrop = (dataTransfer: DataTransfer) => {
ContentMessages.sharedInstance().sendContentListToRoom(
Array.from(dataTransfer.files),
this.props.mxEvent.getRoomId(),
this.threadRelation,
MatrixClientPeg.get(),
TimelineRenderingType.Thread,
);
};
const threadRelation: IEventRelation = {
private get threadRelation(): IEventRelation {
return {
"rel_type": RelationType.Thread,
"event_id": this.state.thread?.id,
"m.in_reply_to": {
"event_id": this.state.lastThreadReply?.getId() ?? this.state.thread?.id,
},
};
}
public render(): JSX.Element {
const highlightedEventId = this.props.isInitialEventHighlighted
? this.props.initialEvent?.getId()
: null;
const threadRelation = this.threadRelation;
const messagePanelClassNames = classNames(
"mx_RoomView_messagePanel",
@ -272,8 +312,11 @@ export default class ThreadView extends React.Component<IProps, IState> {
onClose={this.props.onClose}
withoutScrollContainer={true}
header={this.renderThreadViewHeader()}
ref={this.cardRef}
onKeyDown={this.onKeyDown}
>
{ this.state.thread && (
{ this.state.thread && <div className="mx_ThreadView_timelinePanelWrapper">
<FileDropTarget parent={this.cardRef.current} onFileDrop={this.onFileDrop} />
<TimelinePanel
ref={this.timelinePanelRef}
showReadReceipts={false} // Hide the read receipts
@ -297,7 +340,7 @@ export default class ThreadView extends React.Component<IProps, IState> {
onUserScroll={this.onScroll}
onPaginationRequest={this.onPaginationRequest}
/>
) }
</div> }
{ ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && (
<UploadBar room={this.props.room} relation={threadRelation} />