Wire up drag-drop file uploads for the thread view (#7860)
This commit is contained in:
parent
42e9ea4540
commit
8fccef86d8
30 changed files with 616 additions and 482 deletions
120
src/components/structures/FileDropTarget.tsx
Normal file
120
src/components/structures/FileDropTarget.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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} />
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue