diff --git a/internal_packages/composer/lib/composer-editor.jsx b/internal_packages/composer/lib/composer-editor.jsx
new file mode 100644
index 000000000..701e4dcec
--- /dev/null
+++ b/internal_packages/composer/lib/composer-editor.jsx
@@ -0,0 +1,231 @@
+import React, {Component, PropTypes} from 'react';
+import {ExtensionRegistry, DOMUtils} from 'nylas-exports';
+import {ScrollRegion, Contenteditable} from 'nylas-component-kit';
+
+/**
+ * Renders the text editor for the composer
+ * Any component registering in the ComponentRegistry with the role
+ * 'Composer:Editor' will receive these set of props.
+ *
+ * In order for the Composer to work correctly and have a complete set of
+ * functionality (like file pasting), any registered editor *must* call the
+ * provided callbacks at the appropriate time.
+ *
+ * @param {object} props - props for ComposerEditor
+ * @param {string} props.body - Html string with the draft content to be
+ * rendered by the editor
+ * @param {string} props.draftClientId - Id of the draft being currently edited
+ * @param {object} props.initialSelectionSnapshot - Initial content selection
+ * that was previously saved
+ * @param {object} props.parentActions - Object containg helper actions
+ * associated with the parent container
+ * @param {props.parentActions.getComposerBoundingRect} props.parentActions.getComposerBoundingRect
+ * @param {props.parentActions.scrollTo} props.parentActions.scrollTo
+ * @param {props.onFocus} props.onFocus
+ * @param {props.onFilePaste} props.onFilePaste
+ * @param {props.onBodyChanged} props.onBodyChanged
+ * @class ComposerEditor
+ */
+class ComposerEditor extends Component {
+ static displayName = 'ComposerEditor'
+
+ /**
+ * This function will return the {DOMRect} for the parent component
+ * @function
+ * @name props.parentActions.getComposerBoundingRect
+ */
+ /**
+ * This function will make the screen scrollTo the desired position in the
+ * message list
+ * @function
+ * @name props.parentActions.scrollTo
+ * @param {object} options
+ * @param {string} options.clientId - Id of the message we want to scroll to
+ * @param {string} [options.positon] - If clientId is provided, this optional
+ * parameter will indicate what position of the message to scrollTo. See
+ * {ScrollRegion}
+ * @param {DOMRect} options.rect - Bounding rect we want to scroll to
+ */
+ /**
+ * This function should be called when the editing region is focused by the user
+ * @callback props.onFocus
+ */
+ /**
+ * This function should be called when the user pastes a file into the editing
+ * region
+ * @callback props.onFilePaste
+ */
+ /**
+ * This function should be called when the body of the draft changes, i.e.
+ * when the editor is being typed into. It should pass in an object that looks
+ * like a DOM Event with the current value of the content.
+ * @callback props.onBodyChanged
+ * @param {object} event - DOMEvent-like object that contains information
+ * about the current value of the body
+ * @param {string} event.target.value - HTML string that represents the
+ * current content of the editor body
+ */
+ static propTypes = {
+ body: PropTypes.string.isRequired,
+ draftClientId: PropTypes.string,
+ initialSelectionSnapshot: PropTypes.object,
+ onFocus: PropTypes.func.isRequired,
+ onFilePaste: PropTypes.func.isRequired,
+ onBodyChanged: PropTypes.func.isRequired,
+ parentActions: PropTypes.shape({
+ scrollTo: PropTypes.func,
+ getComposerBoundingRect: PropTypes.func,
+ }),
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ extensions: ExtensionRegistry.Composer.extensions(),
+ };
+ this._coreExtension = {
+ onFocus: props.onFocus,
+ };
+ }
+
+ componentDidMount() {
+ this.unsub = ExtensionRegistry.Composer.listen(this._onExtensionsChanged);
+ }
+
+ componentWillUnmount() {
+ this.unsub();
+ }
+
+
+ // Public methods
+
+ /**
+ * @private
+ * Methods in ES6 classes should be defined using object method shorthand
+ * syntax (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Method_definitions),
+ * as opposed to arrow function syntax, if we want them to be enumerated
+ * on the prototype. Arrow function syntax is used to lexically bind the `this`
+ * value, and are specialized for non-method callbacks, where them picking up
+ * the this of their surrounding method or constructor is an advantage.
+ * See https://goo.gl/9ZMOGl for an example
+ * and http://www.2ality.com/2015/02/es6-classes-final.html for more info.
+ */
+
+ // TODO Get rid of these selection methods
+ getCurrentSelection() {
+ return this.refs.contenteditable.getCurrentSelection();
+ }
+
+ getPreviousSelection() {
+ return this.refs.contenteditable.getPreviousSelection();
+ }
+
+ focusEditor() {
+ this.refs.contenteditable.selectEnd();
+ }
+
+ /**
+ * @private
+ * This method was included so that the tests don't break
+ * TODO refactor the tests!
+ */
+ _onDOMMutated(mutations) {
+ this.refs.contenteditable._onDOMMutated(mutations);
+ }
+
+
+ // Helpers
+
+ _scrollToBottom = ()=> {
+ this.props.parentActions.scrollTo({
+ clientId: this.props.draftClientId,
+ position: ScrollRegion.ScrollPosition.Bottom,
+ });
+ }
+
+ /**
+ * @private
+ * If the bottom of the container we're scrolling to is really far away
+ * from the contenteditable and your scroll position, we don't want to
+ * jump away. This can commonly happen if the composer has a very tall
+ * image attachment. The "send" button may be 1000px away from the bottom
+ * of the contenteditable. props.parentActions.scrollToBottom moves to the bottom of
+ * the "send" button.
+ */
+ _bottomIsNearby = (editableNode)=> {
+ const parentRect = this.props.parentActions.getComposerBoundingRect();
+ const selfRect = editableNode.getBoundingClientRect();
+ return Math.abs(parentRect.bottom - selfRect.bottom) <= 250;
+ }
+
+ /**
+ * @private
+ * As you're typing a lot of content and the cursor begins to scroll off
+ * to the bottom, we want to make it look like we're tracking your
+ * typing.
+ */
+ _shouldScrollToBottom(selection, editableNode) {
+ return (
+ this.props.parentActions.scrollTo != null &&
+ DOMUtils.atEndOfContent(selection, editableNode) &&
+ this._bottomIsNearby(editableNode)
+ );
+ }
+
+ /**
+ * @private
+ * When the selectionState gets set (e.g. undo-ing and
+ * redo-ing) we need to make sure it's visible to the user.
+ *
+ * Unfortunately, we can't use the native `scrollIntoView` because it
+ * naively scrolls the whole window and doesn't know not to scroll if
+ * it's already in view. There's a new native method called
+ * `scrollIntoViewIfNeeded`, but this only works when the scroll
+ * container is a direct parent of the requested element. In this case
+ * the scroll container may be many levels up.
+ */
+ _ensureSelectionVisible = (selection, editableNode)=> {
+ // If our parent supports scroll, check for that
+ if (this._shouldScrollToBottom(selection, editableNode)) {
+ this._scrollToBottom();
+ } else if (this.props.parentActions.scrollTo != null) {
+ // Don't bother computing client rects if no scroll method has been provided
+ const rangeInScope = DOMUtils.getRangeInScope(editableNode);
+ if (!rangeInScope) return;
+
+ let rect = rangeInScope.getBoundingClientRect();
+ if (DOMUtils.isEmptyBoundingRect(rect)) {
+ rect = DOMUtils.getSelectionRectFromDOM(selection);
+ }
+ if (rect) {
+ this.props.parentActions.scrollTo({rect});
+ }
+ }
+ }
+
+
+ // Handlers
+
+ _onExtensionsChanged = ()=> {
+ this.setState({extensions: ExtensionRegistry.Composer.extensions()});
+ }
+
+
+ // Renderers
+
+ render() {
+ return (
+
This is a test"
- replyToMessageId: null
-
-makeComposer = ->
- @composer = NylasTestUtils.renderIntoDocument(
-
On Sep 3 2015, at 12:14 pm, Evan Morikawa <evan@evanmorikawa.com> wrote:""" + @composer = ReactTestUtils.renderIntoDocument( +
This is a test!
- Begin forwarded message: -""" + afterEach -> + DraftStore._cleanupAllSessions() + NylasTestUtils.removeFromDocument(@composer) - useDraft.call @, - from: [u1] - to: [u2] - subject: "Fwd: Test Forward Message 1" - body: @fwdBody + describe "when sending a new message", -> + it 'makes a request with the message contents', -> + useDraft.call @ + makeComposer.call @ + editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) + spyOn(@proxy.changes, "add") + editableNode.innerHTML = "Hello world" + @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) + expect(@proxy.changes.add).toHaveBeenCalled() + expect(@proxy.changes.add.calls.length).toBe 1 + body = @proxy.changes.add.calls[0].args[0].body + expect(body).toBe "Hello world" - makeComposer.call @ - @editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) - spyOn(@proxy.changes, "add") - - it 'begins with the forwarded message expanded', -> - expect(@editableNode.innerHTML).toBe @fwdBody - - it 'saves the full new body, plus forwarded text', -> - @editableNode.innerHTML = "Hello world#{@fwdBody}" - @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) - expect(@proxy.changes.add).toHaveBeenCalled() - expect(@proxy.changes.add.calls.length).toBe 1 - body = @proxy.changes.add.calls[0].args[0].body - expect(body).toBe """Hello world#{@fwdBody}""" - - describe "When displaying info from a draft", -> - beforeEach -> - useFullDraft.apply(@) - makeComposer.call(@) - - it "attaches the draft to the proxy", -> - expect(@draft).toBeDefined() - expect(@composer._proxy.draft()).toBe @draft - - it "sets the basic draft state", -> - expect(@composer.state.from).toEqual [u1] - expect(@composer.state.to).toEqual [u2] - expect(@composer.state.cc).toEqual [u3, u4] - expect(@composer.state.bcc).toEqual [u5] - expect(@composer.state.subject).toEqual "Test Message 1" - expect(@composer.state.files).toEqual [f1, f2] - expect(@composer.state.body).toEqual "Hello World
- From: Evan Morikawa <evan@evanmorikawa.com>
Subject: Test Forward Message 1
Date: Sep 3 2015, at 12:14 pm
To: Evan Morikawa <evan@nylas.com> -
+ @isSending = false + spyOn(DraftStore, "isSendingDraft").andCallFake => @isSending - This is a test! -
On Sep 3 2015, at 12:14 pm, Evan Morikawa <evan@evanmorikawa.com> wrote:""" useDraft.call @, - to: [u1] - subject: "Hello World" - body: pristineBody + from: [u1] + to: [u2] + subject: "Test Reply Message 1" + body: @replyBody + + makeComposer.call @ + @editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) + spyOn(@proxy.changes, "add") + + it 'begins with the replying message collapsed', -> + expect(@editableNode.innerHTML).toBe "" + + it 'saves the full new body, plus quoted text', -> + @editableNode.innerHTML = "Hello world" + @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) + expect(@proxy.changes.add).toHaveBeenCalled() + expect(@proxy.changes.add.calls.length).toBe 1 + body = @proxy.changes.add.calls[0].args[0].body + expect(body).toBe """Hello world#{@replyBody}""" + + describe "when sending a forwarded message message", -> + beforeEach -> + @fwdBody = """
This is a test!
+ Begin forwarded message: +""" + + useDraft.call @, + from: [u1] + to: [u2] + subject: "Fwd: Test Forward Message 1" + body: @fwdBody + + makeComposer.call @ + @editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) + spyOn(@proxy.changes, "add") + + it 'begins with the forwarded message expanded', -> + expect(@editableNode.innerHTML).toBe @fwdBody + + it 'saves the full new body, plus forwarded text', -> + @editableNode.innerHTML = "Hello world#{@fwdBody}" + @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) + expect(@proxy.changes.add).toHaveBeenCalled() + expect(@proxy.changes.add.calls.length).toBe 1 + body = @proxy.changes.add.calls[0].args[0].body + expect(body).toBe """Hello world#{@fwdBody}""" + + describe "When displaying info from a draft", -> + beforeEach -> + useFullDraft.apply(@) makeComposer.call(@) - spyOn(@composer._proxy, 'draftPristineBody').andCallFake -> pristineBody + it "attaches the draft to the proxy", -> + expect(@draft).toBeDefined() + expect(@composer._proxy.draft()).toBe @draft + it "sets the basic draft state", -> + expect(@composer.state.from).toEqual [u1] + expect(@composer.state.to).toEqual [u2] + expect(@composer.state.cc).toEqual [u3, u4] + expect(@composer.state.bcc).toEqual [u5] + expect(@composer.state.subject).toEqual "Test Message 1" + expect(@composer.state.files).toEqual [f1, f2] + expect(@composer.state.body).toEqual "Hello World
+ From: Evan Morikawa <evan@evanmorikawa.com>
Subject: Test Forward Message 1
Date: Sep 3 2015, at 12:14 pm
To: Evan Morikawa <evan@nylas.com> +
+ + This is a test! +
This is my quoted text!" + makeComposer.call(@) + @composer._sendDraft() + expect(Actions.sendDraft).toHaveBeenCalled() + + it "does not warn if the user has attached a file", -> + useDraft.call @, + to: [u1] + subject: "Hello World" + body: "" + files: [f1] + makeComposer.call(@) + @composer._sendDraft() + expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() + + it "shows a warning if there's no subject", -> + useDraft.call @, to: [u1], subject: "" + makeComposer.call(@) @composer._sendDraft() expect(Actions.sendDraft).not.toHaveBeenCalled() expect(@dialog.showMessageBox).toHaveBeenCalled() dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] - it "does not warn if the body of the email is all quoted text, but the email is a forward", -> - useDraft.call @, - to: [u1] - subject: "Fwd: Hello World" - body: "
This is my quoted text!" - makeComposer.call(@) + it "doesn't show a warning if requirements are satisfied", -> + useFullDraft.apply(@); makeComposer.call(@) @composer._sendDraft() expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() - it "does not warn if the user has attached a file", -> + describe "Checking for attachments", -> + warn = (body) -> + useDraft.call @, subject: "Subject", to: [u1], body: body + makeComposer.call(@); @composer._sendDraft() + expect(Actions.sendDraft).not.toHaveBeenCalled() + expect(@dialog.showMessageBox).toHaveBeenCalled() + dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] + expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] + + noWarn = (body) -> + useDraft.call @, subject: "Subject", to: [u1], body: body + makeComposer.call(@); @composer._sendDraft() + expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() + + it "warns", -> warn.call(@, "Check out the attached file") + it "warns", -> warn.call(@, "I've added an attachment") + it "warns", -> warn.call(@, "I'm going to attach the file") + it "warns", -> warn.call(@, "Hey attach me
sup") + + it "doesn't warn", -> noWarn.call(@, "sup yo") + it "doesn't warn", -> noWarn.call(@, "Look at the file") + it "doesn't warn", -> noWarn.call(@, "Hey there
attach") + + it "doesn't show a warning if you've attached a file", -> + useDraft.call @, + subject: "Subject" + to: [u1] + body: "Check out attached file" + files: [f1] + makeComposer.call(@); @composer._sendDraft() + expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() + + it "bypasses the warning if force bit is set", -> + useDraft.call @, to: [u1], subject: "" + makeComposer.call(@) + @composer._sendDraft(force: true) + expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() + + it "sends when you click the send button", -> + useFullDraft.apply(@); makeComposer.call(@) + sendBtn = React.findDOMNode(@composer.refs.sendButton) + ReactTestUtils.Simulate.click sendBtn + expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID) + expect(Actions.sendDraft.calls.length).toBe 1 + + it "doesn't send twice if you double click", -> + useFullDraft.apply(@); makeComposer.call(@) + sendBtn = React.findDOMNode(@composer.refs.sendButton) + ReactTestUtils.Simulate.click sendBtn + @isSending = true + DraftStore.trigger() + ReactTestUtils.Simulate.click sendBtn + expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID) + expect(Actions.sendDraft.calls.length).toBe 1 + + describe "when sending a message with keyboard inputs", -> + beforeEach -> + useFullDraft.apply(@) + makeComposer.call(@) + NylasTestUtils.loadKeymap("internal_packages/composer/keymaps/composer") + @$composer = @composer.refs.composerWrap + + it "sends the draft on cmd-enter", -> + if process.platform is "darwin" + cmdctrl = 'cmd' + else + cmdctrl = 'ctrl' + NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) + expect(Actions.sendDraft).toHaveBeenCalled() + expect(Actions.sendDraft.calls.length).toBe 1 + + it "does not send the draft on enter if the button isn't in focus", -> + NylasTestUtils.keyDown("enter", React.findDOMNode(@$composer)) + expect(Actions.sendDraft).not.toHaveBeenCalled() + + it "doesn't let you send twice", -> + if process.platform is "darwin" + cmdctrl = 'cmd' + else + cmdctrl = 'ctrl' + NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) + expect(Actions.sendDraft).toHaveBeenCalled() + expect(Actions.sendDraft.calls.length).toBe 1 + @isSending = true + DraftStore.trigger() + NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) + expect(Actions.sendDraft).toHaveBeenCalled() + expect(Actions.sendDraft.calls.length).toBe 1 + + describe "drag and drop", -> + beforeEach -> useDraft.call @, to: [u1] subject: "Hello World" body: "" files: [f1] makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() - it "shows a warning if there's no subject", -> - useDraft.call @, to: [u1], subject: "" - makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() - expect(@dialog.showMessageBox).toHaveBeenCalled() - dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] - expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] + describe "_shouldAcceptDrop", -> + it "should return true if the event is carrying native files", -> + event = + dataTransfer: + files:[{'pretend':'imafile'}] + types:[] + expect(@composer._shouldAcceptDrop(event)).toBe(true) - it "doesn't show a warning if requirements are satisfied", -> - useFullDraft.apply(@); makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() + it "should return true if the event is carrying a non-native file URL not on the draft", -> + event = + dataTransfer: + files:[] + types:['text/uri-list'] + spyOn(@composer, '_nonNativeFilePathForDrop').andReturn("file://one-file") + spyOn(FileUploadStore, 'linkedUpload').andReturn({filePath: "file://other-file"}) - describe "Checking for attachments", -> - warn = (body) -> - useDraft.call @, subject: "Subject", to: [u1], body: body - makeComposer.call(@); @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() - expect(@dialog.showMessageBox).toHaveBeenCalled() - dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] - expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] + expect(@composer.state.files.length).toBe(1) + expect(@composer._shouldAcceptDrop(event)).toBe(true) - noWarn = (body) -> - useDraft.call @, subject: "Subject", to: [u1], body: body - makeComposer.call(@); @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() + it "should return false if the event is carrying a non-native file URL already on the draft", -> + event = + dataTransfer: + files:[] + types:['text/uri-list'] + spyOn(@composer, '_nonNativeFilePathForDrop').andReturn("file://one-file") + spyOn(FileUploadStore, 'linkedUpload').andReturn({filePath: "file://one-file"}) - it "warns", -> warn.call(@, "Check out the attached file") - it "warns", -> warn.call(@, "I've added an attachment") - it "warns", -> warn.call(@, "I'm going to attach the file") - it "warns", -> warn.call(@, "Hey attach me
sup") + expect(@composer.state.files.length).toBe(1) + expect(@composer._shouldAcceptDrop(event)).toBe(false) - it "doesn't warn", -> noWarn.call(@, "sup yo") - it "doesn't warn", -> noWarn.call(@, "Look at the file") - it "doesn't warn", -> noWarn.call(@, "Hey there
attach") + it "should return false otherwise", -> + event = + dataTransfer: + files:[] + types:['text/plain'] + expect(@composer._shouldAcceptDrop(event)).toBe(false) - it "doesn't show a warning if you've attached a file", -> - useDraft.call @, - subject: "Subject" - to: [u1] - body: "Check out attached file" - files: [f1] - makeComposer.call(@); @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() + describe "_nonNativeFilePathForDrop", -> + it "should return a path in the text/nylas-file-url data", -> + event = + dataTransfer: + types: ['text/nylas-file-url'] + getData: -> "image/png:test.png:file:///Users/bengotow/Desktop/test.png" + expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/test.png") - it "bypasses the warning if force bit is set", -> - useDraft.call @, to: [u1], subject: "" - makeComposer.call(@) - @composer._sendDraft(force: true) - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() + it "should return a path in the text/uri-list data", -> + event = + dataTransfer: + types: ['text/uri-list'] + getData: -> "file:///Users/bengotow/Desktop/test.png" + expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/test.png") - it "sends when you click the send button", -> - useFullDraft.apply(@); makeComposer.call(@) - sendBtn = React.findDOMNode(@composer.refs.sendButton) - ReactTestUtils.Simulate.click sendBtn - expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID) - expect(Actions.sendDraft.calls.length).toBe 1 + it "should return null otherwise", -> + event = + dataTransfer: + types: ['text/plain'] + getData: -> "Hello world" + expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) - it "doesn't send twice if you double click", -> - useFullDraft.apply(@); makeComposer.call(@) - sendBtn = React.findDOMNode(@composer.refs.sendButton) - ReactTestUtils.Simulate.click sendBtn - @isSending = true - DraftStore.trigger() - ReactTestUtils.Simulate.click sendBtn - expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID) - expect(Actions.sendDraft.calls.length).toBe 1 + it "should urldecode the contents of the text/uri-list field", -> + event = + dataTransfer: + types: ['text/uri-list'] + getData: -> "file:///Users/bengotow/Desktop/Screen%20shot.png" + expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/Screen shot.png") - describe "when sending a message with keyboard inputs", -> + it "should return null if text/uri-list contains a non-file path", -> + event = + dataTransfer: + types: ['text/uri-list'] + getData: -> "http://apple.com" + expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) + + it "should return null if text/nylas-file-url contains a non-file path", -> + event = + dataTransfer: + types: ['text/nylas-file-url'] + getData: -> "application/json:filename.json:undefined" + expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) + + describe "when scrolling to track your cursor", -> + it "it tracks when you're at the end of the text", -> + + it "it doesn't track when typing in the middle of the body", -> + + it "it doesn't track when typing in the middle of the body", -> + + describe "When composing a new message", -> + it "Can add someone in the to field", -> + + it "Can add someone in the cc field", -> + + it "Can add someone in the bcc field", -> + + describe "When replying to a message", -> + + describe "When replying all to a message", -> + + describe "When forwarding a message", -> + + describe "When changing the subject of a message", -> + + describe "A draft with files (attachments) and uploads", -> beforeEach -> - useFullDraft.apply(@) - makeComposer.call(@) - NylasTestUtils.loadKeymap("internal_packages/composer/keymaps/composer") - @$composer = @composer.refs.composerWrap + @file1 = new File + id: "f_1" + filename: "f1.pdf" + size: 1230 - it "sends the draft on cmd-enter", -> - if process.platform is "darwin" - cmdctrl = 'cmd' - else - cmdctrl = 'ctrl' - NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) - expect(Actions.sendDraft).toHaveBeenCalled() - expect(Actions.sendDraft.calls.length).toBe 1 + @file2 = new File + id: "f_2" + filename: "f2.jpg" + size: 4560 - it "does not send the draft on enter if the button isn't in focus", -> - NylasTestUtils.keyDown("enter", React.findDOMNode(@$composer)) - expect(Actions.sendDraft).not.toHaveBeenCalled() + @file3 = new File + id: "f_3" + filename: "f3.png" + size: 7890 - it "doesn't let you send twice", -> - if process.platform is "darwin" - cmdctrl = 'cmd' - else - cmdctrl = 'ctrl' - NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) - expect(Actions.sendDraft).toHaveBeenCalled() - expect(Actions.sendDraft.calls.length).toBe 1 - @isSending = true - DraftStore.trigger() - NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) - expect(Actions.sendDraft).toHaveBeenCalled() - expect(Actions.sendDraft.calls.length).toBe 1 + @up1 = + uploadTaskId: 4 + messageClientId: DRAFT_CLIENT_ID + filePath: "/foo/bar/f4.bmp" + fileName: "f4.bmp" + fileSize: 1024 - describe "drag and drop", -> + @up2 = + uploadTaskId: 5 + messageClientId: DRAFT_CLIENT_ID + filePath: "/foo/bar/f5.zip" + fileName: "f5.zip" + fileSize: 1024 + + spyOn(Actions, "fetchFile") + spyOn(FileUploadStore, "linkedUpload").andReturn null + spyOn(FileUploadStore, "uploadsForMessage").andReturn [@up1, @up2] + + useDraft.call @, files: [@file1, @file2] + makeComposer.call @ + + it 'starts fetching attached files', -> + waitsFor -> + Actions.fetchFile.callCount == 1 + runs -> + expect(Actions.fetchFile).toHaveBeenCalled() + expect(Actions.fetchFile.calls.length).toBe(1) + expect(Actions.fetchFile.calls[0].args[0]).toBe @file2 + + it 'injects an Attachment component for non image files', -> + els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: {role: "Attachment"}) + expect(els.length).toBe 1 + + it 'injects an Attachment:Image component for image files', -> + els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: {role: "Attachment:Image"}) + expect(els.length).toBe 1 + + describe "when the DraftStore `isSending` isn't stubbed out", -> beforeEach -> - useDraft.call @, - to: [u1] - subject: "Hello World" - body: "" - files: [f1] + DraftStore._draftsSending = {} + + it "doesn't send twice in a popout", -> + spyOn(Actions, "queueTask") + spyOn(Actions, "sendDraft").andCallThrough() + useFullDraft.call(@) makeComposer.call(@) + @composer._sendDraft() + @composer._sendDraft() + expect(Actions.sendDraft.calls.length).toBe 1 - describe "_shouldAcceptDrop", -> - it "should return true if the event is carrying native files", -> - event = - dataTransfer: - files:[{'pretend':'imafile'}] - types:[] - expect(@composer._shouldAcceptDrop(event)).toBe(true) - - it "should return true if the event is carrying a non-native file URL not on the draft", -> - event = - dataTransfer: - files:[] - types:['text/uri-list'] - spyOn(@composer, '_nonNativeFilePathForDrop').andReturn("file://one-file") - spyOn(FileUploadStore, 'linkedUpload').andReturn({filePath: "file://other-file"}) - - expect(@composer.state.files.length).toBe(1) - expect(@composer._shouldAcceptDrop(event)).toBe(true) - - it "should return false if the event is carrying a non-native file URL already on the draft", -> - event = - dataTransfer: - files:[] - types:['text/uri-list'] - spyOn(@composer, '_nonNativeFilePathForDrop').andReturn("file://one-file") - spyOn(FileUploadStore, 'linkedUpload').andReturn({filePath: "file://one-file"}) - - expect(@composer.state.files.length).toBe(1) - expect(@composer._shouldAcceptDrop(event)).toBe(false) - - it "should return false otherwise", -> - event = - dataTransfer: - files:[] - types:['text/plain'] - expect(@composer._shouldAcceptDrop(event)).toBe(false) - - describe "_nonNativeFilePathForDrop", -> - it "should return a path in the text/nylas-file-url data", -> - event = - dataTransfer: - types: ['text/nylas-file-url'] - getData: -> "image/png:test.png:file:///Users/bengotow/Desktop/test.png" - expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/test.png") - - it "should return a path in the text/uri-list data", -> - event = - dataTransfer: - types: ['text/uri-list'] - getData: -> "file:///Users/bengotow/Desktop/test.png" - expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/test.png") - - it "should return null otherwise", -> - event = - dataTransfer: - types: ['text/plain'] - getData: -> "Hello world" - expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) - - it "should urldecode the contents of the text/uri-list field", -> - event = - dataTransfer: - types: ['text/uri-list'] - getData: -> "file:///Users/bengotow/Desktop/Screen%20shot.png" - expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/Screen shot.png") - - it "should return null if text/uri-list contains a non-file path", -> - event = - dataTransfer: - types: ['text/uri-list'] - getData: -> "http://apple.com" - expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) - - it "should return null if text/nylas-file-url contains a non-file path", -> - event = - dataTransfer: - types: ['text/nylas-file-url'] - getData: -> "application/json:filename.json:undefined" - expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) - - describe "when scrolling to track your cursor", -> - it "it tracks when you're at the end of the text", -> - - it "it doesn't track when typing in the middle of the body", -> - - it "it doesn't track when typing in the middle of the body", -> - - describe "When composing a new message", -> - it "Can add someone in the to field", -> - - it "Can add someone in the cc field", -> - - it "Can add someone in the bcc field", -> - - describe "When replying to a message", -> - - describe "When replying all to a message", -> - - describe "When forwarding a message", -> - - describe "When changing the subject of a message", -> - - describe "A draft with files (attachments) and uploads", -> - beforeEach -> - @file1 = new File - id: "f_1" - filename: "f1.pdf" - size: 1230 - - @file2 = new File - id: "f_2" - filename: "f2.jpg" - size: 4560 - - @file3 = new File - id: "f_3" - filename: "f3.png" - size: 7890 - - @up1 = - uploadTaskId: 4 - messageClientId: DRAFT_CLIENT_ID - filePath: "/foo/bar/f4.bmp" - fileName: "f4.bmp" - fileSize: 1024 - - @up2 = - uploadTaskId: 5 - messageClientId: DRAFT_CLIENT_ID - filePath: "/foo/bar/f5.zip" - fileName: "f5.zip" - fileSize: 1024 - - spyOn(Actions, "fetchFile") - spyOn(FileUploadStore, "linkedUpload").andReturn null - spyOn(FileUploadStore, "uploadsForMessage").andReturn [@up1, @up2] - - useDraft.call @, files: [@file1, @file2] - makeComposer.call @ - - it 'starts fetching attached files', -> - waitsFor -> - Actions.fetchFile.callCount == 1 - runs -> - expect(Actions.fetchFile).toHaveBeenCalled() - expect(Actions.fetchFile.calls.length).toBe(1) - expect(Actions.fetchFile.calls[0].args[0]).toBe @file2 - - it 'injects an Attachment component for non image files', -> - els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: {role: "Attachment"}) - expect(els.length).toBe 1 - - it 'injects an Attachment:Image component for image files', -> - els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: {role: "Attachment:Image"}) - expect(els.length).toBe 1 - -describe "when the DraftStore `isSending` isn't stubbed out", -> - beforeEach -> - DraftStore._draftsSending = {} - - it "doesn't send twice in a popout", -> - spyOn(Actions, "queueTask") - spyOn(Actions, "sendDraft").andCallThrough() - useFullDraft.call(@) - makeComposer.call(@) - @composer._sendDraft() - @composer._sendDraft() - expect(Actions.sendDraft.calls.length).toBe 1 - - it "doesn't send twice in the main window", -> - spyOn(Actions, "queueTask") - spyOn(Actions, "sendDraft").andCallThrough() - spyOn(NylasEnv, "isMainWindow").andReturn true - useFullDraft.call(@) - makeComposer.call(@) - @composer._sendDraft() - @composer._sendDraft() - expect(Actions.sendDraft.calls.length).toBe 1 + it "doesn't send twice in the main window", -> + spyOn(Actions, "queueTask") + spyOn(Actions, "sendDraft").andCallThrough() + spyOn(NylasEnv, "isMainWindow").andReturn true + useFullDraft.call(@) + makeComposer.call(@) + @composer._sendDraft() + @composer._sendDraft() + expect(Actions.sendDraft.calls.length).toBe 1 diff --git a/internal_packages/composer/spec/quoted-text-spec.cjsx b/internal_packages/composer/spec/quoted-text-spec.cjsx index 768f022ad..3a635766e 100644 --- a/internal_packages/composer/spec/quoted-text-spec.cjsx +++ b/internal_packages/composer/spec/quoted-text-spec.cjsx @@ -9,10 +9,18 @@ ReactTestUtils = React.addons.TestUtils Fields = require '../lib/fields' Composer = require "../lib/composer-view" -{DraftStore} = require 'nylas-exports' +ComposerEditor = require '../lib/composer-editor' + +{DraftStore, ComponentRegistry} = require 'nylas-exports' describe "Composer Quoted Text", -> beforeEach -> + # TODO + # Extract ComposerEditor tests instead of rendering injected component + # here + ComposerEditor.containerRequired = false + ComponentRegistry.register(ComposerEditor, role: "Composer:Editor") + @onChange = jasmine.createSpy('onChange') @htmlNoQuote = 'Test HTML
QUOTE' @@ -28,6 +36,8 @@ describe "Composer Quoted Text", -> afterEach -> DraftStore._cleanupAllSessions() + ComposerEditor.containerRequired = undefined + ComponentRegistry.unregister(ComposerEditor) # Must be called with the test's scope setHTML = (newHTML) -> diff --git a/internal_packages/message-list/lib/message-item-container.cjsx b/internal_packages/message-list/lib/message-item-container.cjsx index b6eb65089..94c1b7815 100644 --- a/internal_packages/message-list/lib/message-item-container.cjsx +++ b/internal_packages/message-list/lib/message-item-container.cjsx @@ -18,7 +18,7 @@ class MessageItemContainer extends React.Component collapsed: React.PropTypes.bool isLastMsg: React.PropTypes.bool isBeforeReplyArea: React.PropTypes.bool - onRequestScrollTo: React.PropTypes.func + scrollTo: React.PropTypes.func constructor: (@props) -> @state = @_getStateFromStores() @@ -62,7 +62,7 @@ class MessageItemContainer extends React.Component mode: "inline" draftClientId: @props.message.clientId threadId: @props.thread.id - onRequestScrollTo: @props.onRequestScrollTo + scrollTo: @props.scrollTo
""" - it 'works with these manual test cases', -> for {before, after} in tests opts = keepIfWholeBodyIsQuote: true @@ -273,6 +272,26 @@ describe "QuotedHTMLTransformer", -> expect0 = "
"
expect(QuotedHTMLTransformer.removeQuotedHTML(input0)).toEqual expect0
+ it 'works as expected when body tag inside the html', ->
+ input0 = """
+
+ On Dec 16 2015, at 7:08 pm, Juan Tejada <juan@nylas.com> wrote: +
+ + + + ++ + +h2
+he he hehehehehehe
+dufjcasc
+
+ """ + expect0 = "
" + expect(QuotedHTMLTransformer.removeQuotedHTML(input0)).toEqual expect0 # We have a little utility method that you can manually uncomment to diff --git a/src/components/contenteditable/contenteditable.cjsx b/src/components/contenteditable/contenteditable.cjsx index 3e409925e..2fa232c49 100644 --- a/src/components/contenteditable/contenteditable.cjsx +++ b/src/components/contenteditable/contenteditable.cjsx @@ -35,13 +35,11 @@ class Contenteditable extends React.Component # The current html state, as a string, of the contenteditable. value: React.PropTypes.string - initialSelectionSnapshot: React.PropTypes.object + # Initial content selection that was previously saved + initialSelectionSnapshot: React.PropTypes.object, # Handlers onChange: React.PropTypes.func.isRequired - # Passes an absolute top coordinate to scroll to. - onScrollTo: React.PropTypes.func - onScrollToBottom: React.PropTypes.func onFilePaste: React.PropTypes.func # A list of objects that extend {ContenteditableExtension} @@ -549,7 +547,7 @@ class Contenteditable extends React.Component endNodeIndex: DOMUtils.getNodeIndex(context, selection.focusNode) isCollapsed: selection.isCollapsed - @_ensureSelectionVisible(selection) + @_onSelectionChanged(selection) @setInnerState selection: @_selection @@ -564,54 +562,11 @@ class Contenteditable extends React.Component selection: @_selection editableFocused: true - # When the selectionState gets set by a parent (e.g. undo-ing and - # redo-ing) we need to make sure it's visible to the user. - # - # Unfortunately, we can't use the native `scrollIntoView` because it - # naively scrolls the whole window and doesn't know not to scroll if - # it's already in view. There's a new native method called - # `scrollIntoViewIfNeeded`, but this only works when the scroll - # container is a direct parent of the requested element. In this case - # the scroll container may be many levels up. - _ensureSelectionVisible: (selection) -> - # If our parent supports scroll to bottom, check for that - if @_shouldScrollToBottom(selection) - @props.onScrollToBottom() - - # Don't bother computing client rects if no scroll method has been provided - else if @props.onScrollTo - rangeInScope = DOMUtils.getRangeInScope(@_editableNode()) - return unless rangeInScope - - rect = rangeInScope.getBoundingClientRect() - if DOMUtils.isEmptyBoudingRect(rect) - rect = DOMUtils.getSelectionRectFromDOM(selection) - - if rect - @props.onScrollTo({rect}) - + _onSelectionChanged: (selection) -> + @props.onSelectionChanged(selection, @_editableNode()) # The bounding client rect has changed @setInnerState editableNode: @_editableNode() - # As you're typing a lot of content and the cursor begins to scroll off - # to the bottom, we want to make it look like we're tracking your - # typing. - _shouldScrollToBottom: (selection) -> - (@props.onScrollToBottom and - DOMUtils.atEndOfContent(selection, @_editableNode()) and - @_bottomIsNearby()) - - # If the bottom of the container we're scrolling to is really far away - # from this contenteditable and your scroll position, we don't want to - # jump away. This can commonly happen if the composer has a very tall - # image attachment. The "send" button may be 1000px away from the bottom - # of the contenteditable. props.onScrollToBottom moves to the bottom of - # the "send" button. - _bottomIsNearby: -> - parentRect = @props.getComposerBoundingRect() - selfRect = @_editableNode().getBoundingClientRect() - return Math.abs(parentRect.bottom - selfRect.bottom) <= 250 - # We use global listeners to determine whether or not dragging is # happening. This is because dragging may stop outside the scope of # this element. Note that the `dragstart` and `dragend` events don't @@ -741,7 +696,7 @@ class Contenteditable extends React.Component newEndNode, @_selection.endOffset) - @_ensureSelectionVisible(selection) + @_onSelectionChanged(selection) @_setupListeners() # This needs to be in the contenteditable area because we need to first diff --git a/src/components/injected-component-set.cjsx b/src/components/injected-component-set.cjsx index 7710fb83e..86ee538ab 100644 --- a/src/components/injected-component-set.cjsx +++ b/src/components/injected-component-set.cjsx @@ -57,6 +57,7 @@ class InjectedComponentSet extends React.Component className: React.PropTypes.string exposedProps: React.PropTypes.object containersRequired: React.PropTypes.bool + requiredMethods: React.PropTypes.arrayOf(React.PropTypes.string) @defaultProps: direction: 'row' diff --git a/src/components/injected-component.cjsx b/src/components/injected-component.cjsx index 49343419e..84a3e90a5 100644 --- a/src/components/injected-component.cjsx +++ b/src/components/injected-component.cjsx @@ -41,14 +41,28 @@ class InjectedComponent extends React.Component - `exposedProps` (optional) An {Object} with props that will be passed to each item rendered into the set. + - `requiredMethods` (options) An {Array} with a list of methods that should be + implemented by the registered component instance. If these are not implemented, + an error will be thrown. + + - `fallback` (optional) A {Component} to default to in case there are no matching + components in the ComponentRegistry + ### @propTypes: matching: React.PropTypes.object.isRequired className: React.PropTypes.string exposedProps: React.PropTypes.object + fallback: React.PropTypes.func + requiredMethods: React.PropTypes.arrayOf(React.PropTypes.string) + + @defaultProps: + requiredMethods: [] constructor: (@props) -> @state = @_getStateFromStores() + @_verifyRequiredMethods() + @_setRequiredMethods(@props.requiredMethods) componentDidMount: => @_componentUnlistener = ComponentRegistry.listen => @@ -61,6 +75,9 @@ class InjectedComponent extends React.Component if not _.isEqual(newProps.matching, @props?.matching) @setState(@_getStateFromStores(newProps)) + componentDidUpdate: => + @_setRequiredMethods(@props.requiredMethods) + render: => return
unless @state.component
@@ -69,7 +86,6 @@ class InjectedComponent extends React.Component
className += " registered-region-visible" if @state.visible
component = @state.component
-
if component.containerRequired is false
element =