feat(send): Send and Archive

Summary: Send and Archive plus a new setting.

Test Plan: new tests

Reviewers: bengotow, juan

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2446
This commit is contained in:
Evan Morikawa 2016-01-25 14:14:09 -08:00
parent ac34f4410b
commit ecbadaf01e
19 changed files with 647 additions and 75 deletions

View file

@ -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
<div style={order: 0, flex: 1} />
<button className="btn btn-toolbar btn-emphasis btn-text btn-send" style={order: -100}
ref="sendButton"
onClick={@_sendDraft}><RetinaImg name="icon-composer-send.png" mode={RetinaImg.Mode.ContentIsMask} /><span className="text">Send</span></button>
<SendActionButton draft={@_proxy?.draft()}
ref="sendActionButton"
isValidDraft={@_isValidDraft} />
</InjectedComponentSet>
@ -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())

View file

@ -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
<button className={classes}
style={order: -100}
onClick={@_onPrimaryClick}>{@_sendContent(iconUrl)}</button>
_renderSendDropdown: ->
actionConfigs = @_orderedActionConfigs()
<ButtonDropdown
className={"btn-send dropdown-btn-emphasis dropdown-btn-text"}
style={order: -100}
primaryItem={@_sendContent(actionConfigs[0].iconUrl)}
primaryTitle={actionConfigs[0].title}
primaryClick={@_onPrimaryClick}
closeOnMenuClick={true}
menu={@_dropdownMenu(actionConfigs[1..-1])}/>
_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) ->
<Menu items={actionConfigs}
itemKey={ (actionConfig) -> 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 = <span>&nbsp;+&nbsp;</span>
additionalImg = <RetinaImg url={iconUrl}
mode={RetinaImg.Mode.ContentIsMask} />
else
plusHTML = ""
additionalImg = ""
<span>
<RetinaImg name={sendIcon} mode={RetinaImg.Mode.ContentIsMask} />
<span className="text">Send{plusHTML}</span>{additionalImg}
</span>
module.exports = SendActionButton

View file

@ -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: "<br><br><blockquote class='gmail_quote'>This is my quoted text!</blockquote>"
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

View file

@ -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 draftClientId="unused"/>)
@composer._proxy = trigger: ->
@composer._proxy =
trigger: ->
draft: => @draft
spyOn(@composer, "_addToProxy")
spyOn(@composer, "_setupSession")

View file

@ -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: -> <div className="btn-good"></div>
onSend: ->
class SecondExtension extends ComposerExtension
@sendActionConfig: ({draft}) ->
title: "Second Extension"
content: -> <div className="btn-second"></div>
onSend: ->
class NullExtension extends ComposerExtension
@sendActionConfig: ({draft}) -> null
isValidDraft = null
describe "SendActionButton", ->
render = (draft, valid=true) ->
isValidDraft = jasmine.createSpy("isValidDraft").andReturn(valid)
ReactTestUtils.renderIntoDocument(
<SendActionButton draft={draft} isValidDraft={isValidDraft} />
)
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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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

View file

@ -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

View file

@ -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": {
}
}

View file

@ -0,0 +1 @@
describe "SendAndArchive", ->

View file

@ -0,0 +1,2 @@
.send-and-archive {
}

View file

@ -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 ->

View file

@ -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", ->

View file

@ -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
<div ref="button" onBlur={@_onBlur} tabIndex={999} className={classnames}>
<div ref="button" onBlur={@_onBlur} tabIndex={999} className={classnames} style={@props.style}>
<div className="primary-item"
title={@props.primaryTitle ? ""}
onClick={@props.primaryClick}>
@ -28,19 +33,19 @@ class ButtonDropdown extends React.Component
<div className="secondary-picker" onClick={@toggleDropdown}>
<RetinaImg name={"icon-thread-disclosure.png"} mode={RetinaImg.Mode.ContentIsMask}/>
</div>
<div className="secondary-items">
<div className="secondary-items" onMouseDown={@_onMenuClick}>
{@props.menu}
</div>
</div>
else
<div ref="button" onBlur={@_onBlur} tabIndex={999} className={classnames}>
<div ref="button" onBlur={@_onBlur} tabIndex={999} className={classnames} style={@props.style}>
<div className="only-item"
title={@props.primaryTitle ? ""}
onClick={@toggleDropdown}>
{@props.primaryItem}
<RetinaImg name={"icon-thread-disclosure.png"} style={marginLeft:12} mode={RetinaImg.Mode.ContentIsMask}/>
</div>
<div className="secondary-items left">
<div className="secondary-items left" onMouseDown={@_onMenuClick}>
{@props.menu}
</div>
</div>
@ -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)

View file

@ -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:

View file

@ -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,

View file

@ -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()

View file

@ -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
})

View file

@ -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;
}
}
}
}