diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 065df3770..192c59d19 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -26,6 +26,7 @@ FileUpload = require './file-upload' ImageFileUpload = require './image-file-upload' ComposerEditor = require './composer-editor' +SendActionButton = require './send-action-button' ExpandedParticipants = require './expanded-participants' CollapsedParticipants = require './collapsed-participants' @@ -111,7 +112,7 @@ class ComposerView extends React.Component @_applyFieldFocus() _keymapHandlers: -> - 'composer:send-message': => @_sendDraft() + 'composer:send-message': => @_onPrimarySend() 'composer:delete-empty-draft': => @_deleteDraftIfEmpty() 'composer:show-and-focus-bcc': => @_onAdjustEnabledFields(show: [Fields.Bcc]) @@ -468,9 +469,9 @@ class ComposerView extends React.Component
- + @@ -687,14 +688,14 @@ class ComposerView extends React.Component @_saveToHistory(selections) unless source.fromUndoManager - _sendDraft: (options = {}) => - return unless @_proxy + _isValidDraft: (options = {}) => + return false unless @_proxy # 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. - return if DraftStore.isSendingDraft(@props.draftClientId) + return false if DraftStore.isSendingDraft(@props.draftClientId) draft = @_proxy.draft() remote = require('remote') @@ -714,7 +715,7 @@ class ComposerView extends React.Component message: 'Cannot Send', detail: dealbreaker }) - return + return false bodyIsEmpty = draft.body is @_proxy.draftPristineBody() forwarded = Utils.isForwardedMessage(draft) @@ -744,10 +745,13 @@ class ComposerView extends React.Component detail: "Send #{warnings.join(' and ')}?" }) if response is 0 # response is button array index - @_sendDraft({force: true}) - return + return @_isValidDraft({force: true}) + else return false - Actions.sendDraft(@props.draftClientId) + return true + + _onPrimarySend: -> + @refs["sendActionButton"].primaryClick() _mentionsAttachment: (body) => body = QuotedHTMLTransformer.removeQuotedHTML(body.toLowerCase().trim()) diff --git a/internal_packages/composer/lib/send-action-button.cjsx b/internal_packages/composer/lib/send-action-button.cjsx new file mode 100644 index 000000000..57bc56cfc --- /dev/null +++ b/internal_packages/composer/lib/send-action-button.cjsx @@ -0,0 +1,145 @@ +_ = require 'underscore' +_str = require 'underscore.string' +{React, Actions, ExtensionRegistry} = require 'nylas-exports' +{Menu, RetinaImg, ButtonDropdown} = require 'nylas-component-kit' + +class SendActionButton extends React.Component + @displayName: "SendActionButton" + + @propTypes: + draft: React.PropTypes.object + isValidDraft: React.PropTypes.func + + @CONFIG_KEY: "core.sending.defaultSendType" + + constructor: (@props) -> + @state = + actionConfigs: @_actionConfigs(@props) + selectedSendType: NylasEnv.config.get(SendActionButton.CONFIG_KEY) ? @_defaultActionConfig().configKey + + componentDidMount: -> + @unsub = ExtensionRegistry.Composer.listen(@_onExtensionsChanged) + + componentWillReceiveProps: (newProps) -> + @setState actionConfigs: @_actionConfigs(newProps) + + componentWillUnmount: -> + @unsub() + + primaryClick: => @_onPrimaryClick() + + _configKeyFromTitle: (title) -> + return _str.dasherize(title.toLowerCase()) + + _onExtensionsChanged: => + @setState actionConfigs: @_actionConfigs(@props) + + _defaultActionConfig: -> + title: "Send" + iconUrl: null + onSend: ({draft}) -> Actions.sendDraft(draft.clientId) + configKey: "send" + + _actionConfigs: (props) -> + return [] unless props.draft + actionConfigs = [@_defaultActionConfig()] + + for extension in ExtensionRegistry.Composer.extensions() + try + actionConfig = extension.sendActionConfig?({draft: props.draft}) + if actionConfig + @_verifyConfig(actionConfig, extension) + actionConfig.configKey = @_configKeyFromTitle(actionConfig.title) + actionConfigs.push(actionConfig) + catch err + NylasEnv.emitError(err) + + return actionConfigs + + _verifyConfig: (config={}, extension) -> + name = extension.name + if not _.isString(config.title) + throw new Error("#{name}.sendActionConfig must return a string `title`") + + if not _.isFunction(config.onSend) + throw new Error("#{name}.sendActionConfig must return a `onSend` function that will be called when the action is selected") + + return true + + render: -> + return false if not @props.draft + if @state.actionConfigs.length is 1 + @_renderSingleDefaultButton() + else + @_renderSendDropdown() + + _onPrimaryClick: => + actionConfigs = @_orderedActionConfigs() + @_sendWithAction(actionConfigs[0].onSend) + + _renderSingleDefaultButton: -> + classes = "btn btn-toolbar btn-normal btn-emphasis btn-text btn-send" + iconUrl = @state.actionConfigs[0].iconUrl + + + _renderSendDropdown: -> + actionConfigs = @_orderedActionConfigs() + + + _orderedActionConfigs: -> + configKeys = _.pluck(@state.actionConfigs, "configKey") + if @state.selectedSendType not in configKeys + selectedSendType = @_defaultActionConfig().configKey + else + selectedSendType = @state.selectedSendType + + primary = _.findWhere(@state.actionConfigs, configKey: selectedSendType) + rest = _.reject @state.actionConfigs, (config) -> + config.configKey is selectedSendType + + return [primary].concat(rest) + + _sendWithAction: (onSend) -> + isValidDraft = @props.isValidDraft() + if isValidDraft + try + onSend({draft: @props.draft}) + catch err + NylasEnv.emitError(err) + + _dropdownMenu: (actionConfigs) -> + actionConfig.configKey } + itemContent={ (actionConfig) => @_sendContent(actionConfig.iconUrl) } + onSelect={@_menuItemSelect} + /> + + _menuItemSelect: (actionConfig) => + @setState selectedSendType: actionConfig.configKey + + _sendContent: (iconUrl) -> + sendIcon = "icon-composer-send.png" + + if iconUrl + plusHTML =  +  + additionalImg = + else + plusHTML = "" + additionalImg = "" + + + + Send{plusHTML}{additionalImg} + + +module.exports = SendActionButton diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index a3ef04649..59ec7fd31 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -110,6 +110,7 @@ describe "ComposerView", -> DRAFT_CLIENT_ID = "local-123" useDraft = (draftAttributes={}) -> @draft = new Message _.extend({draft: true, body: ""}, draftAttributes) + @draft.clientId = DRAFT_CLIENT_ID draft = @draft proxy = draftStoreProxyStub(DRAFT_CLIENT_ID, @draft) @proxy = proxy @@ -426,8 +427,8 @@ describe "ComposerView", -> it "shows an error if there are no recipients", -> useDraft.call @, subject: "no recipients" makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() + status = @composer._isValidDraft() + expect(status).toBe false expect(@dialog.showMessageBox).toHaveBeenCalled() dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] expect(dialogArgs.detail).toEqual("You need to provide one or more recipients before sending the message.") @@ -438,8 +439,8 @@ describe "ComposerView", -> subject: 'hello world!' to: [new Contact(email: 'lol', name: 'lol')] makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() + status = @composer._isValidDraft() + expect(status).toBe false expect(@dialog.showMessageBox).toHaveBeenCalled() dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] expect(dialogArgs.detail).toEqual("lol is not a valid email address - please remove or edit it before sending.") @@ -457,8 +458,8 @@ describe "ComposerView", -> spyOn(@composer._proxy, 'draftPristineBody').andCallFake -> pristineBody - @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() + status = @composer._isValidDraft() + expect(status).toBe false expect(@dialog.showMessageBox).toHaveBeenCalled() dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] @@ -469,8 +470,8 @@ describe "ComposerView", -> subject: "Fwd: Hello World" body: "

This is my quoted text!
" makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() + status = @composer._isValidDraft() + expect(status).toBe true it "does not warn if the user has attached a file", -> useDraft.call @, @@ -479,38 +480,40 @@ describe "ComposerView", -> body: "" files: [f1] makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() + status = @composer._isValidDraft() + expect(status).toBe true 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() + status = @composer._isValidDraft() + expect(status).toBe false expect(@dialog.showMessageBox).toHaveBeenCalled() dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] it "doesn't show a warning if requirements are satisfied", -> useFullDraft.apply(@); makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() + status = @composer._isValidDraft() + expect(status).toBe true expect(@dialog.showMessageBox).not.toHaveBeenCalled() describe "Checking for attachments", -> warn = (body) -> useDraft.call @, subject: "Subject", to: [u1], body: body - makeComposer.call(@); @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() + makeComposer.call(@) + status = @composer._isValidDraft() + expect(status).toBe false 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() + makeComposer.call(@) + status = @composer._isValidDraft() + expect(status).toBe true expect(@dialog.showMessageBox).not.toHaveBeenCalled() it "warns", -> warn.call(@, "Check out the attached file") @@ -528,31 +531,32 @@ describe "ComposerView", -> to: [u1] body: "Check out attached file" files: [f1] - makeComposer.call(@); @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() + makeComposer.call(@) + status = @composer._isValidDraft() + expect(status).toBe true 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() + status = @composer._isValidDraft(force: true) + expect(status).toBe true 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 + sendBtn = @composer.refs.sendActionButton + sendBtn.primaryClick() 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 + sendBtn = @composer.refs.sendActionButton + sendBtn.primaryClick() @isSending = true DraftStore.trigger() - ReactTestUtils.Simulate.click sendBtn + sendBtn.primaryClick() expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID) expect(Actions.sendDraft.calls.length).toBe 1 @@ -764,9 +768,12 @@ describe "ComposerView", -> spyOn(Actions, "sendDraft").andCallThrough() useFullDraft.call(@) makeComposer.call(@) - @composer._sendDraft() - @composer._sendDraft() - expect(Actions.sendDraft.calls.length).toBe 1 + + firstStatus = @composer._isValidDraft() + expect(firstStatus).toBe true + Actions.sendDraft(DRAFT_CLIENT_ID) + secondStatus = @composer._isValidDraft() + expect(secondStatus).toBe false it "doesn't send twice in the main window", -> spyOn(Actions, "queueTask") @@ -774,6 +781,9 @@ describe "ComposerView", -> spyOn(NylasEnv, "isMainWindow").andReturn true useFullDraft.call(@) makeComposer.call(@) - @composer._sendDraft() - @composer._sendDraft() - expect(Actions.sendDraft.calls.length).toBe 1 + firstStatus = @composer._isValidDraft() + expect(firstStatus).toBe true + Actions.sendDraft(DRAFT_CLIENT_ID) + secondStatus = @composer._isValidDraft() + expect(secondStatus).toBe false + diff --git a/internal_packages/composer/spec/quoted-text-spec.cjsx b/internal_packages/composer/spec/quoted-text-spec.cjsx index f56101588..9f4437a7d 100644 --- a/internal_packages/composer/spec/quoted-text-spec.cjsx +++ b/internal_packages/composer/spec/quoted-text-spec.cjsx @@ -11,7 +11,7 @@ Fields = require '../lib/fields' Composer = require "../lib/composer-view" ComposerEditor = require '../lib/composer-editor' -{DraftStore, ComponentRegistry} = require 'nylas-exports' +{Message, DraftStore, ComponentRegistry} = require 'nylas-exports' describe "Composer Quoted Text", -> beforeEach -> @@ -27,8 +27,12 @@ describe "Composer Quoted Text", -> spyOn(Composer.prototype, "_prepareForDraft") + @draft = new Message(draft: true, clientId: "client-123") + @composer = ReactTestUtils.renderIntoDocument() - @composer._proxy = trigger: -> + @composer._proxy = + trigger: -> + draft: => @draft spyOn(@composer, "_addToProxy") spyOn(@composer, "_setupSession") diff --git a/internal_packages/composer/spec/send-actions-spec.cjsx b/internal_packages/composer/spec/send-actions-spec.cjsx new file mode 100644 index 000000000..7e3ba47dc --- /dev/null +++ b/internal_packages/composer/spec/send-actions-spec.cjsx @@ -0,0 +1,191 @@ +React = require "react/addons" +ReactTestUtils = React.addons.TestUtils + +SendActionButton = require '../lib/send-action-button' +{Actions, Message, ComposerExtension, ExtensionRegistry} = require 'nylas-exports' +{ButtonDropdown, RetinaImg} = require 'nylas-component-kit' + +class NAExtension extends ComposerExtension + +class GoodExtension extends ComposerExtension + @sendActionConfig: ({draft}) -> + title: "Good Extension" + content: ->
+ onSend: -> + +class SecondExtension extends ComposerExtension + @sendActionConfig: ({draft}) -> + title: "Second Extension" + content: ->
+ onSend: -> + +class NullExtension extends ComposerExtension + @sendActionConfig: ({draft}) -> null + +isValidDraft = null + +describe "SendActionButton", -> + render = (draft, valid=true) -> + isValidDraft = jasmine.createSpy("isValidDraft").andReturn(valid) + + ReactTestUtils.renderIntoDocument( + + ) + + beforeEach -> + spyOn(NylasEnv, "emitError") + spyOn(Actions, "sendDraft") + @clientId = "client-23" + @draft = new Message(clientId: @clientId, draft: true) + + it "renders without error", -> + @sendActionButton = render(@draft) + expect(ReactTestUtils.isCompositeComponentWithType @sendActionButton, SendActionButton).toBe true + + it "is a single button when there are no extensions", -> + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [] + @sendActionButton = render(@draft) + dropdowns = ReactTestUtils.scryRenderedComponentsWithType(@sendActionButton, ButtonDropdown) + buttons = ReactTestUtils.scryRenderedDOMComponentsWithTag(@sendActionButton, "button") + expect(buttons.length).toBe 1 + expect(dropdowns.length).toBe 0 + + it "is a dropdown when there's another valid extension", -> + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [GoodExtension] + @sendActionButton = render(@draft) + dropdowns = ReactTestUtils.scryRenderedComponentsWithType(@sendActionButton, ButtonDropdown) + buttons = ReactTestUtils.scryRenderedDOMComponentsWithTag(@sendActionButton, "button") + expect(buttons.length).toBe 0 + expect(dropdowns.length).toBe 1 + + it "has the correct primary item", -> + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [GoodExtension, SecondExtension] + @sendActionButton = render(@draft) + @sendActionButton.setState(selectedSendType: 'second-extension') + dropdown = ReactTestUtils.findRenderedComponentWithType(@sendActionButton, ButtonDropdown) + expect(dropdown.props.primaryTitle).toBe "Second Extension" + + it "falls back to a default if the primary item can't be found", -> + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [GoodExtension, SecondExtension] + @sendActionButton = render(@draft) + @sendActionButton.setState(selectedSendType: 'does-not-exist') + dropdown = ReactTestUtils.findRenderedComponentWithType(@sendActionButton, ButtonDropdown) + expect(dropdown.props.primaryTitle).toBe "Send" + + it "is a single button when a valid extension returns null", -> + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [NullExtension] + @sendActionButton = render(@draft) + dropdowns = ReactTestUtils.scryRenderedComponentsWithType(@sendActionButton, ButtonDropdown) + buttons = ReactTestUtils.scryRenderedDOMComponentsWithTag(@sendActionButton, "button") + expect(buttons.length).toBe 1 + expect(dropdowns.length).toBe 0 + + it "still renders but catches when an extension is missing a title", -> + class NoTitle extends ComposerExtension + @sendActionConfig: ({draft}) -> + title: null + iconUrl: "nylas://foo/bar/baz" + onSend: -> + + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [NoTitle] + @sendActionButton = render(@draft) + dropdowns = ReactTestUtils.scryRenderedComponentsWithType(@sendActionButton, ButtonDropdown) + buttons = ReactTestUtils.scryRenderedDOMComponentsWithTag(@sendActionButton, "button") + expect(buttons.length).toBe 1 + expect(dropdowns.length).toBe 0 + expect(NylasEnv.emitError).toHaveBeenCalled() + expect(NylasEnv.emitError.calls[0].args[0].message).toMatch /title/ + + it "still renders with a null iconUrl and doesn't show the image", -> + class NoIconUrl extends ComposerExtension + @sendActionConfig: ({draft}) -> + title: "some title" + iconUrl: null + onSend: -> + + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [NoIconUrl] + @sendActionButton = render(@draft) + @sendActionButton.setState(selectedSendType: 'some-title') + dropdowns = ReactTestUtils.scryRenderedComponentsWithType(@sendActionButton, ButtonDropdown) + icons = ReactTestUtils.scryRenderedComponentsWithType(@sendActionButton, RetinaImg) + buttons = ReactTestUtils.scryRenderedDOMComponentsWithTag(@sendActionButton, "button") + expect(buttons.length).toBe 0 # It's a dropdown instead + expect(dropdowns.length).toBe 1 + expect(icons.length).toBe 3 + + it "still renders but catches when an extension is missing an onSend", -> + class NoClick extends ComposerExtension + @sendActionConfig: ({draft}) -> + title: "some title" + iconUrl: "nylas://foo/bar/baz" + onSend: null + + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [NoClick] + @sendActionButton = render(@draft) + dropdowns = ReactTestUtils.scryRenderedComponentsWithType(@sendActionButton, ButtonDropdown) + buttons = ReactTestUtils.scryRenderedDOMComponentsWithTag(@sendActionButton, "button") + expect(buttons.length).toBe 1 + expect(dropdowns.length).toBe 0 + expect(NylasEnv.emitError).toHaveBeenCalled() + expect(NylasEnv.emitError.calls[0].args[0].message).toMatch /onSend/ + + it "sends a draft by default", -> + @sendActionButton = render(@draft) + button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@sendActionButton, "button")) + ReactTestUtils.Simulate.click(button) + expect(isValidDraft).toHaveBeenCalled() + expect(Actions.sendDraft).toHaveBeenCalled() + expect(Actions.sendDraft.calls[0].args[0]).toBe @draft.clientId + + it "doesn't send a draft if the isValidDraft fails", -> + @sendActionButton = render(@draft, false) + button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@sendActionButton, "button")) + ReactTestUtils.Simulate.click(button) + expect(isValidDraft).toHaveBeenCalled() + expect(Actions.sendDraft).not.toHaveBeenCalled() + + it "does the primaryClick action of the extension", -> + clicked = false + class Click extends ComposerExtension + @sendActionConfig: ({draft}) -> + title: "click" + iconUrl: "nylas://foo/bar/baz" + onSend: -> clicked = "onSend fired" + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [Click] + + @sendActionButton = render(@draft) + @sendActionButton.setState(selectedSendType: 'click') + + button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(@sendActionButton, "primary-item")) + ReactTestUtils.Simulate.click(button) + expect(clicked).toBe "onSend fired" + + it "catches any errors in an extension's primaryClick method", -> + clicked = false + class Click extends ComposerExtension + @sendActionConfig: ({draft}) -> + title: "click" + iconUrl: "nylas://foo/bar/baz" + onSend: -> throw new Error("BOO") + spyOn(ExtensionRegistry.Composer, "extensions").andReturn [Click] + + @sendActionButton = render(@draft) + @sendActionButton.setState(selectedSendType: 'click') + + button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(@sendActionButton, "primary-item")) + ReactTestUtils.Simulate.click(button) + expect(clicked).toBe false + expect(NylasEnv.emitError).toHaveBeenCalled() + expect(NylasEnv.emitError.calls[0].args[0].message).toMatch /BOO/ + + it "initializes with the correct config item", -> + spyOn(NylasEnv.config, "get").andReturn "test-state" + @sendActionButton = render(@draft) + expect(@sendActionButton.state.selectedSendType).toBe "test-state" + + it "initializes with the default key", -> + spyOn(NylasEnv.config, "get").andReturn null + @sendActionButton = render(@draft) + expect(@sendActionButton.state.selectedSendType).toBe "send" + + diff --git a/internal_packages/send-and-archive/images/composer-archive@2x.png b/internal_packages/send-and-archive/images/composer-archive@2x.png new file mode 100644 index 000000000..f6a66f97a Binary files /dev/null and b/internal_packages/send-and-archive/images/composer-archive@2x.png differ diff --git a/internal_packages/send-and-archive/lib/main.coffee b/internal_packages/send-and-archive/lib/main.coffee new file mode 100644 index 000000000..f41c4fc95 --- /dev/null +++ b/internal_packages/send-and-archive/lib/main.coffee @@ -0,0 +1,11 @@ +{ExtensionRegistry} = require 'nylas-exports' +SendAndArchiveExtension = require './send-and-archive-extension' + +module.exports = + activate: (@state={}) -> + ExtensionRegistry.Composer.register(SendAndArchiveExtension) + + deactivate: -> + ExtensionRegistry.Composer.unregister(SendAndArchiveExtension) + + serialize: -> @state diff --git a/internal_packages/send-and-archive/lib/send-and-archive-extension.cjsx b/internal_packages/send-and-archive/lib/send-and-archive-extension.cjsx new file mode 100644 index 000000000..6911aaf08 --- /dev/null +++ b/internal_packages/send-and-archive/lib/send-and-archive-extension.cjsx @@ -0,0 +1,26 @@ +{React, + Actions, + TaskFactory, + ComposerExtension, + FocusedMailViewStore} = require 'nylas-exports' + +{RetinaImg} = require 'nylas-component-kit' + +class SendAndArchiveExtension extends ComposerExtension + @sendActionConfig: ({draft}) -> + if draft.threadId + return { + title: "Send and Archive" + iconUrl: "nylas://send-and-archive/images/composer-archive@2x.png" + onSend: @_sendAndArchive + } + else return null + + @_sendAndArchive: ({draft}) -> + Actions.sendDraft(draft.clientId) + archiveTask = TaskFactory.taskForArchiving + threads: [draft.threadId] + fromView: FocusedMailViewStore.mailView() + Actions.queueTask(archiveTask) + +module.exports = SendAndArchiveExtension diff --git a/internal_packages/send-and-archive/package.json b/internal_packages/send-and-archive/package.json new file mode 100755 index 000000000..be75f2ba1 --- /dev/null +++ b/internal_packages/send-and-archive/package.json @@ -0,0 +1,17 @@ +{ + "name": "composer-signature", + "version": "0.1.0", + "main": "./lib/main", + "description": "A small extension to the draft store that implements signatures", + "license": "GPL-3.0", + "private": true, + "engines": { + "nylas": "*" + }, + "windowTypes": { + "default": true, + "composer": true + }, + "dependencies": { + } +} diff --git a/internal_packages/send-and-archive/spec/send-and-archive-spec.coffee b/internal_packages/send-and-archive/spec/send-and-archive-spec.coffee new file mode 100644 index 000000000..8b08590dd --- /dev/null +++ b/internal_packages/send-and-archive/spec/send-and-archive-spec.coffee @@ -0,0 +1 @@ +describe "SendAndArchive", -> diff --git a/internal_packages/send-and-archive/styles/send-and-archive.less b/internal_packages/send-and-archive/styles/send-and-archive.less new file mode 100644 index 000000000..7d57ba587 --- /dev/null +++ b/internal_packages/send-and-archive/styles/send-and-archive.less @@ -0,0 +1,2 @@ +.send-and-archive { +} diff --git a/spec/stores/draft-store-spec.coffee b/spec/stores/draft-store-spec.coffee index cf3f7476b..133462873 100644 --- a/spec/stores/draft-store-spec.coffee +++ b/spec/stores/draft-store-spec.coffee @@ -9,9 +9,11 @@ DatabaseStore, SoundRegistry, SendDraftTask, + ChangeMailTask, DestroyDraftTask, ComposerExtension, ExtensionRegistry, + FocusedContentStore, DatabaseTransaction, SanitizeTransformer, InlineStyleTransformer} = require 'nylas-exports' @@ -657,7 +659,7 @@ describe "DraftStore", -> DraftStore._draftSessions = {} DraftStore._draftsSending = {} @forceCommit = false - msg = new Message(clientId: draftClientId) + msg = new Message(clientId: draftClientId, threadId: "thread-123", replyToMessageId: "message-123") proxy = prepare: -> Promise.resolve(proxy) teardown: -> @@ -744,6 +746,28 @@ describe "DraftStore", -> runs -> expect(@forceCommit).toBe true + it "includes the threadId", -> + runs -> + DraftStore._onSendDraft(draftClientId) + waitsFor -> + DraftStore._doneWithSession.calls.length > 0 + runs -> + expect(Actions.queueTask).toHaveBeenCalled() + task = Actions.queueTask.calls[0].args[0] + expect(task instanceof SendDraftTask).toBe true + expect(task.threadId).toBe "thread-123" + + it "includes the replyToMessageId", -> + runs -> + DraftStore._onSendDraft(draftClientId) + waitsFor -> + DraftStore._doneWithSession.calls.length > 0 + runs -> + expect(Actions.queueTask).toHaveBeenCalled() + task = Actions.queueTask.calls[0].args[0] + expect(task instanceof SendDraftTask).toBe true + expect(task.replyToMessageId).toBe "message-123" + it "queues a SendDraftTask", -> runs -> DraftStore._onSendDraft(draftClientId) @@ -754,20 +778,6 @@ describe "DraftStore", -> task = Actions.queueTask.calls[0].args[0] expect(task instanceof SendDraftTask).toBe true expect(task.draftClientId).toBe draftClientId - expect(task.fromPopout).toBe false - - it "queues a SendDraftTask with popout info", -> - spyOn(NylasEnv, "getWindowType").andReturn "composer" - spyOn(NylasEnv, "isMainWindow").andReturn false - spyOn(NylasEnv, "close") - runs -> - DraftStore._onSendDraft(draftClientId) - waitsFor -> - DraftStore._doneWithSession.calls.length > 0 - runs -> - expect(Actions.queueTask).toHaveBeenCalled() - task = Actions.queueTask.calls[0].args[0] - expect(task.fromPopout).toBe true it "resets the sending state if there's an error", -> spyOn(NylasEnv, "isMainWindow").andReturn false @@ -778,17 +788,44 @@ describe "DraftStore", -> it "displays a popup in the main window if there's an error", -> spyOn(NylasEnv, "isMainWindow").andReturn true + spyOn(FocusedContentStore, "focused").andReturn(id: "t1") remote = require('remote') dialog = remote.require('dialog') spyOn(dialog, "showMessageBox") + spyOn(Actions, "composePopoutDraft") DraftStore._draftsSending[draftClientId] = true - Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId}) + Actions.draftSendingFailed({threadId: 't1', errorMessage: "boohoo", draftClientId}) advanceClock(200) expect(DraftStore.isSendingDraft(draftClientId)).toBe false expect(DraftStore.trigger).toHaveBeenCalledWith(draftClientId) expect(dialog.showMessageBox).toHaveBeenCalled() dialogArgs = dialog.showMessageBox.mostRecentCall.args[1] expect(dialogArgs.detail).toEqual("boohoo") + expect(Actions.composePopoutDraft).not.toHaveBeenCalled + + it "re-opens the draft if you're not looking at the thread", -> + spyOn(NylasEnv, "isMainWindow").andReturn true + spyOn(FocusedContentStore, "focused").andReturn(id: "t1") + spyOn(Actions, "composePopoutDraft") + DraftStore._draftsSending[draftClientId] = true + Actions.draftSendingFailed({threadId: 't2', errorMessage: "boohoo", draftClientId}) + advanceClock(200) + expect(Actions.composePopoutDraft).toHaveBeenCalled + call = Actions.composePopoutDraft.calls[0] + expect(call.args[0]).toBe draftClientId + expect(call.args[1]).toEqual {errorMessage: "boohoo"} + + it "re-opens the draft if there is no thread id", -> + spyOn(NylasEnv, "isMainWindow").andReturn true + spyOn(Actions, "composePopoutDraft") + DraftStore._draftsSending[draftClientId] = true + spyOn(FocusedContentStore, "focused").andReturn(null) + Actions.draftSendingFailed({errorMessage: "boohoo", draftClientId}) + advanceClock(200) + expect(Actions.composePopoutDraft).toHaveBeenCalled + call = Actions.composePopoutDraft.calls[0] + expect(call.args[0]).toBe draftClientId + expect(call.args[1]).toEqual {errorMessage: "boohoo"} describe "session teardown", -> beforeEach -> diff --git a/spec/tasks/send-draft-spec.coffee b/spec/tasks/send-draft-spec.coffee index a3924cf0d..3eec3d4e6 100644 --- a/spec/tasks/send-draft-spec.coffee +++ b/spec/tasks/send-draft-spec.coffee @@ -74,6 +74,8 @@ describe "SendDraftTask", -> expect(task.backupDraft.clientId).toBe "local-123" expect(task.backupDraft.serverId).toBe "server-123" expect(task.backupDraft).not.toBe draft # It's a clone + expect(task.replyToMessageId).not.toBeDefined() + expect(task.threadId).not.toBeDefined() expect(calledBody).toBe Message.attributes.body describe "performRemote", -> diff --git a/src/components/button-dropdown.cjsx b/src/components/button-dropdown.cjsx index 97fc066c2..d27af7905 100644 --- a/src/components/button-dropdown.cjsx +++ b/src/components/button-dropdown.cjsx @@ -9,6 +9,11 @@ class ButtonDropdown extends React.Component primaryClick: React.PropTypes.func bordered: React.PropTypes.bool menu: React.PropTypes.element + style: React.PropTypes.object + closeOnMenuClick: React.PropTypes.bool + + @defaultProps: + style: {} constructor: (@props) -> @state = showing: false @@ -19,7 +24,7 @@ class ButtonDropdown extends React.Component classnames += " bordered" if @props.bordered isnt false if @props.primaryClick -
+
@@ -28,19 +33,19 @@ class ButtonDropdown extends React.Component
-
+
{@props.menu}
else -
+
{@props.primaryItem}
-
+
{@props.menu}
@@ -48,6 +53,10 @@ class ButtonDropdown extends React.Component toggleDropdown: => @setState(showing: !@state.showing) + _onMenuClick: (event) => + if @props.closeOnMenuClick + @setState showing: false + _onBlur: (event) => target = event.nativeEvent.relatedTarget if target? and React.findDOMNode(@refs.button).contains(target) diff --git a/src/config-schema.coffee b/src/config-schema.coffee index 9304dd0ca..59e1b42ce 100644 --- a/src/config-schema.coffee +++ b/src/config-schema.coffee @@ -80,6 +80,12 @@ module.exports = enum: ['reply', 'reply-all'] enumLabels: ['Reply', 'Reply All'] title: "Default reply behavior" + defaultSendType: + type: 'string' + default: 'send' + enum: ['send', 'send-and-archive'] + enumLabels: ['Send', 'Send and Archive'] + title: "Default send behavior" notifications: type: 'object' properties: diff --git a/src/extensions/composer-extension.coffee b/src/extensions/composer-extension.coffee index b4fdb9471..ab01931d2 100644 --- a/src/extensions/composer-extension.coffee +++ b/src/extensions/composer-extension.coffee @@ -36,6 +36,34 @@ Section: Extensions ### class ComposerExtension extends ContenteditableExtension + ### + Public: Allows the addition of new types of send actions such as "Send + Later" + + - `draft`: A fully populated {Message} object that is about to be sent. + + Return an object that adheres to the following spec. If the draft data + indicates that your action should not be available, then return null. + + - `title`: A short, single string that is displayed to users when + describing your component. It is used in the hover title text of your + option in the dropdown menu. It is also used in the "Default Send + Behavior" dropdown setting. If your string is selected, then the + `core.sending.defaultSendType` will be set to your string and your + option will appear as the default. + ## TODO FIXME: The preferences does not yet know how to dynamically + # pick these up. For now they are hard-coded. + + - `onSend`: Callback for when your option is clicked as the primary + action. The function will be passed `{draft}` as its only argument. + It does not need to return anything. It may be asynchronous and likely + queue Tasks. + + - `iconUrl`: A custom icon to be placed in the Send button. SendAction + extensions have the form "Send + {ICON}" + ### + @sendActionConfig: ({draft}) -> + ### Public: Inspect the draft, and return any warnings that need to be displayed before the draft is sent. Warnings should be string phrases, diff --git a/src/flux/stores/draft-store.coffee b/src/flux/stores/draft-store.coffee index d01b4fc65..dd5acf78f 100644 --- a/src/flux/stores/draft-store.coffee +++ b/src/flux/stores/draft-store.coffee @@ -8,6 +8,7 @@ DraftStoreProxy = require './draft-store-proxy' DatabaseStore = require './database-store' AccountStore = require './account-store' ContactStore = require './contact-store' +FocusedContentStore = require './focused-content-store' SendDraftTask = require '../tasks/send-draft' DestroyDraftTask = require '../tasks/destroy-draft' @@ -510,10 +511,15 @@ class DraftStore # committed to the Database since we'll look them up again just # before send. session.changes.commit(force: true, noSyncback: true).then => + draft = session.draft() # We unfortunately can't give the SendDraftTask the raw draft JSON # data because there may still be pending tasks (like a # {FileUploadTask}) that will continue to update the draft data. - task = new SendDraftTask(draftClientId, {fromPopout: @_isPopout()}) + opts = + threadId: draft.threadId + replyToMessageId: draft.replyToMessageId + + task = new SendDraftTask(draftClientId, opts) Actions.queueTask(task) # NOTE: We may be done with the session in this window, but there @@ -541,17 +547,23 @@ class DraftStore files = _.reject files, (f) -> f.id is file.id session.changes.add({files}, immediate: true) - _onDraftSendingFailed: ({draftClientId, errorMessage}) -> + _onDraftSendingFailed: ({draftClientId, threadId, errorMessage}) -> @_draftsSending[draftClientId] = false @trigger(draftClientId) if NylasEnv.isMainWindow() # We delay so the view has time to update the restored draft. If we # don't delay the modal may come up in a state where the draft looks # like it hasn't been restored or has been lost. - _.delay -> - NylasEnv.showErrorDialog(errorMessage) + _.delay => + @_notifyUserOfError({draftClientId, threadId, errorMessage}) , 100 + _notifyUserOfError: ({draftClientId, threadId, errorMessage}) -> + focusedThread = FocusedContentStore.focused('thread') + if threadId and focusedThread?.id is threadId + NylasEnv.showErrorDialog(errorMessage) + else + Actions.composePopoutDraft(draftClientId, {errorMessage}) # Deprecations store = new DraftStore() diff --git a/src/flux/tasks/send-draft.coffee b/src/flux/tasks/send-draft.coffee index b4ed42f76..bc264b664 100644 --- a/src/flux/tasks/send-draft.coffee +++ b/src/flux/tasks/send-draft.coffee @@ -14,7 +14,7 @@ class NotFoundError extends Error module.exports = class SendDraftTask extends Task - constructor: (@draftClientId, {@fromPopout}={}) -> + constructor: (@draftClientId, {@threadId, @replyToMessageId}={}) -> super label: -> @@ -161,7 +161,8 @@ class SendDraftTask extends Task return Promise.resolve([Task.Status.Failed, err]) _notifyUserOfError: (msg) => - if @fromPopout - Actions.composePopoutDraft(@draftClientId, {errorMessage: msg}) - else - Actions.draftSendingFailed({draftClientId: @draftClientId, errorMessage: msg}) + Actions.draftSendingFailed({ + threadId: @threadId + draftClientId: @draftClientId, + errorMessage: msg + }) diff --git a/static/components/button-dropdown.less b/static/components/button-dropdown.less index 0815dff1a..459d39268 100644 --- a/static/components/button-dropdown.less +++ b/static/components/button-dropdown.less @@ -25,6 +25,44 @@ } } + &.dropdown-btn-emphasis { + .primary-item, + .secondary-picker, + .only-item { + .btn.btn-emphasis; + } + } + + &.dropdown-btn-text { + .primary-item, + .secondary-picker, + .only-item { + .btn.btn-text; + height: 28px; + line-height: 1; + } + .primary-item, .only-item { + padding-top: 5px; + } + .secondary-picker { + padding: 4px 8px 2px 8px; + } + .secondary-items { + .menu { + .item { + font-size: 13px; + padding: 6px 11px; + &:first-child { + padding-top: 6px; + } + &:last-child { + padding-bottom: 6px; + } + } + } + } + } + .primary-item, .only-item { padding: 0.33em 1em; @@ -130,5 +168,33 @@ body.platform-win32 { .secondary-items { border-radius: 0; } + &.dropdown-btn-emphasis { + .primary-item, + .secondary-picker, + .only-item { + border: 0; + background: @btn-emphasis-bg-color; + &:hover { + border-radius: 0; + background: darken(@btn-emphasis-bg-color, 10%); + } + &:active { + background: @btn-emphasis-bg-color; + box-shadow: 0 0 0; + } + } + } + &.dropdown-btn-text { + .primary-item, + .secondary-picker, + .only-item { + .btn.btn-text; + padding-top: 5px; + height: 28px; + } + .secondary-picker { + padding: 4px 8px 2px 8px; + } + } } }