diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..4fcc0ecc2 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,25 @@ +Thanks for taking the time to file an issue! If you have general question or a problem with your email account, take a quick look at the N1 Knowledge Base to see if you question is addressed there: + +https://support.nylas.com/hc/en-us/sections/203638587-N1_ + +Our team tries to respond to all GitHub issues. To make sure your issue is +actionable, try to include the following information: + +- [ ] Are there any related issues? Try searching for both open and closed issues here: https://github.com/nylas/N1/issues?q=is%3Aissue. Keep in mind that email features are often described differently on different platforms. (Conversations == threads, shortcuts == hotkeys, etc.) + +- [ ] What operating system are you using? + +- [ ] What version of N1 are you using? + + +**Bug?** +- [ ] Do you have any third-party plugins installed? + +- [ ] Is the issue related to a specific email provider (Gmail, Exchange, etc.)? + +- [ ] Is the issue reproducible with a particular attachment, message, signature, etc? + Try to provide an example as a file attachment or a screenshot. + + +**Feature Request?** +- [ ] Does this feature exist in another mail client or tool you use? diff --git a/README.md b/README.md index c68735bc1..30aace113 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Build Status](https://travis-ci.org/nylas/N1.svg?branch=master)](https://travis-ci.org/nylas/N1) [![Slack Invite Button](http://slack-invite.nylas.com/badge.svg)](http://slack-invite.nylas.com) +[![GitHub issues On Deck](https://badge.waffle.io/nylas/N1.png?label=on deck&title=On Deck)](https://waffle.io/nylas/N1) # Download N1 @@ -33,6 +34,7 @@ We're working on building a plugin index that makes it super easy to add them to - [Solarized Dark](https://github.com/NSHenry/N1-Solarized-Dark) - [Berend](https://github.com/Frique/N1-Berend) - [LevelUp](https://github.com/stolinski/level-up-nylas-n1-theme) +- [Darkside](http://jamiewilson.io/darkside/) ##### Composer - [Translate](https://github.com/nylas/N1/tree/master/internal_packages/composer-translate) -- Works with 10 languages diff --git a/build/config/eslint.json b/build/config/eslint.json index 905a80e23..672f41f80 100644 --- a/build/config/eslint.json +++ b/build/config/eslint.json @@ -11,6 +11,7 @@ }, "rules": { "react/prop-types": [2, {"ignore": ["children"]}], + "react/no-multi-comp": [1], "eqeqeq": [2, "smart"], "id-length": [0], "object-curly-spacing": [0], diff --git a/build/resources/linux/icons/128.png b/build/resources/linux/icons/128.png index 1c99b6a74..f0ee977ec 100644 Binary files a/build/resources/linux/icons/128.png and b/build/resources/linux/icons/128.png differ diff --git a/build/resources/linux/icons/16.png b/build/resources/linux/icons/16.png index db463bb6e..e83f6e5cd 100644 Binary files a/build/resources/linux/icons/16.png and b/build/resources/linux/icons/16.png differ diff --git a/build/resources/linux/icons/256.png b/build/resources/linux/icons/256.png index 52d174d3e..f15248bba 100644 Binary files a/build/resources/linux/icons/256.png and b/build/resources/linux/icons/256.png differ diff --git a/build/resources/linux/icons/32.png b/build/resources/linux/icons/32.png index 60562ccfc..25f2d1f72 100644 Binary files a/build/resources/linux/icons/32.png and b/build/resources/linux/icons/32.png differ diff --git a/build/resources/linux/icons/512.png b/build/resources/linux/icons/512.png index 4c2de38b4..db62e3037 100644 Binary files a/build/resources/linux/icons/512.png and b/build/resources/linux/icons/512.png differ diff --git a/build/resources/linux/icons/64.png b/build/resources/linux/icons/64.png index 110f3674a..d1c4b3406 100644 Binary files a/build/resources/linux/icons/64.png and b/build/resources/linux/icons/64.png differ diff --git a/build/resources/mac/nylas.icns b/build/resources/mac/nylas.icns index 2bdcc16f2..86398f897 100644 Binary files a/build/resources/mac/nylas.icns and b/build/resources/mac/nylas.icns differ diff --git a/build/resources/nylas b/build/resources/nylas index 8c4a86eb7..d5cfa7794 160000 --- a/build/resources/nylas +++ b/build/resources/nylas @@ -1 +1 @@ -Subproject commit 8c4a86eb7ee1a06249a9ae35397e2084a09ad1dc +Subproject commit d5cfa779439aa7b67e7f805af63ae967ddf7628e diff --git a/build/resources/nylas.png b/build/resources/nylas.png index 4c2de38b4..a69dbaaff 100644 Binary files a/build/resources/nylas.png and b/build/resources/nylas.png differ diff --git a/build/resources/win/nylas.ico b/build/resources/win/nylas.ico index 1481b9b14..fb1e6f61b 100644 Binary files a/build/resources/win/nylas.ico and b/build/resources/win/nylas.ico differ diff --git a/build/tasks/generate-asar-task.coffee b/build/tasks/generate-asar-task.coffee index e5279cbb8..9e8dd23a7 100644 --- a/build/tasks/generate-asar-task.coffee +++ b/build/tasks/generate-asar-task.coffee @@ -14,6 +14,7 @@ module.exports = (grunt) -> '**/examples/**' '**/src/tasks/**' '**/node_modules/spellchecker/**' + '**/node_modules/windows-shortcuts/**' ] unpack = "{#{unpack.join(',')}}" diff --git a/internal_packages/category-picker/lib/category-picker.cjsx b/internal_packages/category-picker/lib/category-picker.cjsx index 9b6b8aca4..ef20cac84 100644 --- a/internal_packages/category-picker/lib/category-picker.cjsx +++ b/internal_packages/category-picker/lib/category-picker.cjsx @@ -33,7 +33,7 @@ class CategoryPicker extends React.Component @_categories = [] @_standardCategories = [] @_userCategories = [] - @state = _.extend @_recalculateState(@props), searchValue: "" + @state = _.extend(@_recalculateState(@props), searchValue: "") @contextTypes: sheetDepth: React.PropTypes.number diff --git a/internal_packages/category-picker/stylesheets/category-picker.less b/internal_packages/category-picker/stylesheets/category-picker.less index d92749ceb..b234e572c 100644 --- a/internal_packages/category-picker/stylesheets/category-picker.less +++ b/internal_packages/category-picker/stylesheets/category-picker.less @@ -1,5 +1,6 @@ @import "ui-variables"; +@popover-width: 250px; body.platform-win32 { .category-picker { @@ -14,6 +15,8 @@ body.platform-win32 { .menu { background: @background-secondary; + width: @popover-width; + max-height: 400px; .header-container { border-bottom: 0; diff --git a/internal_packages/composer-emojis/assets/icon.png b/internal_packages/composer-emojis/assets/icon.png index be3a5887c..d8c359fe8 100644 Binary files a/internal_packages/composer-emojis/assets/icon.png and b/internal_packages/composer-emojis/assets/icon.png differ diff --git a/internal_packages/composer-emojis/lib/emoji-picker.jsx b/internal_packages/composer-emojis/lib/emoji-picker.jsx index 48212f8b2..13c1ded12 100644 --- a/internal_packages/composer-emojis/lib/emoji-picker.jsx +++ b/internal_packages/composer-emojis/lib/emoji-picker.jsx @@ -3,7 +3,7 @@ import EmojiActions from './emoji-actions' const emoji = require('node-emoji'); class EmojiPicker extends React.Component { - static displayName = "EmojiPicker" + static displayName = "EmojiPicker"; static propTypes = { emojiOptions: React.PropTypes.array, selectedEmoji: React.PropTypes.string, @@ -33,8 +33,8 @@ class EmojiPicker extends React.Component { this.props.emojiOptions.forEach((emojiOption, i) => { const emojiChar = emoji.get(emojiOption); const emojiClass = emojiIndex === i ? "btn btn-icon emoji-option" : "btn btn-icon"; - emojis.push(); - emojis.push(
); + emojis.push(); + emojis.push(
); }) } return ( diff --git a/internal_packages/composer-emojis/lib/emojis-composer-extension.jsx b/internal_packages/composer-emojis/lib/emojis-composer-extension.jsx index 7875df990..3cb60b924 100644 --- a/internal_packages/composer-emojis/lib/emojis-composer-extension.jsx +++ b/internal_packages/composer-emojis/lib/emojis-composer-extension.jsx @@ -40,7 +40,7 @@ class EmojisComposerExtension extends ContenteditableExtension { sel.focusOffset + triggerWord.length); } } - } + }; static toolbarComponentConfig = ({toolbarState}) => { const sel = toolbarState.selectionSnapshot; @@ -57,18 +57,19 @@ class EmojisComposerExtension extends ContenteditableExtension { selectedEmoji}, locationRefNode: locationRefNode, width: EmojisComposerExtension._emojiPickerWidth(emojiOptions), + height: EmojisComposerExtension._emojiPickerHeight(emojiOptions), } } } return null; - } + }; static editingActions = () => { return [{ action: EmojiActions.selectEmoji, callback: EmojisComposerExtension._onSelectEmoji, }] - } + }; static onKeyDown = ({editor, event}) => { const sel = editor.currentSelection() @@ -103,7 +104,7 @@ class EmojisComposerExtension extends ContenteditableExtension { actionArg: {emojiChar: emoji.get(selectedEmoji)}}); } } - } + }; static _findEmojiOptions(sel) { if (sel.anchorNode && @@ -160,7 +161,7 @@ class EmojisComposerExtension extends ContenteditableExtension { } editor.insertText(emojiChar); } - } + }; static _emojiPickerWidth(emojiOptions) { let maxLength = 0; @@ -173,6 +174,14 @@ class EmojisComposerExtension extends ContenteditableExtension { return (maxLength + 10) * WIDTH_PER_CHAR; } + static _emojiPickerHeight(emojiOptions) { + const HEIGHT_PER_EMOJI = 28; + if (emojiOptions.length < 5) { + return emojiOptions.length * HEIGHT_PER_EMOJI + 20; + } + return 5 * HEIGHT_PER_EMOJI + 20; + } + static _getTextUntilSpace(node, offset) { let text = node.nodeValue.substring(0, offset); let prevTextNode = DOMUtils.previousTextNode(node); diff --git a/internal_packages/composer-emojis/package.json b/internal_packages/composer-emojis/package.json index 32ba060a8..5aa39a518 100644 --- a/internal_packages/composer-emojis/package.json +++ b/internal_packages/composer-emojis/package.json @@ -23,5 +23,5 @@ "default": true, "composer": true }, - "license": "MIT" + "license": "GPL-3.0" } diff --git a/internal_packages/composer-templates/icon.png b/internal_packages/composer-templates/icon.png index e9d436c7d..0166d9626 100644 Binary files a/internal_packages/composer-templates/icon.png and b/internal_packages/composer-templates/icon.png differ diff --git a/internal_packages/composer-templates/stylesheets/message-templates.less b/internal_packages/composer-templates/stylesheets/message-templates.less index d76ee8a4e..b40c7fc37 100755 --- a/internal_packages/composer-templates/stylesheets/message-templates.less +++ b/internal_packages/composer-templates/stylesheets/message-templates.less @@ -7,6 +7,7 @@ .menu { .content-container { height:150px; + width: 210px; overflow-y:scroll; } .footer-container { @@ -85,7 +86,7 @@ margin: 0 0 0 8px; } } - + .section-body { padding: 10px 0 0 0; diff --git a/internal_packages/composer-translate/icon.png b/internal_packages/composer-translate/icon.png index 3c7cc296c..a2a9d25f0 100644 Binary files a/internal_packages/composer-translate/icon.png and b/internal_packages/composer-translate/icon.png differ diff --git a/internal_packages/composer-translate/stylesheets/translate.less b/internal_packages/composer-translate/stylesheets/translate.less index a57e8bc06..fa8e3e76c 100644 --- a/internal_packages/composer-translate/stylesheets/translate.less +++ b/internal_packages/composer-translate/stylesheets/translate.less @@ -8,6 +8,7 @@ } .content-container { height:185px; + width:170px; overflow:scroll; } } diff --git a/internal_packages/composer/lib/composer-editor.jsx b/internal_packages/composer/lib/composer-editor.jsx index 56177adf8..42ffaacba 100644 --- a/internal_packages/composer/lib/composer-editor.jsx +++ b/internal_packages/composer/lib/composer-editor.jsx @@ -272,7 +272,7 @@ class ComposerEditor extends Component { value={this.props.body} onChange={this.props.onBodyChanged} onFilePaste={this.props.onFilePaste} - onSelectionChanged={this._ensureSelectionVisible} + onSelectionRestored={this._ensureSelectionVisible} initialSelectionSnapshot={this.props.initialSelectionSnapshot} extensions={[this._coreExtension].concat(this.state.extensions)} /> ); diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 8180571ce..b8c3800d6 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -58,7 +58,7 @@ class ComposerView extends React.Component constructor: (@props) -> @state = - populated: false + draftReady: false to: [] cc: [] bcc: [] @@ -223,6 +223,7 @@ class ComposerView extends React.Component ref="expandedParticipants" mode={@props.mode} accounts={@state.accounts} + draftReady={@state.draftReady} focusedField={@state.focusedField} enabledFields={@state.enabledFields} onPopoutComposer={@_onPopoutComposer} @@ -247,6 +248,7 @@ class ComposerView extends React.Component _onPopoutComposer: => + return unless @state.draftReady Actions.composePopoutDraft @props.draftClientId _onKeyDown: (event) => @@ -505,9 +507,9 @@ class ComposerView extends React.Component subject: draft.subject accounts: @_getAccountsForSend() - if !@state.populated + if !@state.draftReady _.extend state, - populated: true + draftReady: true focusedField: @_initiallyFocusedField(draft) enabledFields: @_initiallyEnabledFields(draft) showQuotedText: @isForwardedMessage() @@ -551,7 +553,7 @@ class ComposerView extends React.Component return enabledFields _getAccountsForSend: => - if @_proxy.draft()?.replyToMessageId + if @_proxy.draft()?.threadId [AccountStore.accountForId(@_proxy.draft().accountId)] else AccountStore.accounts() @@ -645,7 +647,7 @@ class ComposerView extends React.Component return _addToProxy: (changes={}, source={}) => - return unless @_proxy + return unless @_proxy and @_proxy.draft() selections = @_getSelections() @@ -684,7 +686,7 @@ class ComposerView extends React.Component if dealbreaker dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', - buttons: ['Edit Message'], + buttons: ['Edit Message', 'Cancel'], message: 'Cannot Send', detail: dealbreaker }) diff --git a/internal_packages/composer/lib/expanded-participants.cjsx b/internal_packages/composer/lib/expanded-participants.cjsx index da765e040..0f4c9462f 100644 --- a/internal_packages/composer/lib/expanded-participants.cjsx +++ b/internal_packages/composer/lib/expanded-participants.cjsx @@ -17,6 +17,21 @@ class ExpandedParticipants extends React.Component bcc: React.PropTypes.array from: React.PropTypes.array + # We need to know if the draft is ready so we can enable and disable + # ParticipantTextFields. + # + # It's possible for a ParticipantTextField, before the draft is + # ready, to start the request to `add`, `remove`, or `edit`. This + # happens when there are multiple drafts rendering, each requesting + # focus. A blur event gets fired before the draft is loaded, causing + # logic to run that sets an empty field. These requests are + # asynchronous. They may resolve after the draft is in fact ready. + # This is bad because the desire to `remove` participants may have + # been made with an empty, non-loaded draft, but executed on the new + # draft that was loaded in the time it took the async request to + # return. + draftReady: React.PropTypes.bool + # The account to which the current draft belongs accounts: React.PropTypes.array @@ -47,6 +62,7 @@ class ExpandedParticipants extends React.Component bcc: [] from: [] accounts: [] + draftReady: false enabledFields: [] constructor: (@props={}) -> @@ -109,6 +125,7 @@ class ExpandedParticipants extends React.Component field='to' change={@props.onChangeParticipants} className="composer-participant-field to-field" + draftReady={@props.draftReady} onFocus={ => @props.onChangeFocusedField(Fields.To) } participants={to: @props['to'], cc: @props['cc'], bcc: @props['bcc']} /> @@ -120,6 +137,7 @@ class ExpandedParticipants extends React.Component ref={Fields.Cc} key="cc" field='cc' + draftReady={@props.draftReady} change={@props.onChangeParticipants} onEmptied={ => @props.onAdjustEnabledFields(hide: [Fields.Cc]) } onFocus={ => @props.onChangeFocusedField(Fields.Cc) } @@ -133,6 +151,7 @@ class ExpandedParticipants extends React.Component ref={Fields.Bcc} key="bcc" field='bcc' + draftReady={@props.draftReady} change={@props.onChangeParticipants} onEmptied={ => @props.onAdjustEnabledFields(hide: [Fields.Bcc]) } onFocus={ => @props.onChangeFocusedField(Fields.Bcc) } diff --git a/internal_packages/composer/lib/participants-text-field.cjsx b/internal_packages/composer/lib/participants-text-field.cjsx index 7c699c6de..b0f9d00d4 100644 --- a/internal_packages/composer/lib/participants-text-field.cjsx +++ b/internal_packages/composer/lib/participants-text-field.cjsx @@ -31,8 +31,24 @@ class ParticipantsTextField extends React.Component onFocus: React.PropTypes.func + # We need to know if the draft is ready so we can enable and disable + # ParticipantTextFields. + # + # It's possible for a ParticipantTextField, before the draft is + # ready, to start the request to `add`, `remove`, or `edit`. This + # happens when there are multiple drafts rendering, each requesting + # focus. A blur event gets fired before the draft is loaded, causing + # logic to run that sets an empty field. These requests are + # asynchronous. They may resolve after the draft is in fact ready. + # This is bad because the desire to `remove` participants may have + # been made with an empty, non-loaded draft, but executed on the new + # draft that was loaded in the time it took the async request to + # return. + draftReady: React.PropTypes.bool + @defaultProps: visible: true + draftReady: false shouldComponentUpdate: (nextProps, nextState) => not Utils.isEqualReact(nextProps, @props) or @@ -95,6 +111,7 @@ class ParticipantsTextField extends React.Component return [new Contact(email: string, name: null)] _remove: (values) => + return unless @props.draftReady field = @props.field updates = {} updates[field] = _.reject @props.participants[field], (p) -> @@ -104,6 +121,7 @@ class ParticipantsTextField extends React.Component @props.change(updates) _edit: (token, replacementString) => + return unless @props.draftReady field = @props.field tokenIndex = @props.participants[field].indexOf(token) @_tokensForString(replacementString).then (replacements) => @@ -113,6 +131,14 @@ class ParticipantsTextField extends React.Component @props.change(updates) _add: (values, options={}) => + # It's important we return here (as opposed to ignoring the + # `@props.change` callback) because this method is asynchronous. + # + # The `tokensPromise` may be formed with an empty draft, but resolved + # after a draft was prepared. This would cause the bad data to be + # propagated. + return unless @props.draftReady + # If the input is a string, parse out email addresses and build # an array of contact objects. For each email address wrapped in # parentheses, look for a preceding name, if one exists. diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index 7a15727fd..1ffb2cef1 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -144,7 +144,7 @@ describe "ComposerView", -> ) - describe "populated composer", -> + describe "draftReady composer", -> beforeEach -> @isSending = false spyOn(DraftStore, "isSendingDraft").andCallFake => @isSending @@ -242,7 +242,7 @@ describe "ComposerView", -> expect(@composer.state.body).toEqual "Hello World
This is a test" it "sets first-time initial state about focused fields", -> - expect(@composer.state.populated).toBe true + expect(@composer.state.draftReady).toBe true expect(@composer.state.focusedField).toBeDefined() expect(@composer.state.enabledFields).toBeDefined() @@ -425,7 +425,7 @@ describe "ComposerView", -> 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.") - expect(dialogArgs.buttons).toEqual ['Edit Message'] + expect(dialogArgs.buttons).toEqual ['Edit Message', 'Cancel'] it "shows an error if a recipient is invalid", -> useDraft.call @, @@ -437,7 +437,7 @@ describe "ComposerView", -> 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.") - expect(dialogArgs.buttons).toEqual ['Edit Message'] + expect(dialogArgs.buttons).toEqual ['Edit Message', 'Cancel'] describe "empty body warning", -> it "warns if the body of the email is still the pristine body", -> @@ -751,4 +751,3 @@ describe "ComposerView", -> Actions.sendDraft(DRAFT_CLIENT_ID) secondStatus = @composer._isValidDraft() expect(secondStatus).toBe false - diff --git a/internal_packages/composer/spec/participants-text-field-spec.cjsx b/internal_packages/composer/spec/participants-text-field-spec.cjsx index b71d4a01e..52d14c8ea 100644 --- a/internal_packages/composer/spec/participants-text-field-spec.cjsx +++ b/internal_packages/composer/spec/participants-text-field-spec.cjsx @@ -50,6 +50,7 @@ describe 'ParticipantsTextField', -> field={@fieldName} tabIndex={@tabIndex} visible={true} + draftReady={true} participants={@participants} change={@propChange} /> ) diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less index 450558ce4..33db49d10 100644 --- a/internal_packages/composer/stylesheets/composer.less +++ b/internal_packages/composer/stylesheets/composer.less @@ -14,7 +14,7 @@ body.platform-win32 { border-radius: 0; } input, input:focus { - border: 0; + box-shadow: none; } } } @@ -154,6 +154,7 @@ body.platform-win32 { color: @text-color; padding-right: 12px; margin-left: 0; + margin-top: 2px; } .num-remaining-wrap { @@ -164,10 +165,10 @@ body.platform-win32 { .show-more-fade { position: absolute; width: 190px; - height: 37px; + height: 32px; right: 0; top: 0; - background: linear-gradient(to right, fade(@blurred-bg-color, 0%) 0%, fade(@blurred-bg-color, 100%) 40%); + background: linear-gradient(to right, fade(@background-primary, 0%) 0%, fade(@background-primary, 100%) 40%); } } } @@ -281,6 +282,9 @@ body.platform-win32 { .message-item-wrap { .message-item-white-wrap.composer-outer-wrap { background: @blurred-bg-color; + .show-more-fade { + background: linear-gradient(to right, fade(@blurred-bg-color, 0%) 0%, fade(@blurred-bg-color, 100%) 40%); + } } .message-item-white-wrap.composer-outer-wrap.focused { box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.28), 0 1px 1.5px rgba(0, 0, 0, 0.08), 0 0 3px @accent-primary; @@ -312,11 +316,11 @@ body.platform-win32 { &:hover { .primary-item, .only-item { - border-radius: 4px; + border-radius: @border-radius-base; } } .secondary-items { - border-radius: 4px; + border-radius: @border-radius-base; } .item { .contact.is-alias { diff --git a/internal_packages/github-contact-card/icon.png b/internal_packages/github-contact-card/icon.png index 0c32bda74..2548f130c 100644 Binary files a/internal_packages/github-contact-card/icon.png and b/internal_packages/github-contact-card/icon.png differ diff --git a/internal_packages/github-contact-card/package.json b/internal_packages/github-contact-card/package.json index f75ecc2c1..2b7ace5a3 100644 --- a/internal_packages/github-contact-card/package.json +++ b/internal_packages/github-contact-card/package.json @@ -10,7 +10,7 @@ "description": "Extends the contact card in the sidebar to show public repos of the people you email.", "icon": "./icon.png", - "license": "MIT", + "license": "GPL-3.0", "engines": { "nylas": ">=0.3.0 <0.5.0" }, diff --git a/internal_packages/link-tracking/README.md b/internal_packages/link-tracking/README.md new file mode 100644 index 000000000..97f2d061f --- /dev/null +++ b/internal_packages/link-tracking/README.md @@ -0,0 +1,4 @@ + +## Open Tracking + +Adds tracking pixels to messages and tracks whether they have been opened. diff --git a/internal_packages/link-tracking/assets/linktracking-icon@2x.png b/internal_packages/link-tracking/assets/linktracking-icon@2x.png new file mode 100644 index 000000000..f03e0172e Binary files /dev/null and b/internal_packages/link-tracking/assets/linktracking-icon@2x.png differ diff --git a/internal_packages/link-tracking/icon.png b/internal_packages/link-tracking/icon.png new file mode 100644 index 000000000..94e058a45 Binary files /dev/null and b/internal_packages/link-tracking/icon.png differ diff --git a/internal_packages/link-tracking/lib/link-tracking-button.jsx b/internal_packages/link-tracking/lib/link-tracking-button.jsx new file mode 100644 index 000000000..282328338 --- /dev/null +++ b/internal_packages/link-tracking/lib/link-tracking-button.jsx @@ -0,0 +1,59 @@ +import {DraftStore, React, Actions, NylasAPI, DatabaseStore, Message, Rx} from 'nylas-exports' +import {RetinaImg} from 'nylas-component-kit' +import plugin from '../package.json' +const PLUGIN_ID = plugin.appId; + +export default class LinkTrackingButton extends React.Component { + static displayName = 'LinkTrackingButton'; + + static propTypes = { + draftClientId: React.PropTypes.string.isRequired, + }; + + constructor(props) { + super(props); + this.state = {enabled: false}; + } + + componentDidMount() { + const query = DatabaseStore.findBy(Message, {clientId: this.props.draftClientId}); + this._subscription = Rx.Observable.fromQuery(query).subscribe(this.setStateFromDraft) + } + + componentWillUnmount() { + this._subscription.dispose(); + } + + setStateFromDraft =(draft)=> { + if (!draft) return; + const metadata = draft.metadataForPluginId(PLUGIN_ID); + this.setState({enabled: metadata ? metadata.tracked : false}); + }; + + _onClick=()=> { + const currentlyEnabled = this.state.enabled; + + // write metadata into the draft to indicate tracked state + DraftStore.sessionForClientId(this.props.draftClientId) + .then(session => session.draft()) + .then(draft => { + return NylasAPI.authPlugin(PLUGIN_ID, plugin.title, draft.accountId).then(() => { + Actions.setMetadata(draft, PLUGIN_ID, currentlyEnabled ? null : {tracked: true}); + }); + }); + }; + + render() { + return ( + + ) + } +} + diff --git a/internal_packages/link-tracking/lib/link-tracking-composer-extension.es6 b/internal_packages/link-tracking/lib/link-tracking-composer-extension.es6 new file mode 100644 index 000000000..74adb57af --- /dev/null +++ b/internal_packages/link-tracking/lib/link-tracking-composer-extension.es6 @@ -0,0 +1,46 @@ +import {ComposerExtension, Actions, QuotedHTMLTransformer} from 'nylas-exports'; +import plugin from '../package.json' + +import uuid from 'node-uuid'; + +const LINK_REGEX = (/(]*>)|(]*>)/g); +const PLUGIN_ID = plugin.appId; +const PLUGIN_URL = "n1-link-tracking.herokuapp.com"; + +class DraftBody { + constructor(draft) {this._body = draft.body} + get unquoted() {return QuotedHTMLTransformer.removeQuotedHTML(this._body);} + set unquoted(text) {this._body = QuotedHTMLTransformer.appendQuotedHTML(text, this._body);} + get body() {return this._body} +} + +export default class LinkTrackingComposerExtension extends ComposerExtension { + static finalizeSessionBeforeSending({session}) { + const draft = session.draft(); + + // grab message metadata, if any + const metadata = draft.metadataForPluginId(PLUGIN_ID); + if (metadata) { + const draftBody = new DraftBody(draft); + const links = []; + const messageUid = uuid.v4().replace(/-/g, ""); + + // loop through all elements, replace with redirect links and save mappings + draftBody.unquoted = draftBody.unquoted.replace(LINK_REGEX, (match, prefix, url, suffix) => { + const encoded = encodeURIComponent(url); + const redirectUrl = `http://${PLUGIN_URL}/${draft.accountId}/${messageUid}/${links.length}?redirect=${encoded}`; + links.push({url: url, click_count: 0, click_data: []}); + return prefix + redirectUrl + suffix; + }); + + // save the draft + session.changes.add({body: draftBody.body}); + session.changes.commit(); + + // save the link info to draft metadata + metadata.uid = messageUid; + metadata.links = links; + Actions.setMetadata(draft, PLUGIN_ID, metadata); + } + } +} diff --git a/internal_packages/link-tracking/lib/link-tracking-icon.jsx b/internal_packages/link-tracking/lib/link-tracking-icon.jsx new file mode 100644 index 000000000..962fda3cb --- /dev/null +++ b/internal_packages/link-tracking/lib/link-tracking-icon.jsx @@ -0,0 +1,58 @@ +import {React} from 'nylas-exports' +import {RetinaImg} from 'nylas-component-kit' +import plugin from '../package.json' + +const sum = (array, extractFn) => array.reduce( (a, b) => a + extractFn(b), 0 ); + +export default class LinkTrackingIcon extends React.Component { + + static displayName = 'LinkTrackingIcon'; + + static propTypes = { + thread: React.PropTypes.object.isRequired, + }; + + constructor(props) { + super(props); + this.state = this._getStateFromThread(props.thread); + } + + componentWillReceiveProps(newProps) { + this.setState(this._getStateFromThread(newProps.thread)); + } + + _getStateFromThread(thread) { + const messages = thread.metadata; + // Pull a list of metadata for all messages + const metadataObjs = messages.map(msg => msg.metadataForPluginId(plugin.appId)).filter(meta => meta); + if (metadataObjs.length) { + // If there's metadata, return the total number of link clicks in the most recent metadata + const mostRecentMetadata = metadataObjs.pop(); + return { + clicks: sum(mostRecentMetadata.links || [], link => link.click_count || 0), + }; + } + return {clicks: null}; + } + + + _renderIcon = () => { + return this.state.clicks == null ? "" : this._getIcon(this.state.clicks); + }; + + _getIcon(clicks) { + return ( + 0 ? "clicked" : ""} + url="nylas://link-tracking/assets/linktracking-icon@2x.png" + mode={RetinaImg.Mode.ContentIsMask} /> + {clicks > 0 ? clicks : ""} + ) + } + + render() { + return (
+ {this._renderIcon()} +
) + } +} diff --git a/internal_packages/link-tracking/lib/link-tracking-panel.jsx b/internal_packages/link-tracking/lib/link-tracking-panel.jsx new file mode 100644 index 000000000..26861b54b --- /dev/null +++ b/internal_packages/link-tracking/lib/link-tracking-panel.jsx @@ -0,0 +1,47 @@ +import {React} from 'nylas-exports' +import plugin from '../package.json' + +export default class LinkTrackingPanel extends React.Component { + static displayName = 'LinkTrackingPanel'; + + static propTypes = { + message: React.PropTypes.object.isRequired, + }; + + constructor(props) { + super(props); + this.state = this._getStateFromMessage(props.message) + } + + componentWillReceiveProps(newProps) { + this.setState(this._getStateFromMessage(newProps.message)); + } + + _getStateFromMessage(message) { + const metadata = message.metadataForPluginId(plugin.appId); + return metadata ? {links: metadata.links} : {}; + } + + _renderContents() { + return this.state.links.map(link => { + return ( + {link.url} + {link.click_count + " clicks"} + ) + }) + } + + render() { + if (this.state.links) { + return (
+

Link Tracking Enabled

+ + + {this._renderContents()} + +
+
); + } + return
; + } +} diff --git a/internal_packages/link-tracking/lib/main.es6 b/internal_packages/link-tracking/lib/main.es6 new file mode 100644 index 000000000..7dfc532e3 --- /dev/null +++ b/internal_packages/link-tracking/lib/main.es6 @@ -0,0 +1,61 @@ +import {ComponentRegistry, DatabaseStore, Message, ExtensionRegistry, Actions} from 'nylas-exports'; +import LinkTrackingButton from './link-tracking-button'; +import LinkTrackingIcon from './link-tracking-icon'; +import LinkTrackingComposerExtension from './link-tracking-composer-extension'; +import LinkTrackingPanel from './link-tracking-panel'; +import plugin from '../package.json' + +import request from 'request'; + +const post = Promise.promisify(request.post, {multiArgs: true}); +const PLUGIN_ID = plugin.appId; +const PLUGIN_URL = "n1-link-tracking.herokuapp.com"; + +function afterDraftSend({draftClientId}) { + // only run this handler in the main window + if (!NylasEnv.isMainWindow()) return; + + // query for the message + DatabaseStore.findBy(Message, {clientId: draftClientId}).then((message) => { + // grab message metadata, if any + const metadata = message.metadataForPluginId(PLUGIN_ID); + // get the uid from the metadata, if present + if (metadata) { + const uid = metadata.uid; + + // post the uid and message id pair to the plugin server + const data = {uid: uid, message_id: message.id}; + const serverUrl = `http://${PLUGIN_URL}/register-message`; + return post({ + url: serverUrl, + body: JSON.stringify(data), + }).then( ([response, responseBody]) => { + if (response.statusCode !== 200) { + throw new Error(); + } + return responseBody; + }).catch(error => { + NylasEnv.showErrorDialog("There was a problem contacting the Link Tracking server! This message will not have link tracking"); + Promise.reject(error); + }); + } + }); +} + +export function activate() { + ComponentRegistry.register(LinkTrackingButton, {role: 'Composer:ActionButton'}); + ComponentRegistry.register(LinkTrackingIcon, {role: 'ThreadListIcon'}); + ComponentRegistry.register(LinkTrackingPanel, {role: 'message:BodyHeader'}); + ExtensionRegistry.Composer.register(LinkTrackingComposerExtension); + this._unlistenSendDraftSuccess = Actions.sendDraftSuccess.listen(afterDraftSend); +} + +export function serialize() {} + +export function deactivate() { + ComponentRegistry.unregister(LinkTrackingButton); + ComponentRegistry.unregister(LinkTrackingIcon); + ComponentRegistry.unregister(LinkTrackingPanel); + ExtensionRegistry.Composer.unregister(LinkTrackingComposerExtension); + this._unlistenSendDraftSuccess() +} diff --git a/internal_packages/link-tracking/package.json b/internal_packages/link-tracking/package.json new file mode 100644 index 000000000..9e57dbda6 --- /dev/null +++ b/internal_packages/link-tracking/package.json @@ -0,0 +1,25 @@ +{ + "name": "link-tracking", + "main": "./lib/main", + "version": "0.1.0", + "appId":"ybqmsi39th16ka60dcxz1in9", + + "title": "Link Tracking", + "description": "Tracks whether links in an email have been clicked by recipients", + "icon": "./icon.png", + "isOptional": true, + + "repository": { + "type": "git", + "url": "" + }, + "engines": { + "nylas": ">=0.3.46" + }, + "windowTypes": { + "default": true, + "composer": true + }, + "dependencies": {}, + "license": "GPL-3.0" +} \ No newline at end of file diff --git a/internal_packages/link-tracking/stylesheets/main.less b/internal_packages/link-tracking/stylesheets/main.less new file mode 100644 index 000000000..7f737d023 --- /dev/null +++ b/internal_packages/link-tracking/stylesheets/main.less @@ -0,0 +1,47 @@ +@import "ui-variables"; +@import "ui-mixins"; + + +.link-tracking-icon img.content-mask { + background-color: #AAA; + vertical-align: text-bottom; +} +.link-tracking-icon img.content-mask.clicked { + background-color: #CCC; +} +.link-tracking-icon .link-click-count { + display: inline-block; + position: relative; + left: -16px; + text-align: center; + + color: #3187e1; + font-size: 12px; + font-weight: bold; +} +.link-tracking-icon { + width: 16px; + margin-right: 4px; +} + + +.link-tracking-panel { + background: #DDF6FF; + border: 1px solid #ACD; + padding: 5px; + border-radius: 5px; +} + +.link-tracking-panel h4{ + text-align: center; + margin-top: 0; +} +.link-tracking-panel table{ + width: 100%; +} +.link-tracking-panel td { + border-bottom: 1px solid #D5EAF5; + border-top: 1px solid #D5EAF5; + padding: 0 10px; + text-align: left; +} \ No newline at end of file diff --git a/internal_packages/message-list/lib/message-list.cjsx b/internal_packages/message-list/lib/message-list.cjsx index 7712e4ce7..1cd3e05c0 100755 --- a/internal_packages/message-list/lib/message-list.cjsx +++ b/internal_packages/message-list/lib/message-list.cjsx @@ -216,8 +216,10 @@ class MessageList extends React.Component
- {subject} - {@_renderLabels()} +
+ {subject} + {@_renderLabels()} +
{@_renderIcons()}
@@ -402,7 +404,7 @@ class MessageList extends React.Component _onChange: => newState = @_getStateFromStores() - if @state.currentThread isnt newState.currentThread + if @state.currentThread?.id isnt newState.currentThread?.id newState.minified = true @setState(newState) diff --git a/internal_packages/message-list/lib/message-participants.cjsx b/internal_packages/message-list/lib/message-participants.cjsx index 5c3ac44d5..cb3b21a55 100644 --- a/internal_packages/message-list/lib/message-participants.cjsx +++ b/internal_packages/message-list/lib/message-participants.cjsx @@ -29,19 +29,9 @@ class MessageParticipants extends React.Component _allToParticipants: => _.union(@props.to, @props.cc, @props.bcc) - _selectPlainText: (e) => + _selectText: (e) => textNode = e.currentTarget.childNodes[0] - @_selectText(textNode) - _selectCommaText: (e) => - textNode = e.currentTarget.childNodes[0].childNodes[0] - @_selectText(textNode) - - _selectBracketedText: (e) => - textNode = e.currentTarget.childNodes[1].childNodes[0] # because of React rendering - @_selectText(textNode) - - _selectText: (textNode) => range = document.createRange() range.setStart(textNode, 0) range.setEnd(textNode, textNode.length) @@ -67,12 +57,18 @@ class MessageParticipants extends React.Component if c.name?.length > 0 and c.name isnt c.email
- {c.name}  - <{c.email}>{comma}  +
+ {c.name} +
+
+ {"<"}{c.email}{">#{comma}"} +
else
- {c.email}{comma}  +
+ {c.email}{comma} +
) diff --git a/internal_packages/message-list/lib/thread-trash-button.cjsx b/internal_packages/message-list/lib/thread-trash-button.cjsx index ca575a9de..22bb56761 100644 --- a/internal_packages/message-list/lib/thread-trash-button.cjsx +++ b/internal_packages/message-list/lib/thread-trash-button.cjsx @@ -15,7 +15,7 @@ class ThreadTrashButton extends React.Component render: => focusedMailboxPerspective = FocusedPerspectiveStore.current() - return false unless focusedMailboxPerspective?.canTrashThreads() + return false unless focusedMailboxPerspective.canTrashThreads() ) + } + +} diff --git a/internal_packages/open-tracking/lib/open-tracking-composer-extension.es6 b/internal_packages/open-tracking/lib/open-tracking-composer-extension.es6 new file mode 100644 index 000000000..f77ff88fb --- /dev/null +++ b/internal_packages/open-tracking/lib/open-tracking-composer-extension.es6 @@ -0,0 +1,41 @@ +import {ComposerExtension, Actions, QuotedHTMLTransformer} from 'nylas-exports'; +import plugin from '../package.json' + +import uuid from 'node-uuid'; + +const PLUGIN_ID = plugin.appId; +const PLUGIN_URL = "n1-open-tracking.herokuapp.com"; + +class DraftBody { + constructor(draft) {this._body = draft.body} + get unquoted() {return QuotedHTMLTransformer.removeQuotedHTML(this._body);} + set unquoted(text) {this._body = QuotedHTMLTransformer.appendQuotedHTML(text, this._body);} + get body() {return this._body} +} + +export default class OpenTrackingComposerExtension extends ComposerExtension { + static finalizeSessionBeforeSending({session}) { + const draft = session.draft(); + + // grab message metadata, if any + const metadata = draft.metadataForPluginId(PLUGIN_ID); + if (metadata) { + // generate a UID + const uid = uuid.v4().replace(/-/g, ""); + + // insert a tracking pixel into the message + const serverUrl = `http://${PLUGIN_URL}/${draft.accountId}/${uid}`; + const img = ``; + const draftBody = new DraftBody(draft); + draftBody.unquoted = draftBody.unquoted + "
" + img; + + // save the draft + session.changes.add({body: draftBody.body}); + session.changes.commit(); + + // save the uid to draft metadata + metadata.uid = uid; + Actions.setMetadata(draft, PLUGIN_ID, metadata); + } + } +} diff --git a/internal_packages/open-tracking/lib/open-tracking-icon.jsx b/internal_packages/open-tracking/lib/open-tracking-icon.jsx new file mode 100644 index 000000000..972bdde4f --- /dev/null +++ b/internal_packages/open-tracking/lib/open-tracking-icon.jsx @@ -0,0 +1,52 @@ +import {React} from 'nylas-exports' +import {RetinaImg} from 'nylas-component-kit' +import plugin from '../package.json' + +export default class OpenTrackingIcon extends React.Component { + static displayName = 'OpenTrackingIcon'; + + static propTypes = { + thread: React.PropTypes.object.isRequired, + }; + + constructor(props) { + super(props); + this.state = this._getStateFromThread(props.thread) + } + + componentWillReceiveProps(newProps) { + this.setState(this._getStateFromThread(newProps.thread)); + } + + _getStateFromThread(thread) { + const messages = thread.metadata; + const metadataObjs = messages.map(msg => msg.metadataForPluginId(plugin.appId)).filter(meta => meta); + return {opened: metadataObjs.length ? metadataObjs.every(m => m.open_count > 0) : null}; + } + + _renderIcon = () => { + if (this.state.opened == null) { + return ; + } else if (this.state.opened) { + return ( + + ); + } + return ( + + ); + }; + + render() { + return ( +
+ {this._renderIcon()} +
+ ); + } +} diff --git a/internal_packages/open-tracking/package.json b/internal_packages/open-tracking/package.json new file mode 100644 index 000000000..940bcf99d --- /dev/null +++ b/internal_packages/open-tracking/package.json @@ -0,0 +1,25 @@ +{ + "name": "open-tracking", + "main": "./lib/main", + "version": "0.1.0", + "appId":"9tm0s8yzzkaahi5nql34iw8wu", + + "title": "Open Tracking", + "description": "Tracks whether email messages have been opened by recipients", + "icon": "./icon.png", + "isOptional": true, + + "repository": { + "type": "git", + "url": "" + }, + "engines": { + "nylas": ">=0.3.46" + }, + "windowTypes": { + "default": true, + "composer": true + }, + "dependencies": {}, + "license": "GPL-3.0" +} \ No newline at end of file diff --git a/internal_packages/open-tracking/stylesheets/main.less b/internal_packages/open-tracking/stylesheets/main.less new file mode 100644 index 000000000..65d1401ae --- /dev/null +++ b/internal_packages/open-tracking/stylesheets/main.less @@ -0,0 +1,24 @@ +@import "ui-variables"; +@import "ui-mixins"; + +.open-tracking-icon img.content-mask { + background-color: #AAA; + vertical-align: text-bottom; +} +.open-tracking-icon img.content-mask.unopened { + background-color: #C00; +} +.open-tracking-icon .open-count { + display: inline-block; + position: relative; + left: -16px; + text-align: center; + + color: #3187e1; + font-size: 12px; + font-weight: bold; +} +.open-tracking-icon { + width: 16px; + margin-right: 4px; +} \ No newline at end of file diff --git a/internal_packages/personal-level-indicators/icon.png b/internal_packages/personal-level-indicators/icon.png index 4b6f6e75a..c00bfa5cf 100644 Binary files a/internal_packages/personal-level-indicators/icon.png and b/internal_packages/personal-level-indicators/icon.png differ diff --git a/internal_packages/personal-level-indicators/package.json b/internal_packages/personal-level-indicators/package.json index 7c9ae9838..91a7e00d3 100644 --- a/internal_packages/personal-level-indicators/package.json +++ b/internal_packages/personal-level-indicators/package.json @@ -15,6 +15,6 @@ "engines": { "nylas": ">=0.3.0 <0.5.0" }, - "dependencies": [], - "license": "MIT" + "dependencies": {}, + "license": "GPL-3.0" } diff --git a/internal_packages/phishing-detection/icon.png b/internal_packages/phishing-detection/icon.png index 6b476c1fd..a9c381077 100644 Binary files a/internal_packages/phishing-detection/icon.png and b/internal_packages/phishing-detection/icon.png differ diff --git a/internal_packages/plugins/lib/package.cjsx b/internal_packages/plugins/lib/package.cjsx index a92ff3201..97d91185d 100644 --- a/internal_packages/plugins/lib/package.cjsx +++ b/internal_packages/plugins/lib/package.cjsx @@ -16,7 +16,7 @@ class Package extends React.Component extras = [] if @props.package.icon - icon = + icon = else icon = @@ -48,7 +48,9 @@ class Package extends React.Component ) -
{icon}
+
+
{icon}
+
{title ? name}
{description}
diff --git a/internal_packages/plugins/stylesheets/plugins.less b/internal_packages/plugins/stylesheets/plugins.less index 574b14b6c..a8c46a757 100644 --- a/internal_packages/plugins/stylesheets/plugins.less +++ b/internal_packages/plugins/stylesheets/plugins.less @@ -58,8 +58,16 @@ margin-bottom:@padding-large-vertical; padding:@padding-large-vertical @padding-large-horizontal; - .icon { - padding-right: @padding-large-horizontal; + .icon-container { + width: 52px; + height: 52px; + border-radius: 6px; + background: linear-gradient(to bottom, @background-primary 0%, @background-secondary 100%); + box-shadow: 0 0.5px 0 rgba(0,0,0,0.15), 0 -0.5px 0 rgba(0,0,0,0.15), 0.5px 0 0 rgba(0,0,0,0.15), -0.5px 0 0 rgba(0,0,0,0.15), 0 0.5px 1px rgba(0, 0, 0, 0.15); + flex-shrink: 0; + margin-right: @padding-large-horizontal; + text-align: center; + line-height: 50px; } .info { max-width: 380px; diff --git a/internal_packages/preferences/lib/tabs/preferences-account-details.jsx b/internal_packages/preferences/lib/tabs/preferences-account-details.jsx index 6354afa6c..dc7027f03 100644 --- a/internal_packages/preferences/lib/tabs/preferences-account-details.jsx +++ b/internal_packages/preferences/lib/tabs/preferences-account-details.jsx @@ -12,11 +12,11 @@ class PreferencesAccountDetails extends Component { constructor(props) { super(props); - this.state = _.clone(props.account); + this.state = {account: _.clone(props.account)}; } componentWillReceiveProps(nextProps) { - this.setState(_.clone(nextProps.account)); + this.setState({account: _.clone(nextProps.account)}); } componentWillUnmount() { @@ -51,30 +51,32 @@ class PreferencesAccountDetails extends Component { return `${name} <${email}>`; } - _updatedDefaultAlias(originalAlias, newAlias, defaultAlias) { - if (originalAlias === defaultAlias) { - return newAlias; - } - return defaultAlias; - } - _saveChanges = ()=> { - this.props.onAccountUpdated(this.props.account, this.state); + this.props.onAccountUpdated(this.props.account, this.state.account); + }; + + _setState = (updates, callback = ()=>{})=> { + const updated = _.extend({}, this.state.account, updates); + this.setState({account: updated}, callback); + }; + + _setStateAndSave = (updates)=> { + this._setState(updates, ()=> { + this._saveChanges(); + }); }; // Handlers _onAccountLabelUpdated = (event)=> { - this.setState({label: event.target.value}); + this._setState({label: event.target.value}); }; _onAccountAliasCreated = (newAlias)=> { const coercedAlias = this._makeAlias(newAlias); const aliases = this.state.aliases.concat([coercedAlias]); - this.setState({aliases}, ()=> { - this._saveChanges(); - }); + this._setStateAndSave({aliases}) }; _onAccountAliasUpdated = (newAlias, alias, idx)=> { @@ -85,9 +87,7 @@ class PreferencesAccountDetails extends Component { defaultAlias = coercedAlias; } aliases[idx] = coercedAlias; - this.setState({aliases, defaultAlias}, ()=> { - this._saveChanges(); - }); + this._setStateAndSave({aliases, defaultAlias}); }; _onAccountAliasRemoved = (alias, idx)=> { @@ -97,16 +97,12 @@ class PreferencesAccountDetails extends Component { defaultAlias = null; } aliases.splice(idx, 1); - this.setState({aliases, defaultAlias}, ()=> { - this._saveChanges(); - }); + this._setStateAndSave({aliases, defaultAlias}); }; _onDefaultAliasSelected = (event)=> { const defaultAlias = event.target.value === 'None' ? null : event.target.value; - this.setState({defaultAlias}, ()=> { - this._saveChanges(); - }); + this._setStateAndSave({defaultAlias}); }; @@ -129,7 +125,7 @@ class PreferencesAccountDetails extends Component { } render() { - const account = this.state; + const {account} = this.state; const aliasPlaceholder = this._makeAlias( `alias@${account.emailAddress.split('@')[1]}` ); diff --git a/internal_packages/print/lib/print-window.es6 b/internal_packages/print/lib/print-window.es6 index 25ca54362..a9351038e 100644 --- a/internal_packages/print/lib/print-window.es6 +++ b/internal_packages/print/lib/print-window.es6 @@ -26,6 +26,9 @@ export default class PrintWindow {