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() { 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.props.draft.to.length === 0) { this.refs.header.showAndFocusField(Fields.To); } else if ((this.props.draft.subject || "").trim().length === 0) { this.refs.header.showAndFocusField(Fields.Subject); } else { this.refs[Fields.Body].focus(); } } _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 ( {this._renderContent()} ); } return this._renderContent(); } _onNewHeaderComponents = () => { if (this.refs.header) { this.focus() } } _renderContent() { return (
{this._renderBodyRegions()} {this._renderFooterRegions()}
); } _renderBodyRegions() { const exposedProps = { draft: this.props.draft, session: this.props.session, } return (
{this._renderEditor()} {this._renderQuotedTextControl()} {this._renderAttachments()}
); } _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 ( ); } // 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 ( ••• ); } return false; } _onExpandQuotedText = () => { this.setState({ showQuotedText: true, showQuotedTextControl: false, }, () => { DraftHelpers.appendQuotedTextToDraft(this.props.draft) .then((draftWithQuotedText) => { this.props.session.changes.add({ body: `${draftWithQuotedText.body}
`, }) }) }) } _onRemoveQuotedText = (event) => { event.stopPropagation() const {session, draft} = this.props session.changes.add({ body: `${draft.body}
`, }) this.setState({ showQuotedText: false, showQuotedTextControl: false, }) } _renderFooterRegions() { return (
); } _renderAttachments() { return (
{this._renderFileAttachments()} {this._renderUploadAttachments()}
); } _renderFileAttachments() { const {files, clientId: messageClientId} = this.props.draft return ( ) } _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) => Actions.removeAttachment(upload)} /> ); const imageUploads = this._imageFiles(uploads) .filter(u => !u.inline) .map((upload) => Actions.removeAttachment(upload)} /> ); return nonImageUploads.concat(imageUploads); } _renderActionsWorkspaceRegion() { return ( ) } _renderActionsRegion() { return (
); } // 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 (
this.setState({isDropping})} onDrop={this._onDrop} >
Drop to attach
{this._renderContentScrollRegion()}
{this._renderActionsWorkspaceRegion()}
{this._renderActionsRegion()}
); } }