Mailspring/packages/client-app/internal_packages/composer/lib/composer-view.jsx
Juan Tejada f467664bde [client-app] Measure and report inline composer open times + consolidate timers
Summary:
This commit adds a new perf metric for inline composer times.

Additionally, it consolidates the timer logic for all other types of draft
creation (mailto links, dropping a file in the app dock icon, etc).

Test Plan: manual

Reviewers: halla, evan, mark

Reviewed By: evan, mark

Differential Revision: https://phab.nylas.com/D4186
2017-03-10 14:02:02 -08:00

662 lines
20 KiB
JavaScript

import React from 'react'
import ReactDOM from 'react-dom'
import {remote} from 'electron'
import {
Utils,
Actions,
DraftStore,
DraftHelpers,
} from 'nylas-exports'
import {
DropZone,
RetinaImg,
ScrollRegion,
TabGroupRegion,
AttachmentItem,
InjectedComponent,
KeyCommandsRegion,
OverlaidComponents,
ImageAttachmentItem,
InjectedComponentSet,
} from 'nylas-component-kit'
import ComposerEditor from './composer-editor'
import ComposerHeader from './composer-header'
import SendActionButton from './send-action-button'
import ActionBarPlugins from './action-bar-plugins'
import Fields from './fields'
// The ComposerView is a unique React component because it (currently) is a
// singleton. Normally, the React way to do things would be to re-render the
// Composer with new props.
export default class ComposerView extends React.Component {
static displayName = 'ComposerView';
static propTypes = {
session: React.PropTypes.object.isRequired,
draft: React.PropTypes.object.isRequired,
// Sometimes when changes in the composer happens it's desirable to
// have the parent scroll to a certain location. A parent component can
// pass a callback that gets called when this composer wants to be
// scrolled to.
scrollTo: React.PropTypes.func,
className: React.PropTypes.string,
}
constructor(props) {
super(props)
this.state = {
showQuotedText: DraftHelpers.isForwardedMessage(props.draft),
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(props.draft),
}
}
componentDidMount() {
this._recordComposerOpenTime()
if (this.props.session) {
this._setupForProps(this.props);
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.session !== this.props.session) {
this._teardownForProps();
this._setupForProps(nextProps);
}
if (DraftHelpers.isForwardedMessage(this.props.draft) !== DraftHelpers.isForwardedMessage(nextProps.draft) ||
DraftHelpers.shouldAppendQuotedText(this.props.draft) !== DraftHelpers.shouldAppendQuotedText(nextProps.draft)) {
this.setState({
showQuotedText: DraftHelpers.isForwardedMessage(nextProps.draft),
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(nextProps.draft),
});
}
}
componentWillUnmount() {
this._teardownForProps();
}
focus() {
if (this.refs.header.isFocused()) {
this.refs.header.focus();
} else {
this.refs[Fields.Body].focus();
}
}
_recordComposerOpenTime() {
const {draft: {threadId, replyToMessageId}} = this.props
if (NylasEnv.isComposerWindow()) { return }
// This method only records inline composer opening times. Composer window
// opening times are recorded in ComposerWithWindowPros
const replyTimerKey = `compose-reply-${replyToMessageId}`
const forwardTimerKey = `compose-forward-${threadId}`
let actionTimeMs;
if (NylasEnv.timer.isPending(replyTimerKey)) {
actionTimeMs = NylasEnv.timer.stop(replyTimerKey)
}
if (NylasEnv.timer.isPending(forwardTimerKey)) {
actionTimeMs = NylasEnv.timer.stop(forwardTimerKey)
}
if (actionTimeMs != null) {
Actions.recordPerfMetric({
action: 'open-inline-composer',
actionTimeMs,
maxValue: 4000,
sample: 0.9,
})
}
}
_keymapHandlers() {
return {
'composer:send-message': () => this._onPrimarySend(),
'composer:delete-empty-draft': () => {
if (this.props.draft.pristine) {
this._onDestroyDraft();
}
},
'composer:show-and-focus-bcc': () => this.refs.header.showAndFocusField(Fields.Bcc),
'composer:show-and-focus-cc': () => this.refs.header.showAndFocusField(Fields.Cc),
'composer:focus-to': () => this.refs.header.showAndFocusField(Fields.To),
"composer:show-and-focus-from": () => {},
"core:undo": (event) => {
event.preventDefault();
event.stopPropagation();
this.props.session.undo();
},
"core:redo": (event) => {
event.preventDefault();
event.stopPropagation();
this.props.session.redo();
},
};
}
_setupForProps({draft, session}) {
this.setState({
showQuotedText: DraftHelpers.isForwardedMessage(draft),
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(draft),
});
// TODO: This is a dirty hack to save selection state into the undo/redo
// history. Remove it if / when selection is written into the body with
// marker tags, or when selection is moved from `contenteditable.innerState`
// into a first-order part of the session state.
session._composerViewSelectionRetrieve = () => {
// Selection updates /before/ the contenteditable emits it's change event,
// so the selection that goes with the snapshot state is the previous one.
if (this.refs[Fields.Body].getPreviousSelection) {
return this.refs[Fields.Body].getPreviousSelection();
}
return null;
}
session._composerViewSelectionRestore = (selection) => {
this.refs[Fields.Body].setSelection(selection);
}
draft.files.forEach((file) => {
if (Utils.shouldDisplayAsImage(file)) {
Actions.fetchFile(file);
}
});
}
_teardownForProps() {
if (this.props.session) {
this.props.session._composerViewSelectionRestore = null;
this.props.session._composerViewSelectionRetrieve = null;
}
}
_renderContentScrollRegion() {
if (NylasEnv.isComposerWindow()) {
return (
<ScrollRegion className="compose-body-scroll" ref="scrollregion">
{this._renderContent()}
</ScrollRegion>
);
}
return this._renderContent();
}
_onNewHeaderComponents = () => {
if (this.refs.header) {
this.focus()
}
}
_renderContent() {
return (
<div className="composer-centered">
<ComposerHeader
ref="header"
draft={this.props.draft}
session={this.props.session}
initiallyFocused={this.props.draft.to.length === 0}
onNewHeaderComponents={this._onNewHeaderComponents}
/>
<div
className="compose-body"
ref="composeBody"
onMouseUp={this._onMouseUpComposerBody}
onMouseDown={this._onMouseDownComposerBody}
>
{this._renderBodyRegions()}
{this._renderFooterRegions()}
</div>
</div>
);
}
_renderBodyRegions() {
const exposedProps = {
draft: this.props.draft,
session: this.props.session,
}
return (
<div ref="composerBodyWrap" className="composer-body-wrap">
<OverlaidComponents exposedProps={exposedProps}>
{this._renderEditor()}
</OverlaidComponents>
{this._renderQuotedTextControl()}
{this._renderAttachments()}
</div>
);
}
_renderEditor() {
const exposedProps = {
body: this.props.draft.body,
draftClientId: this.props.draft.clientId,
parentActions: {
getComposerBoundingRect: this._getComposerBoundingRect,
scrollTo: this.props.scrollTo,
},
onFilePaste: this._onFileReceived,
onBodyChanged: this._onBodyChanged,
};
return (
<InjectedComponent
ref={Fields.Body}
className="body-field"
matching={{role: "Composer:Editor"}}
fallback={ComposerEditor}
requiredMethods={[
'focus',
'focusAbsoluteEnd',
'getPreviousSelection',
'setSelection',
'_onDOMMutated',
]}
exposedProps={exposedProps}
/>
);
}
// The contenteditable decides when to request a scroll based on the
// position of the cursor and its relative distance to this composer
// component. We provide it our boundingClientRect so it can calculate
// this value.
_getComposerBoundingRect = () => {
return ReactDOM.findDOMNode(this.refs.composerWrap).getBoundingClientRect()
}
_renderQuotedTextControl() {
if (this.state.showQuotedTextControl) {
return (
<a className="quoted-text-control" onClick={this._onExpandQuotedText}>
<span className="dots">&bull;&bull;&bull;</span>
<span className="remove-quoted-text" onClick={this._onRemoveQuotedText}>
<RetinaImg
title="Remove quoted text"
name="image-cancel-button.png"
mode={RetinaImg.Mode.ContentPreserve}
/>
</span>
</a>
);
}
return false;
}
_onExpandQuotedText = () => {
this.setState({
showQuotedText: true,
showQuotedTextControl: false,
}, () => {
DraftHelpers.appendQuotedTextToDraft(this.props.draft)
.then((draftWithQuotedText) => {
this.props.session.changes.add({
body: `${draftWithQuotedText.body}<div id="n1-quoted-text-marker" />`,
})
})
})
}
_onRemoveQuotedText = (event) => {
event.stopPropagation()
const {session, draft} = this.props
session.changes.add({
body: `${draft.body}<div id="n1-quoted-text-marker" />`,
})
this.setState({
showQuotedText: false,
showQuotedTextControl: false,
})
}
_renderFooterRegions() {
return (
<div className="composer-footer-region">
<InjectedComponentSet
matching={{role: "Composer:Footer"}}
exposedProps={{
draft: this.props.draft,
threadId: this.props.draft.threadId,
draftClientId: this.props.draft.clientId,
session: this.props.session,
}}
direction="column"
/>
</div>
);
}
_renderAttachments() {
return (
<div className="attachments-area">
{this._renderFileAttachments()}
{this._renderUploadAttachments()}
</div>
);
}
_renderFileAttachments() {
const {files, clientId: messageClientId} = this.props.draft
return (
<InjectedComponent
matching={{role: 'MessageAttachments'}}
exposedProps={{files, messageClientId, canRemoveAttachments: true}}
/>
)
}
_imageFiles(files) {
return files.filter(f => Utils.shouldDisplayAsImage(f));
}
_nonImageFiles(files) {
return files.filter(f => !Utils.shouldDisplayAsImage(f));
}
_renderUploadAttachments() {
const {uploads} = this.props.draft;
const nonImageUploads = this._nonImageFiles(uploads)
.map((upload) =>
<AttachmentItem
key={upload.id}
className="file-upload"
draggable={false}
filePath={upload.targetPath}
displayName={upload.filename}
fileIconName={`file-${upload.extension}.png`}
onRemoveAttachment={() => Actions.removeAttachment(upload)}
/>
);
const imageUploads = this._imageFiles(uploads)
.filter(u => !u.inline)
.map((upload) =>
<ImageAttachmentItem
key={upload.id}
className="file-upload"
draggable={false}
filePath={upload.targetPath}
displayName={upload.filename}
onRemoveAttachment={() => Actions.removeAttachment(upload)}
/>
);
return nonImageUploads.concat(imageUploads);
}
_renderActionsWorkspaceRegion() {
return (
<InjectedComponentSet
matching={{role: "Composer:ActionBarWorkspace"}}
exposedProps={{
draft: this.props.draft,
threadId: this.props.draft.threadId,
draftClientId: this.props.draft.clientId,
session: this.props.session,
}}
/>
)
}
_renderActionsRegion() {
return (
<div className="composer-action-bar-content">
<ActionBarPlugins
draft={this.props.draft}
session={this.props.session}
isValidDraft={this._isValidDraft}
/>
<button
tabIndex={-1}
className="btn btn-toolbar btn-trash"
style={{order: 100}}
title="Delete draft"
onClick={this._onDestroyDraft}
>
<RetinaImg name="icon-composer-trash.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
<button
tabIndex={-1}
className="btn btn-toolbar btn-attach"
style={{order: 50}}
title="Attach file"
onClick={this._onSelectAttachment}
>
<RetinaImg name="icon-composer-attachment.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
<div style={{order: 0, flex: 1}} />
<InjectedComponent
ref="sendActionButton"
tabIndex={-1}
style={{order: -100}}
matching={{role: "Composer:SendActionButton"}}
fallback={SendActionButton}
requiredMethods={[
'primarySend',
]}
exposedProps={{
draft: this.props.draft,
draftClientId: this.props.draft.clientId,
session: this.props.session,
isValidDraft: this._isValidDraft,
}}
/>
</div>
);
}
// This lets us click outside of the `contenteditable`'s `contentBody`
// and simulate what happens when you click beneath the text *in* the
// contentEditable.
// Unfortunately, we need to manually keep track of the "click" in
// separate mouseDown, mouseUp events because we need to ensure that the
// start and end target are both not in the contenteditable. This ensures
// that this behavior doesn't interfear with a click and drag selection.
_onMouseDownComposerBody = (event) => {
if (ReactDOM.findDOMNode(this.refs[Fields.Body]).contains(event.target)) {
this._mouseDownTarget = null;
} else {
this._mouseDownTarget = event.target;
}
}
_inFooterRegion(el) {
return el.closest && el.closest(".composer-footer-region, .overlaid-components")
}
_onMouseUpComposerBody = (event) => {
if (event.target === this._mouseDownTarget && !this._inFooterRegion(event.target)) {
// We don't set state directly here because we want the native
// contenteditable focus behavior. When the contenteditable gets focused
const bodyRect = ReactDOM.findDOMNode(this.refs[Fields.Body]).getBoundingClientRect()
if (event.pageY < bodyRect.top) {
this.refs[Fields.Body].focus()
} else {
this.refs[Fields.Body].focusAbsoluteEnd();
}
}
this._mouseDownTarget = null;
}
_onMouseMoveComposeBody = () => {
if (this._mouseComposeBody === "down") {
this._mouseComposeBody = "move";
}
}
_shouldAcceptDrop = (event) => {
// Ensure that you can't pick up a file and drop it on the same draft
const nonNativeFilePath = this._nonNativeFilePathForDrop(event);
const hasNativeFile = event.dataTransfer.files.length > 0;
const hasNonNativeFilePath = nonNativeFilePath !== null;
return hasNativeFile || hasNonNativeFilePath;
}
_nonNativeFilePathForDrop = (event) => {
if (event.dataTransfer.types.includes("text/nylas-file-url")) {
const downloadURL = event.dataTransfer.getData("text/nylas-file-url");
const downloadFilePath = downloadURL.split('file://')[1];
if (downloadFilePath) {
return downloadFilePath;
}
}
// Accept drops of images from within the app
if (event.dataTransfer.types.includes("text/uri-list")) {
const uri = event.dataTransfer.getData('text/uri-list')
if (uri.indexOf('file://') === 0) {
return decodeURI(uri.split('file://')[1]);
}
}
return null;
}
_onDrop = (event) => {
// Accept drops of real files from other applications
for (const file of Array.from(event.dataTransfer.files)) {
this._onFileReceived(file.path);
}
// Accept drops from attachment components / images within the app
const uri = this._nonNativeFilePathForDrop(event);
if (uri) {
this._onFileReceived(uri);
}
}
_onFileReceived = (filePath) => {
// called from onDrop and onFilePaste - assume images should be inline
Actions.addAttachment({
filePath: filePath,
messageClientId: this.props.draft.clientId,
onUploadCreated: (upload) => {
if (Utils.shouldDisplayAsImage(upload)) {
const {draft, session} = this.props;
const uploads = [].concat(draft.uploads);
const matchingUpload = uploads.find(u => u.id === upload.id);
if (matchingUpload) {
matchingUpload.inline = true;
session.changes.add({uploads})
Actions.insertAttachmentIntoDraft({
draftClientId: draft.clientId,
uploadId: matchingUpload.id,
});
}
}
},
});
}
_onBodyChanged = (event) => {
this.props.session.changes.add({body: event.target.value});
return;
}
_isValidDraft = (options = {}) => {
// We need to check the `DraftStore` because the `DraftStore` is
// immediately and synchronously updated as soon as this function
// fires. Since `setState` is asynchronous, if we used that as our only
// check, then we might get a false reading.
if (DraftStore.isSendingDraft(this.props.draft.clientId)) {
return false;
}
const dialog = remote.dialog;
const {session} = this.props
const {errors, warnings} = session.validateDraftForSending()
if (errors.length > 0) {
dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning',
buttons: ['Edit Message', 'Cancel'],
message: 'Cannot Send',
detail: errors[0],
});
return false;
}
if ((warnings.length > 0) && (!options.force)) {
const response = dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning',
buttons: ['Send Anyway', 'Cancel'],
message: 'Are you sure?',
detail: `Send ${warnings.join(' and ')}?`,
});
if (response === 0) { // response is button array index
return this._isValidDraft({force: true});
}
return false;
}
return true;
}
_onPrimarySend = () => {
this.refs.sendActionButton.primarySend();
}
_onDestroyDraft = () => {
Actions.destroyDraft(this.props.draft.clientId);
}
_onSelectAttachment = () => {
Actions.selectAttachment({messageClientId: this.props.draft.clientId});
}
render() {
const dropCoverDisplay = this.state.isDropping ? 'block' : 'none';
return (
<div className={this.props.className}>
<KeyCommandsRegion
localHandlers={this._keymapHandlers()}
className={"message-item-white-wrap composer-outer-wrap"}
tabIndex="-1"
ref="composerWrap"
>
<TabGroupRegion className="composer-inner-wrap">
<DropZone
className="composer-inner-wrap"
shouldAcceptDrop={this._shouldAcceptDrop}
onDragStateChange={({isDropping}) => this.setState({isDropping})}
onDrop={this._onDrop}
>
<div className="composer-drop-cover" style={{display: dropCoverDisplay}}>
<div className="centered">
<RetinaImg
name="composer-drop-to-attach.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
Drop to attach
</div>
</div>
<div className="composer-content-wrap">
{this._renderContentScrollRegion()}
</div>
<div className="composer-action-bar-workspace-wrap">
{this._renderActionsWorkspaceRegion()}
</div>
<div className="composer-action-bar-wrap" data-tooltips-anchor>
<div className="tooltips-container" />
{this._renderActionsRegion()}
</div>
</DropZone>
</TabGroupRegion>
</KeyCommandsRegion>
</div>
);
}
}