Merge branch 'master' of github.com:nylas/N1
25
ISSUE_TEMPLATE.md
Normal file
|
@ -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?
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
[](https://travis-ci.org/nylas/N1)
|
||||
[](http://slack-invite.nylas.com)
|
||||
[](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
|
||||
|
|
|
@ -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],
|
||||
|
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 774 B |
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 160 KiB |
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.7 KiB |
|
@ -1 +1 @@
|
|||
Subproject commit 8c4a86eb7ee1a06249a9ae35397e2084a09ad1dc
|
||||
Subproject commit d5cfa779439aa7b67e7f805af63ae967ddf7628e
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 161 KiB |
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 159 KiB |
|
@ -14,6 +14,7 @@ module.exports = (grunt) ->
|
|||
'**/examples/**'
|
||||
'**/src/tasks/**'
|
||||
'**/node_modules/spellchecker/**'
|
||||
'**/node_modules/windows-shortcuts/**'
|
||||
]
|
||||
unpack = "{#{unpack.join(',')}}"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 17 KiB |
|
@ -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(<button onMouseDown={() => this.onMouseDown(emojiChar)} className={emojiClass}>{emojiChar} :{emojiOption}:</button>);
|
||||
emojis.push(<br />);
|
||||
emojis.push(<button key={emojiChar} onMouseDown={() => this.onMouseDown(emojiChar)} className={emojiClass}>{emojiChar} :{emojiOption}:</button>);
|
||||
emojis.push(<br key={emojiChar + " br"} />);
|
||||
})
|
||||
}
|
||||
return (
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -23,5 +23,5 @@
|
|||
"default": true,
|
||||
"composer": true
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
@ -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;
|
||||
|
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
|
@ -8,6 +8,7 @@
|
|||
}
|
||||
.content-container {
|
||||
height:185px;
|
||||
width:170px;
|
||||
overflow:scroll;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)} />
|
||||
);
|
||||
|
|
|
@ -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
|
|||
</div>
|
||||
|
||||
_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
|
||||
})
|
||||
|
|
|
@ -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']} />
|
||||
</div>
|
||||
|
@ -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) }
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -144,7 +144,7 @@ describe "ComposerView", ->
|
|||
<ComposerView draftClientId={DRAFT_CLIENT_ID} {...props} />
|
||||
)
|
||||
|
||||
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 <b>World</b><br/> 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
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ describe 'ParticipantsTextField', ->
|
|||
field={@fieldName}
|
||||
tabIndex={@tabIndex}
|
||||
visible={true}
|
||||
draftReady={true}
|
||||
participants={@participants}
|
||||
change={@propChange} />
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 15 KiB |
|
@ -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"
|
||||
},
|
||||
|
|
4
internal_packages/link-tracking/README.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
## Open Tracking
|
||||
|
||||
Adds tracking pixels to messages and tracks whether they have been opened.
|
BIN
internal_packages/link-tracking/assets/linktracking-icon@2x.png
Normal file
After Width: | Height: | Size: 635 B |
BIN
internal_packages/link-tracking/icon.png
Normal file
After Width: | Height: | Size: 16 KiB |
59
internal_packages/link-tracking/lib/link-tracking-button.jsx
Normal file
|
@ -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 (
|
||||
<button
|
||||
title="Link Tracking"
|
||||
className={`btn btn-toolbar ${this.state.enabled ? "btn-action" : ""}`}
|
||||
onClick={this._onClick}>
|
||||
<RetinaImg
|
||||
url="nylas://link-tracking/assets/linktracking-icon@2x.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import {ComposerExtension, Actions, QuotedHTMLTransformer} from 'nylas-exports';
|
||||
import plugin from '../package.json'
|
||||
|
||||
import uuid from 'node-uuid';
|
||||
|
||||
const LINK_REGEX = (/(<a\s.*?href\s*?=\s*?")([^"]*)("[^>]*>)|(<a\s.*?href\s*?=\s*?')([^']*)('[^>]*>)/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 <a href> 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);
|
||||
}
|
||||
}
|
||||
}
|
58
internal_packages/link-tracking/lib/link-tracking-icon.jsx
Normal file
|
@ -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 (<span>
|
||||
<RetinaImg
|
||||
className={clicks > 0 ? "clicked" : ""}
|
||||
url="nylas://link-tracking/assets/linktracking-icon@2x.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask} />
|
||||
<span className="link-click-count">{clicks > 0 ? clicks : ""}</span>
|
||||
</span>)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (<div className="link-tracking-icon">
|
||||
{this._renderIcon()}
|
||||
</div>)
|
||||
}
|
||||
}
|
47
internal_packages/link-tracking/lib/link-tracking-panel.jsx
Normal file
|
@ -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 (<tr className="link-info">
|
||||
<td className="link-url">{link.url}</td>
|
||||
<td className="link-count">{link.click_count + " clicks"}</td>
|
||||
</tr>)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.links) {
|
||||
return (<div className="link-tracking-panel">
|
||||
<h4>Link Tracking Enabled</h4>
|
||||
<table>
|
||||
<tbody>
|
||||
{this._renderContents()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>);
|
||||
}
|
||||
return <div></div>;
|
||||
}
|
||||
}
|
61
internal_packages/link-tracking/lib/main.es6
Normal file
|
@ -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()
|
||||
}
|
25
internal_packages/link-tracking/package.json
Normal file
|
@ -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"
|
||||
}
|
47
internal_packages/link-tracking/stylesheets/main.less
Normal file
|
@ -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;
|
||||
}
|
|
@ -216,8 +216,10 @@ class MessageList extends React.Component
|
|||
|
||||
<div className="message-subject-wrap">
|
||||
<MailImportantIcon thread={@state.currentThread}/>
|
||||
<span className="message-subject">{subject}</span>
|
||||
{@_renderLabels()}
|
||||
<div style={flex: 1}>
|
||||
<span className="message-subject">{subject}</span>
|
||||
{@_renderLabels()}
|
||||
</div>
|
||||
{@_renderIcons()}
|
||||
</div>
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
<div key={"#{c.email}-#{i}"} className="participant selectable">
|
||||
<span className="participant-primary" onClick={@_selectPlainText}>{c.name}</span>
|
||||
<span className="participant-secondary" onClick={@_selectBracketedText}><{c.email}>{comma}</span>
|
||||
<div className="participant-primary" onClick={@_selectText}>
|
||||
{c.name}
|
||||
</div>
|
||||
<div className="participant-secondary">
|
||||
{"<"}<span onClick={@_selectText}>{c.email}</span>{">#{comma}"}
|
||||
</div>
|
||||
</div>
|
||||
else
|
||||
<div key={"#{c.email}-#{i}"} className="participant selectable">
|
||||
<span className="participant-primary" onClick={@_selectCommaText}>{c.email}{comma}</span>
|
||||
<div className="participant-primary">
|
||||
<span onClick={@_selectText}>{c.email}</span>{comma}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ class ThreadTrashButton extends React.Component
|
|||
|
||||
render: =>
|
||||
focusedMailboxPerspective = FocusedPerspectiveStore.current()
|
||||
return false unless focusedMailboxPerspective?.canTrashThreads()
|
||||
return false unless focusedMailboxPerspective.canTrashThreads()
|
||||
|
||||
<button className="btn btn-toolbar"
|
||||
style={order: -106}
|
||||
|
|
|
@ -90,7 +90,7 @@ describe "MessageParticipants", ->
|
|||
|
||||
it "uses full names", ->
|
||||
to = ReactTestUtils.findRenderedDOMComponentWithClass(@participants, "to-contact")
|
||||
expect(React.findDOMNode(to).innerText.trim()).toEqual "User Two <user2@nylas.com>"
|
||||
expect(React.findDOMNode(to).innerText.trim()).toEqual "User Two<user2@nylas.com>"
|
||||
|
||||
|
||||
# TODO: We no longer display "to everyone"
|
||||
|
|
|
@ -17,7 +17,7 @@ body.platform-win32 {
|
|||
.sheet-toolbar {
|
||||
.message-toolbar-arrow.down {
|
||||
margin: 0 0 0 1px;
|
||||
padding: 6px 5px 0 5px;
|
||||
padding: 0 5px;
|
||||
.windows-btn-bg;
|
||||
&:hover {
|
||||
background: #e5e5e5;
|
||||
|
@ -29,7 +29,7 @@ body.platform-win32 {
|
|||
}
|
||||
.message-toolbar-arrow.up {
|
||||
margin: 0 0 0 1px;
|
||||
padding: 6px 5px 0 5px;
|
||||
padding: 0 5px;
|
||||
.windows-btn-bg;
|
||||
&.btn-icon:hover {
|
||||
color: @text-color;
|
||||
|
@ -76,12 +76,10 @@ body.platform-win32 {
|
|||
.message-toolbar-arrow.down {
|
||||
order:201;
|
||||
margin-right: 0;
|
||||
padding-top:6px;
|
||||
margin-left: @spacing-standard * 1.5;
|
||||
}
|
||||
.message-toolbar-arrow.up {
|
||||
order:202;
|
||||
padding-top:6px;
|
||||
// <1 because of hit region padding on the button
|
||||
margin-right: @spacing-standard * 0.75;
|
||||
}
|
||||
|
@ -126,15 +124,15 @@ body.platform-win32 {
|
|||
width: calc(~"100% - 12px");
|
||||
max-width: @message-max-width;
|
||||
margin: 11px auto 10px auto;
|
||||
padding-left: 20px;
|
||||
-webkit-user-select: text;
|
||||
line-height: @font-size-large * 1.8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 @padding-large-horizontal;
|
||||
}
|
||||
.mail-important-icon {
|
||||
margin-right:@spacing-half;
|
||||
margin-bottom:2px;
|
||||
margin-bottom:1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.message-subject {
|
||||
|
@ -147,7 +145,6 @@ body.platform-win32 {
|
|||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
margin-left: auto;
|
||||
margin-right: @padding-large-horizontal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
|
@ -193,10 +190,7 @@ body.platform-win32 {
|
|||
width: calc(~"100% - 12px");
|
||||
|
||||
margin: 0 auto;
|
||||
padding: @message-spacing 0;
|
||||
&:last-child {
|
||||
padding-bottom: @spacing-double;
|
||||
}
|
||||
|
||||
.message-item-white-wrap {
|
||||
background: @background-primary;
|
||||
border: 0;
|
||||
|
@ -204,6 +198,7 @@ body.platform-win32 {
|
|||
border-radius: 4px;
|
||||
}
|
||||
|
||||
padding-bottom: @message-spacing * 2;
|
||||
&.before-reply-area { padding-bottom: 0; }
|
||||
|
||||
&.collapsed {
|
||||
|
@ -286,7 +281,7 @@ body.platform-win32 {
|
|||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -80px;
|
||||
margin-top: -14px;
|
||||
margin-top: -15px;
|
||||
border-radius: 15px;
|
||||
border: 1px solid @border-color-divider;
|
||||
width: 160px;
|
||||
|
@ -324,6 +319,7 @@ body.platform-win32 {
|
|||
.message-actions-wrap {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
.pending-spinner {
|
||||
opacity: 1;
|
||||
|
@ -426,7 +422,7 @@ body.platform-win32 {
|
|||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-top: 5px;
|
||||
margin-top: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
@ -555,7 +551,7 @@ body.platform-win32 {
|
|||
|
||||
.participant {
|
||||
display: inline-block;
|
||||
margin-right: 0.5em;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.participant-type {
|
||||
|
@ -581,9 +577,12 @@ body.platform-win32 {
|
|||
|
||||
.participant-primary {
|
||||
color: @text-color-very-subtle;
|
||||
margin-right: 0.15em;
|
||||
display:inline-block;
|
||||
}
|
||||
.participant-secondary {
|
||||
color: @text-color-very-subtle;
|
||||
display:inline-block;
|
||||
}
|
||||
|
||||
.from-contact {
|
||||
|
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 2.2 KiB |
|
@ -1,11 +1,8 @@
|
|||
@import 'ui-variables';
|
||||
|
||||
.btn-toolbar.mode-toggle {
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
img.content-mask {
|
||||
background-color: @text-color-subtle;
|
||||
}
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
}
|
||||
.btn-toolbar.mode-toggle.mode-false {
|
||||
img.content-mask {
|
||||
|
|
|
@ -100,7 +100,7 @@ class AccountChoosePage extends React.Component
|
|||
https://mail.google.com/ \
|
||||
https://www.google.com/m8/feeds \
|
||||
https://www.googleapis.com/auth/calendar'
|
||||
approval_prompt: 'force'
|
||||
prompt: 'consent'
|
||||
})
|
||||
{shell} = require 'electron'
|
||||
shell.openExternal(googleUrl)
|
||||
|
|
|
@ -199,13 +199,6 @@ Providers = [
|
|||
default: 993
|
||||
format: 'integer'
|
||||
page: 1
|
||||
}, {
|
||||
name: 'imap_ssl_enabled'
|
||||
type: 'checkbox'
|
||||
label: 'Use SSL'
|
||||
className: 'half'
|
||||
default: true
|
||||
page: 1
|
||||
}, {
|
||||
name: 'imap_username'
|
||||
type: 'text'
|
||||
|
@ -237,13 +230,6 @@ Providers = [
|
|||
format: 'integer'
|
||||
default: 587
|
||||
page: 2
|
||||
}, {
|
||||
name: 'smtp_ssl_enabled'
|
||||
type: 'checkbox'
|
||||
label: 'Use SSL'
|
||||
className: 'half'
|
||||
default: true
|
||||
page: 2
|
||||
}, {
|
||||
name: 'smtp_username'
|
||||
type: 'text'
|
||||
|
|
|
@ -32,7 +32,14 @@ class PageRouterStore extends NylasStore
|
|||
@_onMoveToPage('initial-preferences', {account: json})
|
||||
Actions.recordUserEvent('First Account Linked')
|
||||
else
|
||||
ipcRenderer.send('account-setup-successful')
|
||||
# When account JSON is received, we want to notify external services
|
||||
# that it succeeded. Unfortunately in this case we're likely to
|
||||
# close the window before those requests can be made. We add a short
|
||||
# delay here to ensure that any pending requests have a chance to
|
||||
# clear before the window closes.
|
||||
setTimeout ->
|
||||
ipcRenderer.send('account-setup-successful')
|
||||
, 100
|
||||
|
||||
_onWindowPropsChanged: ({page, pageData}={}) =>
|
||||
@_onMoveToPage(page, pageData)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
React = require 'react'
|
||||
{shell} = require 'electron'
|
||||
classnames = require 'classnames'
|
||||
{Actions} = require 'nylas-exports'
|
||||
{RetinaImg} = require 'nylas-component-kit'
|
||||
PageRouterStore = require './page-router-store'
|
||||
OnboardingActions = require './onboarding-actions'
|
||||
|
@ -124,6 +125,9 @@ class WelcomePage extends React.Component
|
|||
if @state.step < 2
|
||||
@setState(step: @state.step + 1)
|
||||
else
|
||||
Actions.recordUserEvent('Welcome Page Finished', {
|
||||
tokenAuthEnabled: PageRouterStore.tokenAuthEnabled(0)
|
||||
})
|
||||
if PageRouterStore.tokenAuthEnabled() is "no"
|
||||
OnboardingActions.moveToPage("account-choose")
|
||||
else
|
||||
|
|
|
@ -415,7 +415,7 @@
|
|||
}
|
||||
.btn-install {
|
||||
display:inline-block;
|
||||
width: 70px;
|
||||
width: 56px;
|
||||
margin: 24px;
|
||||
margin-right: 7px;
|
||||
margin-left: 14px;
|
||||
|
@ -512,11 +512,12 @@
|
|||
border-top: 1px solid #d4d4d4;
|
||||
|
||||
.btn-continue {
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
font-weight: 300;
|
||||
margin: 20px 0;
|
||||
padding: 12px 0;
|
||||
width: 296px;
|
||||
line-height: 2.5em;
|
||||
height: 2.5em;
|
||||
}
|
||||
.btn-back {
|
||||
color: rgba(0,0,0,0.4);
|
||||
|
|
4
internal_packages/open-tracking/README.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
## Open Tracking
|
||||
|
||||
Adds tracking pixels to messages and tracks whether they have been opened.
|
After Width: | Height: | Size: 519 B |
BIN
internal_packages/open-tracking/assets/envelope-open-icon@2x.png
Normal file
After Width: | Height: | Size: 638 B |
BIN
internal_packages/open-tracking/assets/eye@2x.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
internal_packages/open-tracking/icon.png
Normal file
After Width: | Height: | Size: 16 KiB |
62
internal_packages/open-tracking/lib/main.es6
Normal file
|
@ -0,0 +1,62 @@
|
|||
import {ComponentRegistry, ExtensionRegistry, DatabaseStore, Message, Actions} from 'nylas-exports';
|
||||
import OpenTrackingButton from './open-tracking-button';
|
||||
import OpenTrackingIcon from './open-tracking-icon';
|
||||
import OpenTrackingComposerExtension from './open-tracking-composer-extension';
|
||||
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-open-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;
|
||||
|
||||
// set metadata against the message
|
||||
Actions.setMetadata(message, PLUGIN_ID, {open_count: 0, open_data: []});
|
||||
|
||||
// post the uid and message id pair to the plugin server
|
||||
const data = {uid: uid, message_id: message.id, thread_id: 1};
|
||||
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 Open Tracking server! This message will not have open tracking :(");
|
||||
Promise.reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function activate() {
|
||||
ComponentRegistry.register(OpenTrackingButton, {role: 'Composer:ActionButton'});
|
||||
ComponentRegistry.register(OpenTrackingIcon, {role: 'ThreadListIcon'});
|
||||
ExtensionRegistry.Composer.register(OpenTrackingComposerExtension);
|
||||
this._unlistenSendDraftSuccess = Actions.sendDraftSuccess.listen(afterDraftSend);
|
||||
}
|
||||
|
||||
export function serialize() {}
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(OpenTrackingButton);
|
||||
ComponentRegistry.unregister(OpenTrackingIcon);
|
||||
ExtensionRegistry.Composer.unregister(OpenTrackingComposerExtension);
|
||||
this._unlistenSendDraftSuccess()
|
||||
}
|
55
internal_packages/open-tracking/lib/open-tracking-button.jsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
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 OpenTrackingButton extends React.Component {
|
||||
|
||||
static displayName = 'OpenTrackingButton';
|
||||
|
||||
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 (<button className={`btn btn-toolbar ${this.state.enabled ? "btn-action" : ""}`}
|
||||
onClick={this._onClick} title="Open Tracking">
|
||||
<RetinaImg url="nylas://open-tracking/assets/envelope-open-icon@2x.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>)
|
||||
}
|
||||
|
||||
}
|
|
@ -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 <img> into the message
|
||||
const serverUrl = `http://${PLUGIN_URL}/${draft.accountId}/${uid}`;
|
||||
const img = `<img width="0" height="0" style="border:0; width:0; height:0;" src="${serverUrl}">`;
|
||||
const draftBody = new DraftBody(draft);
|
||||
draftBody.unquoted = draftBody.unquoted + "<br>" + 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);
|
||||
}
|
||||
}
|
||||
}
|
52
internal_packages/open-tracking/lib/open-tracking-icon.jsx
Normal file
|
@ -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 <span />;
|
||||
} else if (this.state.opened) {
|
||||
return (
|
||||
<RetinaImg
|
||||
url="nylas://open-tracking/assets/envelope-open-icon@2x.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RetinaImg
|
||||
className="unopened"
|
||||
url="nylas://open-tracking/assets/envelope-closed-icon@2x.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask} />
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="open-tracking-icon">
|
||||
{this._renderIcon()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
25
internal_packages/open-tracking/package.json
Normal file
|
@ -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"
|
||||
}
|
24
internal_packages/open-tracking/stylesheets/main.less
Normal file
|
@ -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;
|
||||
}
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 15 KiB |
|
@ -15,6 +15,6 @@
|
|||
"engines": {
|
||||
"nylas": ">=0.3.0 <0.5.0"
|
||||
},
|
||||
"dependencies": [],
|
||||
"license": "MIT"
|
||||
"dependencies": {},
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 16 KiB |
|
@ -16,7 +16,7 @@ class Package extends React.Component
|
|||
extras = []
|
||||
|
||||
if @props.package.icon
|
||||
icon = <img src="nylas://#{@props.package.name}/#{@props.package.icon}" style={width:50} />
|
||||
icon = <img src="nylas://#{@props.package.name}/#{@props.package.icon}" style={width:27, alignContent: 'center', objectFit: 'scale-down'} />
|
||||
else
|
||||
icon = <RetinaImg name="plugin-icon-default.png"/>
|
||||
|
||||
|
@ -48,7 +48,9 @@ class Package extends React.Component
|
|||
)
|
||||
|
||||
<Flexbox className="package" direction="row">
|
||||
<div className="icon" style={flexShink: 0}>{icon}</div>
|
||||
<div className="icon-container">
|
||||
<div className="icon" >{icon}</div>
|
||||
</div>
|
||||
<div className="info">
|
||||
<div className="title">{title ? name}</div>
|
||||
<div className="description">{description}</div>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]}`
|
||||
);
|
||||
|
|
|
@ -26,6 +26,9 @@ export default class PrintWindow {
|
|||
</head>
|
||||
<body>
|
||||
<div id="print-header">
|
||||
<div onClick="continueAndPrint()" id="print-button">
|
||||
Print
|
||||
</div>
|
||||
<div class="logo-wrapper">
|
||||
<img src="${imgPath}" alt="nylas-logo"/>
|
||||
<span class="account">${account.name} <${account.email}></span>
|
||||
|
|
|
@ -1,6 +1,26 @@
|
|||
body {
|
||||
overflow: auto !important;
|
||||
}
|
||||
#print-button {
|
||||
float:right;
|
||||
margin-left: 10px;
|
||||
|
||||
/* From main button styles: */
|
||||
padding: 0 0.8em;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
height: 1.9em;
|
||||
line-height: 1.9em;
|
||||
font-size: 13.02px;
|
||||
cursor: default;
|
||||
color: #231f20;
|
||||
position: relative;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(to bottom, #6bb1f9 0%, #0a80ff 100%);
|
||||
box-shadow: none;
|
||||
border: 1px solid #3878fa;
|
||||
}
|
||||
#print-header {
|
||||
padding: 15px 20px 0 20px;
|
||||
}
|
||||
|
|
|
@ -25,17 +25,20 @@
|
|||
removeClassFromNodes(scrollContentInners, 'scroll-region-content-inner');
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print();
|
||||
// Close this print window after selecting to print
|
||||
// This is really hackish but appears to be the only working solution
|
||||
setTimeout(window.close, 500);
|
||||
function continueAndPrint() {
|
||||
document.getElementById('print-button').style.display = 'none';
|
||||
window.requestAnimationFrame(function() {
|
||||
window.print();
|
||||
// Close this print window after selecting to print
|
||||
// This is really hackish but appears to be the only working solution
|
||||
setTimeout(window.close, 500);
|
||||
});
|
||||
}
|
||||
|
||||
var messageNodes = document.querySelectorAll('.message-item-area>span');
|
||||
|
||||
removeScrollClasses();
|
||||
rebuildMessages(messageNodes, window.printMessages);
|
||||
// Give it a few ms before poppint out the print dialog
|
||||
setTimeout(print, 50);
|
||||
|
||||
window.continueAndPrint = continueAndPrint;
|
||||
})();
|
||||
|
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 14 KiB |
|
@ -23,5 +23,5 @@
|
|||
"default": true,
|
||||
"composer": true
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ class SearchSuggestionStore extends NylasStore
|
|||
|
||||
_onQueryChanged: (query) =>
|
||||
@_searchQuery = query
|
||||
@trigger()
|
||||
@_compileResults()
|
||||
_.defer => @_rebuildResults()
|
||||
|
||||
_onQuerySubmitted: (query) =>
|
||||
|
@ -42,6 +42,7 @@ class SearchSuggestionStore extends NylasStore
|
|||
current = FocusedPerspectiveStore.current()
|
||||
|
||||
if @queryPopulated()
|
||||
Actions.recordUserEvent("Commit Search Query", {})
|
||||
@_perspectiveBeforeSearch ?= current
|
||||
next = MailboxPerspective.forSearch(current.accountIds, @_searchQuery.trim())
|
||||
Actions.focusMailboxPerspective(next)
|
||||
|
|
|
@ -8,8 +8,6 @@
|
|||
z-index: 100;
|
||||
width:450px;
|
||||
margin-top: (38px - 23px) / 2;
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
|
||||
.menu .header-container {
|
||||
padding:0;
|
||||
|
@ -75,7 +73,7 @@
|
|||
|
||||
&.clear {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: 3px;
|
||||
right: 4px;
|
||||
color: @input-accessory-color;
|
||||
display: none;
|
||||
|
|
BIN
internal_packages/send-later/icon.png
Normal file
After Width: | Height: | Size: 15 KiB |
22
internal_packages/send-later/lib/main.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
/** @babel */
|
||||
import {ComponentRegistry} from 'nylas-exports'
|
||||
import SendLaterPopover from './send-later-popover'
|
||||
import SendLaterStore from './send-later-store'
|
||||
import SendLaterStatus from './send-later-status'
|
||||
|
||||
export function activate() {
|
||||
SendLaterStore.activate()
|
||||
ComponentRegistry.register(SendLaterPopover, {role: 'Composer:ActionButton'})
|
||||
ComponentRegistry.register(SendLaterStatus, {role: 'DraftList:DraftStatus'})
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(SendLaterPopover)
|
||||
ComponentRegistry.unregister(SendLaterStatus)
|
||||
SendLaterStore.deactivate()
|
||||
}
|
||||
|
||||
export function serialize() {
|
||||
|
||||
}
|
||||
|
13
internal_packages/send-later/lib/send-later-actions.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/** @babel */
|
||||
import Reflux from 'reflux';
|
||||
|
||||
const SendLaterActions = Reflux.createActions([
|
||||
'sendLater',
|
||||
'cancelSendLater',
|
||||
])
|
||||
|
||||
for (const key in SendLaterActions) {
|
||||
SendLaterActions[key].sync = true
|
||||
}
|
||||
|
||||
export default SendLaterActions
|
6
internal_packages/send-later/lib/send-later-constants.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
/** @babel */
|
||||
export const PLUGIN_ID = "aqx344zhdh6jyabqokejknkvr"
|
||||
export const PLUGIN_NAME = "Send Later"
|
||||
export const DATE_FORMAT_LONG = 'ddd, MMM D, YYYY h:mmA'
|
||||
export const DATE_FORMAT_SHORT = 'MMM D h:mmA'
|
||||
|
158
internal_packages/send-later/lib/send-later-popover.jsx
Normal file
|
@ -0,0 +1,158 @@
|
|||
/** @babel */
|
||||
import _ from 'underscore'
|
||||
import React, {Component, PropTypes} from 'react'
|
||||
import {DateUtils} from 'nylas-exports'
|
||||
import {Popover} from 'nylas-component-kit'
|
||||
import SendLaterActions from './send-later-actions'
|
||||
import SendLaterStore from './send-later-store'
|
||||
import {DATE_FORMAT_SHORT, DATE_FORMAT_LONG} from './send-later-constants'
|
||||
|
||||
|
||||
const SendLaterOptions = {
|
||||
'In 1 hour': DateUtils.in1Hour,
|
||||
'Later Today': DateUtils.laterToday,
|
||||
'Tomorrow Morning': DateUtils.tomorrow,
|
||||
'Tomorrow Evening': DateUtils.tomorrowEvening,
|
||||
'This Weekend': DateUtils.thisWeekend,
|
||||
'Next Week': DateUtils.nextWeek,
|
||||
}
|
||||
|
||||
class SendLaterPopover extends Component {
|
||||
static displayName = 'SendLaterPopover';
|
||||
|
||||
static propTypes = {
|
||||
draftClientId: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
inputSendDate: null,
|
||||
isScheduled: SendLaterStore.isScheduled(this.props.draftClientId),
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = SendLaterStore.listen(this.onScheduledMessagesChanged)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe()
|
||||
}
|
||||
|
||||
onSendLater = (momentDate)=> {
|
||||
const utcDate = momentDate.utc()
|
||||
const formatted = DateUtils.format(utcDate)
|
||||
SendLaterActions.sendLater(this.props.draftClientId, formatted)
|
||||
|
||||
this.setState({isScheduled: null, inputSendDate: null})
|
||||
this.refs.popover.close()
|
||||
};
|
||||
|
||||
onCancelSendLater = ()=> {
|
||||
SendLaterActions.cancelSendLater(this.props.draftClientId)
|
||||
this.setState({inputSendDate: null})
|
||||
this.refs.popover.close()
|
||||
};
|
||||
|
||||
onScheduledMessagesChanged = ()=> {
|
||||
const isScheduled = SendLaterStore.isScheduled(this.props.draftClientId)
|
||||
if (isScheduled !== this.state.isScheduled) {
|
||||
this.setState({isScheduled});
|
||||
}
|
||||
};
|
||||
|
||||
onInputChange = (event)=> {
|
||||
this.updateInputSendDateValue(event.target.value)
|
||||
};
|
||||
|
||||
getButtonLabel = (isScheduled)=> {
|
||||
return isScheduled ? '✅ Scheduled' : 'Send Later';
|
||||
};
|
||||
|
||||
updateInputSendDateValue = _.debounce((dateValue)=> {
|
||||
const inputSendDate = DateUtils.fromString(dateValue)
|
||||
this.setState({inputSendDate})
|
||||
}, 250);
|
||||
|
||||
renderItems() {
|
||||
return Object.keys(SendLaterOptions).map((label)=> {
|
||||
const date = SendLaterOptions[label]()
|
||||
const formatted = DateUtils.format(date, DATE_FORMAT_SHORT)
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
onMouseDown={this.onSendLater.bind(this, date)}
|
||||
className="send-later-option">
|
||||
{label}
|
||||
<em className="item-date-value">{formatted}</em>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
renderEmptyInput() {
|
||||
return (
|
||||
<div className="send-later-section">
|
||||
<label>At a specific time</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Next Monday at 1pm"
|
||||
onChange={this.onInputChange}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderLabeledInput(inputSendDate) {
|
||||
const formatted = DateUtils.format(inputSendDate, DATE_FORMAT_LONG)
|
||||
return (
|
||||
<div className="send-later-section">
|
||||
<label>At a specific time</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Next Monday at 1pm"
|
||||
onChange={this.onInputChange}/>
|
||||
<em className="input-date-value">{formatted}</em>
|
||||
<button
|
||||
className="btn btn-send-later"
|
||||
onClick={this.onSendLater.bind(this, inputSendDate)}>Schedule Email</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {isScheduled, inputSendDate} = this.state
|
||||
const buttonLabel = isScheduled != null ? this.getButtonLabel(isScheduled) : 'Scheduling...';
|
||||
const button = (
|
||||
<button className="btn btn-primary send-later-button">{buttonLabel}</button>
|
||||
)
|
||||
const input = inputSendDate ? this.renderLabeledInput(inputSendDate) : this.renderEmptyInput();
|
||||
|
||||
return (
|
||||
<Popover
|
||||
ref="popover"
|
||||
style={{order: -103}}
|
||||
className="send-later"
|
||||
buttonComponent={button}>
|
||||
<div className="send-later-container">
|
||||
{this.renderItems()}
|
||||
<div className="divider" />
|
||||
{input}
|
||||
{isScheduled ?
|
||||
<div className="divider" />
|
||||
: void 0}
|
||||
{isScheduled ?
|
||||
<div className="send-later-section">
|
||||
<button className="btn btn-send-later" onClick={this.onCancelSendLater}>
|
||||
Unschedule Send
|
||||
</button>
|
||||
</div>
|
||||
: void 0}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default SendLaterPopover
|
40
internal_packages/send-later/lib/send-later-status.jsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import React, {Component, PropTypes} from 'react'
|
||||
import moment from 'moment'
|
||||
import {DateUtils} from 'nylas-exports'
|
||||
import {RetinaImg} from 'nylas-component-kit'
|
||||
import SendLaterActions from './send-later-actions'
|
||||
import {PLUGIN_ID, DATE_FORMAT_SHORT} from './send-later-constants'
|
||||
|
||||
export default class SendLaterStatus extends Component {
|
||||
static displayName = 'SendLaterStatus';
|
||||
|
||||
static propTypes = {
|
||||
draft: PropTypes.object,
|
||||
};
|
||||
|
||||
onCancelSendLater = ()=> {
|
||||
SendLaterActions.cancelSendLater(this.props.draft.clientId)
|
||||
};
|
||||
|
||||
render() {
|
||||
const {draft} = this.props
|
||||
const metadata = draft.metadataForPluginId(PLUGIN_ID)
|
||||
if (metadata && metadata.sendLaterDate) {
|
||||
const {sendLaterDate} = metadata
|
||||
const formatted = DateUtils.format(moment(sendLaterDate), DATE_FORMAT_SHORT)
|
||||
return (
|
||||
<div className="send-later-status">
|
||||
<em className="send-later-status">
|
||||
{`Scheduled for ${formatted}`}
|
||||
</em>
|
||||
<RetinaImg
|
||||
name="image-cancel-button.png"
|
||||
title="Cancel Send Later"
|
||||
onClick={this.onCancelSendLater}
|
||||
mode={RetinaImg.Mode.ContentPreserve} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span />
|
||||
}
|
||||
}
|
84
internal_packages/send-later/lib/send-later-store.js
Normal file
|
@ -0,0 +1,84 @@
|
|||
/** @babel */
|
||||
import NylasStore from 'nylas-store'
|
||||
import {NylasAPI, Actions, Message, Rx, DatabaseStore} from 'nylas-exports'
|
||||
import SendLaterActions from './send-later-actions'
|
||||
import {PLUGIN_ID, PLUGIN_NAME} from './send-later-constants'
|
||||
|
||||
|
||||
class SendLaterStore extends NylasStore {
|
||||
|
||||
constructor(pluginId = PLUGIN_ID) {
|
||||
super()
|
||||
this.pluginId = pluginId
|
||||
this.scheduledMessages = new Map()
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.setupQuerySubscription()
|
||||
|
||||
this.unsubscribers = [
|
||||
SendLaterActions.sendLater.listen(this.onSendLater),
|
||||
SendLaterActions.cancelSendLater.listen(this.onCancelSendLater),
|
||||
]
|
||||
}
|
||||
|
||||
setupQuerySubscription() {
|
||||
const query = DatabaseStore.findAll(
|
||||
Message, [Message.attributes.pluginMetadata.contains(this.pluginId)]
|
||||
)
|
||||
this.queryDisposable = Rx.Observable.fromQuery(query).subscribe(this.onScheduledMessagesChanged)
|
||||
}
|
||||
|
||||
getScheduledMessage = (messageClientId)=> {
|
||||
return this.scheduledMessages.get(messageClientId)
|
||||
};
|
||||
|
||||
isScheduled = (messageClientId)=> {
|
||||
const message = this.getScheduledMessage(messageClientId)
|
||||
if (message && message.metadataForPluginId(this.pluginId).sendLaterDate) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
};
|
||||
|
||||
setMetadata = (draftClientId, metadata)=> {
|
||||
return (
|
||||
DatabaseStore.modelify(Message, [draftClientId])
|
||||
.then((messages)=> {
|
||||
const {accountId} = messages[0]
|
||||
return NylasAPI.authPlugin(this.pluginId, PLUGIN_NAME, accountId)
|
||||
.then(()=> {
|
||||
Actions.setMetadata(messages, this.pluginId, metadata)
|
||||
})
|
||||
.catch((error)=> {
|
||||
console.error(error)
|
||||
NylasEnv.showErrorDialog(error.message)
|
||||
})
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
onScheduledMessagesChanged = (messages)=> {
|
||||
this.scheduledMessages.clear()
|
||||
messages.forEach((message)=> {
|
||||
this.scheduledMessages.set(message.clientId, message);
|
||||
})
|
||||
this.trigger()
|
||||
};
|
||||
|
||||
onSendLater = (draftClientId, sendLaterDate)=> {
|
||||
this.setMetadata(draftClientId, {sendLaterDate})
|
||||
};
|
||||
|
||||
onCancelSendLater = (draftClientId)=> {
|
||||
this.setMetadata(draftClientId, {sendLaterDate: null})
|
||||
};
|
||||
|
||||
deactivate = ()=> {
|
||||
this.queryDisposable.dispose()
|
||||
this.unsubscribers.forEach(unsub => unsub())
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export default new SendLaterStore()
|
21
internal_packages/send-later/package.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "n1-send-later",
|
||||
"version": "1.0.0",
|
||||
|
||||
"title": "Send Later",
|
||||
"description": "Choose to send emails at a specified time in the future.",
|
||||
"icon": "./icon.png",
|
||||
"isOptional": true,
|
||||
|
||||
|
||||
"main": "lib/main",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"windowTypes": {
|
||||
"default": true,
|
||||
"composer": true
|
||||
},
|
||||
"isOptional": true,
|
||||
"license": "GPL-3.0"
|
||||
}
|
70
internal_packages/send-later/stylesheets/send-later.less
Normal file
|
@ -0,0 +1,70 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.send-later {
|
||||
|
||||
.send-later-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 0;
|
||||
width: 250px;
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid @border-color-divider;
|
||||
margin: 10px 0;
|
||||
width: 90%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.send-later-section {
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
label {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
input {
|
||||
border: 1px solid @input-border;
|
||||
}
|
||||
.input-date-value {
|
||||
font-size: 0.9em;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.btn-send-later {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.send-later-option {
|
||||
cursor: default;
|
||||
width: 100%;
|
||||
padding: 1px 10px;
|
||||
|
||||
.item-date-value {
|
||||
display: none;
|
||||
float: right;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
&:hover {
|
||||
background-color: @background-secondary;
|
||||
.item-date-value {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.send-later-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
em {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.62;
|
||||
}
|
||||
img {
|
||||
width: 38px;
|
||||
margin-left: 15px;
|
||||
}
|
||||
}
|
|
@ -1,17 +1,10 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
classNames = require 'classnames'
|
||||
|
||||
{ListTabular,
|
||||
InjectedComponent,
|
||||
Flexbox} = require 'nylas-component-kit'
|
||||
|
||||
{timestamp,
|
||||
subject} = require './formatting-utils'
|
||||
|
||||
{Actions} = require 'nylas-exports'
|
||||
SendingProgressBar = require './sending-progress-bar'
|
||||
SendingCancelButton = require './sending-cancel-button'
|
||||
{InjectedComponentSet, ListTabular} = require 'nylas-component-kit'
|
||||
{subject} = require './formatting-utils'
|
||||
|
||||
|
||||
snippet = (html) =>
|
||||
return "" unless html and typeof(html) is 'string'
|
||||
|
@ -31,7 +24,7 @@ ParticipantsColumn = new ListTabular.Column
|
|||
|
||||
if list.length > 0
|
||||
<div className="participants">
|
||||
{list.map (p) => <span key={p.email}>{p.displayName()}</span>}
|
||||
<span>{list.map((p) => p.displayName()).join(', ')}</span>
|
||||
</div>
|
||||
else
|
||||
<div className="participants no-recipients">
|
||||
|
@ -51,16 +44,15 @@ ContentsColumn = new ListTabular.Column
|
|||
{attachments}
|
||||
</span>
|
||||
|
||||
SendStateColumn = new ListTabular.Column
|
||||
StatusColumn = new ListTabular.Column
|
||||
name: "State"
|
||||
resolver: (draft) =>
|
||||
if draft.uploadTaskId
|
||||
<Flexbox style={width:150, whiteSpace: 'no-wrap'}>
|
||||
<SendingProgressBar style={flex: 1, marginRight: 10} progress={draft.uploadProgress * 100} />
|
||||
<SendingCancelButton taskId={draft.uploadTaskId} />
|
||||
</Flexbox>
|
||||
else
|
||||
<span className="timestamp">{timestamp(draft.date)}</span>
|
||||
<InjectedComponentSet
|
||||
inline={true}
|
||||
containersRequired={false}
|
||||
matching={role: "DraftList:DraftStatus"}
|
||||
className="draft-list-injected-state"
|
||||
exposedProps={{draft}}/>
|
||||
|
||||
module.exports =
|
||||
Wide: [ParticipantsColumn, ContentsColumn, SendStateColumn]
|
||||
Wide: [ParticipantsColumn, ContentsColumn, StatusColumn]
|
||||
|
|
28
internal_packages/thread-list/lib/draft-list-send-status.jsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import React, {Component, PropTypes} from 'react'
|
||||
import {Flexbox} from 'nylas-component-kit'
|
||||
import {timestamp} from './formatting-utils'
|
||||
import SendingProgressBar from './sending-progress-bar'
|
||||
import SendingCancelButton from './sending-cancel-button'
|
||||
|
||||
export default class DraftListSendStatus extends Component {
|
||||
static displayName = 'DraftListSendStatus';
|
||||
|
||||
static propTypes = {
|
||||
draft: PropTypes.object,
|
||||
};
|
||||
|
||||
static containerRequired = false;
|
||||
|
||||
render() {
|
||||
const {draft} = this.props
|
||||
if (draft.uploadTaskId) {
|
||||
return (
|
||||
<Flexbox style={{width: 150, whiteSpace: 'no-wrap'}}>
|
||||
<SendingProgressBar style={{flex: 1, marginRight: 10}} progress={draft.uploadProgress * 100} />
|
||||
<SendingCancelButton taskId={draft.uploadTaskId} />
|
||||
</Flexbox>
|
||||
)
|
||||
}
|
||||
return <span className="timestamp">{timestamp(draft.date)}</span>
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ ThreadList = require './thread-list'
|
|||
|
||||
DraftSelectionBar = require './draft-selection-bar'
|
||||
DraftList = require './draft-list'
|
||||
DraftListSendStatus = require './draft-list-send-status'
|
||||
|
||||
module.exports =
|
||||
activate: (@state={}) ->
|
||||
|
@ -51,6 +52,9 @@ module.exports =
|
|||
ComponentRegistry.register DraftDeleteButton,
|
||||
role: 'draft:BulkAction'
|
||||
|
||||
ComponentRegistry.register DraftListSendStatus,
|
||||
role: 'DraftList:DraftStatus'
|
||||
|
||||
deactivate: ->
|
||||
ComponentRegistry.unregister DraftList
|
||||
ComponentRegistry.unregister DraftSelectionBar
|
||||
|
@ -62,3 +66,4 @@ module.exports =
|
|||
ComponentRegistry.unregister DownButton
|
||||
ComponentRegistry.unregister UpButton
|
||||
ComponentRegistry.unregister DraftDeleteButton
|
||||
ComponentRegistry.unregister DraftListSendStatus
|
||||
|
|
|
@ -18,7 +18,7 @@ class ThreadBulkArchiveButton extends React.Component
|
|||
|
||||
render: ->
|
||||
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||
return false unless mailboxPerspective?.canArchiveThreads()
|
||||
return false unless mailboxPerspective.canArchiveThreads()
|
||||
|
||||
<button style={order:-107}
|
||||
className="btn btn-toolbar"
|
||||
|
@ -43,7 +43,7 @@ class ThreadBulkTrashButton extends React.Component
|
|||
|
||||
render: ->
|
||||
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||
return false unless mailboxPerspective?.canTrashThreads()
|
||||
return false unless mailboxPerspective.canTrashThreads()
|
||||
|
||||
<button style={order:-106}
|
||||
className="btn btn-toolbar"
|
||||
|
|
|
@ -121,8 +121,19 @@ cNarrow = new ListTabular.Column
|
|||
if hasDraft
|
||||
pencil = <RetinaImg name="icon-draft-pencil.png" className="draft-icon" mode={RetinaImg.Mode.ContentPreserve} />
|
||||
|
||||
labels = []
|
||||
if AccountStore.accountForId(thread.accountId).usesLabels()
|
||||
currentCategories = FocusedPerspectiveStore.current().categories() ? []
|
||||
ignored = [].concat(currentCategories, CategoryStore.hiddenCategories(thread.accountId))
|
||||
ignoredIds = _.pluck(ignored, 'id')
|
||||
|
||||
for label in (thread.sortedCategories())
|
||||
continue if label.id in ignoredIds
|
||||
c3LabelComponentCache[label.id] ?= <MailLabel label={label} key={label.id} />
|
||||
labels.push c3LabelComponentCache[label.id]
|
||||
|
||||
<div>
|
||||
<div style={display: 'flex'}>
|
||||
<div style={display: 'flex', alignItems: 'center'}>
|
||||
<ThreadListIcon thread={thread} />
|
||||
<ThreadListParticipants thread={thread} />
|
||||
{pencil}
|
||||
|
@ -134,7 +145,11 @@ cNarrow = new ListTabular.Column
|
|||
thread={thread}
|
||||
showIfAvailableForAnyAccount={true} />
|
||||
<div className="subject">{subject(thread.subject)}</div>
|
||||
<div className="snippet">{thread.snippet}</div>
|
||||
<div className="snippet-and-labels">
|
||||
<div className="snippet">{thread.snippet} </div>
|
||||
<div style={flex: 1, flexShrink: 1}></div>
|
||||
{labels}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
module.exports =
|
||||
|
|
162
internal_packages/thread-list/lib/thread-list-context-menu.es6
Normal file
|
@ -0,0 +1,162 @@
|
|||
import _ from 'underscore'
|
||||
import {
|
||||
Thread,
|
||||
Actions,
|
||||
Message,
|
||||
TaskFactory,
|
||||
DatabaseStore,
|
||||
FocusedPerspectiveStore} from 'nylas-exports'
|
||||
|
||||
export default class ThreadListContextMenu {
|
||||
constructor({threadIds = [], accountIds = []}) {
|
||||
this.threadIds = threadIds
|
||||
this.accountIds = accountIds
|
||||
}
|
||||
|
||||
menuItemTemplate() {
|
||||
return DatabaseStore.modelify(Thread, this.threadIds)
|
||||
.then((threads) => {
|
||||
this.threads = threads;
|
||||
|
||||
return Promise.all([
|
||||
this.replyItem(),
|
||||
this.replyAllItem(),
|
||||
this.forwardItem(),
|
||||
{type: 'separator'},
|
||||
this.archiveItem(),
|
||||
this.trashItem(),
|
||||
this.markAsReadItem(),
|
||||
this.starItem(),
|
||||
// this.moveToOrLabelItem(),
|
||||
// {type: 'separator'},
|
||||
// this.extensionItems(),
|
||||
])
|
||||
}).then((menuItems) => {
|
||||
return _.filter(_.compact(menuItems), (item, index) => {
|
||||
if ((index === 0 || index === menuItems.length - 1) && item.type === "separator") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
replyItem() {
|
||||
if (this.threadIds.length !== 1) { return null }
|
||||
return {
|
||||
label: "Reply",
|
||||
click: () => {
|
||||
Actions.composeReply({threadId: this.threadIds[0], popout: true});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
replyAllItem() {
|
||||
if (this.threadIds.length !== 1) { return null }
|
||||
DatabaseStore.findBy(Message, {threadId: this.threadIds[0]})
|
||||
.order(Message.attributes.date.descending())
|
||||
.limit(1)
|
||||
.then((message) => {
|
||||
if (message && message.canReplyAll()) {
|
||||
return {
|
||||
label: "Reply All",
|
||||
click: () => {
|
||||
Actions.composeReplyAll({threadId: this.threadIds[0], popout: true});
|
||||
},
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
forwardItem() {
|
||||
if (this.threadIds.length !== 1) { return null }
|
||||
return {
|
||||
label: "Forward",
|
||||
click: () => {
|
||||
Actions.composeForward({threadId: this.threadIds[0], popout: true});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
archiveItem() {
|
||||
if (!FocusedPerspectiveStore.current().canArchiveThreads()) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
label: "Archive",
|
||||
click: () => {
|
||||
const tasks = TaskFactory.tasksForArchiving({
|
||||
threads: this.threads,
|
||||
fromPerspective: FocusedPerspectiveStore.current(),
|
||||
})
|
||||
Actions.queueTasks(tasks)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
trashItem() {
|
||||
if (!FocusedPerspectiveStore.current().canTrashThreads()) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
label: "Trash",
|
||||
click: () => {
|
||||
const tasks = TaskFactory.tasksForMovingToTrash({
|
||||
threads: this.threads,
|
||||
fromPerspective: FocusedPerspectiveStore.current(),
|
||||
})
|
||||
Actions.queueTasks(tasks)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
markAsReadItem() {
|
||||
const unread = _.every(this.threads, (t) => {
|
||||
return _.isMatch(t, {unread: false})
|
||||
});
|
||||
const dir = unread ? "Unread" : "Read"
|
||||
|
||||
return {
|
||||
label: `Mark as ${dir}`,
|
||||
click: () => {
|
||||
const task = TaskFactory.taskForInvertingUnread({
|
||||
threads: this.threads,
|
||||
})
|
||||
Actions.queueTask(task)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
starItem() {
|
||||
const starred = _.every(this.threads, (t) => {
|
||||
return _.isMatch(t, {starred: false})
|
||||
});
|
||||
|
||||
let dir = ""
|
||||
let star = "Star"
|
||||
if (!starred) {
|
||||
dir = "Remove "
|
||||
star = (this.threadIds.length > 1) ? "Stars" : "Star"
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
label: `${dir}${star}`,
|
||||
click: () => {
|
||||
const task = TaskFactory.taskForInvertingStarred({
|
||||
threads: this.threads,
|
||||
})
|
||||
Actions.queueTask(task)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
displayMenu() {
|
||||
const {remote} = require('electron')
|
||||
this.menuItemTemplate().then((template) => {
|
||||
remote.Menu.buildFromTemplate(template)
|
||||
.popup(remote.getCurrentWindow());
|
||||
});
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ class ThreadArchiveQuickAction extends React.Component
|
|||
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||
archive = null
|
||||
|
||||
if mailboxPerspective?.canArchiveThreads()
|
||||
if mailboxPerspective.canArchiveThreads()
|
||||
archive = <div key="archive"
|
||||
title="Archive"
|
||||
style={{ order: 100 }}
|
||||
|
@ -42,7 +42,7 @@ class ThreadTrashQuickAction extends React.Component
|
|||
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||
trash = null
|
||||
|
||||
if mailboxPerspective?.canTrashThreads()
|
||||
if mailboxPerspective.canTrashThreads()
|
||||
trash = <div key="remove"
|
||||
title="Trash"
|
||||
style={{ order: 110 }}
|
||||
|
|
|
@ -20,6 +20,7 @@ ThreadListScrollTooltip = require './thread-list-scroll-tooltip'
|
|||
ThreadListStore = require './thread-list-store'
|
||||
FocusContainer = require './focus-container'
|
||||
EmptyState = require './empty-state'
|
||||
ThreadListContextMenu = require './thread-list-context-menu'
|
||||
|
||||
|
||||
class ThreadList extends React.Component
|
||||
|
@ -36,10 +37,12 @@ class ThreadList extends React.Component
|
|||
|
||||
componentDidMount: =>
|
||||
window.addEventListener('resize', @_onResize, true)
|
||||
React.findDOMNode(@).addEventListener('contextmenu', @_onShowContextMenu)
|
||||
@_onResize()
|
||||
|
||||
componentWillUnmount: =>
|
||||
window.removeEventListener('resize', @_onResize, true)
|
||||
React.findDOMNode(@).removeEventListener('contextmenu', @_onShowContextMenu)
|
||||
|
||||
_shift: ({offset, afterRunning}) =>
|
||||
dataSource = ThreadListStore.dataSource()
|
||||
|
@ -71,10 +74,10 @@ class ThreadList extends React.Component
|
|||
render: ->
|
||||
if @state.style is 'wide'
|
||||
columns = ThreadListColumns.Wide
|
||||
itemHeight = 39
|
||||
itemHeight = 36
|
||||
else
|
||||
columns = ThreadListColumns.Narrow
|
||||
itemHeight = 90
|
||||
itemHeight = 85
|
||||
|
||||
<FluxContainer
|
||||
stores=[ThreadListStore]
|
||||
|
@ -96,34 +99,72 @@ class ThreadList extends React.Component
|
|||
</FluxContainer>
|
||||
|
||||
_threadPropsProvider: (item) ->
|
||||
className: classNames
|
||||
'unread': item.unread
|
||||
props =
|
||||
className: classNames
|
||||
'unread': item.unread
|
||||
|
||||
_onDragStart: (event) =>
|
||||
perspective = FocusedPerspectiveStore.current()
|
||||
account = AccountStore.accountForId(item.accountId)
|
||||
finishedName = account.defaultFinishedCategory()?.name
|
||||
|
||||
if finishedName is 'trash' and perspective.canTrashThreads()
|
||||
props.onSwipeRightClass = 'swipe-trash'
|
||||
props.onSwipeRight = (callback) ->
|
||||
tasks = TaskFactory.tasksForMovingToTrash
|
||||
threads: [item]
|
||||
fromPerspective: FocusedPerspectiveStore.current()
|
||||
Actions.queueTasks(tasks)
|
||||
callback(true)
|
||||
|
||||
else if finishedName in ['archive', 'all'] and perspective.canArchiveThreads()
|
||||
props.onSwipeRightClass = 'swipe-archive'
|
||||
props.onSwipeRight = (callback) ->
|
||||
tasks = TaskFactory.tasksForArchiving
|
||||
threads: [item]
|
||||
fromPerspective: FocusedPerspectiveStore.current()
|
||||
Actions.queueTasks(tasks)
|
||||
callback(true)
|
||||
|
||||
props
|
||||
|
||||
_targetItemsForMouseEvent: (event) ->
|
||||
itemThreadId = @refs.list.itemIdAtPoint(event.clientX, event.clientY)
|
||||
unless itemThreadId
|
||||
event.preventDefault()
|
||||
return
|
||||
return null
|
||||
|
||||
dataSource = ThreadListStore.dataSource()
|
||||
if itemThreadId in dataSource.selection.ids()
|
||||
dragThreadIds = dataSource.selection.ids()
|
||||
dragAccountIds = _.uniq(_.pluck(dataSource.selection.items(), 'accountId'))
|
||||
return {
|
||||
threadIds: dataSource.selection.ids()
|
||||
accountIds: _.uniq(_.pluck(dataSource.selection.items(), 'accountId'))
|
||||
}
|
||||
else
|
||||
dragThreadIds = [itemThreadId]
|
||||
dragAccountIds = [dataSource.getById(itemThreadId).accountId]
|
||||
thread = dataSource.getById(itemThreadId)
|
||||
return null unless thread
|
||||
return {
|
||||
threadIds: [thread.id]
|
||||
accountIds: [thread.accountId]
|
||||
}
|
||||
|
||||
dragData = {
|
||||
accountIds: dragAccountIds,
|
||||
threadIds: dragThreadIds
|
||||
}
|
||||
_onShowContextMenu: (event) =>
|
||||
data = @_targetItemsForMouseEvent(event)
|
||||
if not data
|
||||
event.preventDefault()
|
||||
return
|
||||
(new ThreadListContextMenu(data)).displayMenu()
|
||||
|
||||
_onDragStart: (event) =>
|
||||
data = @_targetItemsForMouseEvent(event)
|
||||
if not data
|
||||
event.preventDefault()
|
||||
return
|
||||
|
||||
event.dataTransfer.effectAllowed = "move"
|
||||
event.dataTransfer.dragEffect = "move"
|
||||
|
||||
canvas = CanvasUtils.canvasWithThreadDragImage(dragThreadIds.length)
|
||||
canvas = CanvasUtils.canvasWithThreadDragImage(data.threadIds.length)
|
||||
event.dataTransfer.setDragImage(canvas, 10, 10)
|
||||
event.dataTransfer.setData('nylas-threads-data', JSON.stringify(dragData))
|
||||
event.dataTransfer.setData('nylas-threads-data', JSON.stringify(data))
|
||||
return
|
||||
|
||||
_onDragEnd: (event) =>
|
||||
|
@ -153,44 +194,46 @@ class ThreadList extends React.Component
|
|||
_onSetImportant: (important) =>
|
||||
threads = @_threadsForKeyboardAction()
|
||||
return unless threads
|
||||
|
||||
# TODO Can not apply to threads across more than one account for now
|
||||
account = AccountStore.accountForItems(threads)
|
||||
return unless account?
|
||||
|
||||
return unless account.usesImportantFlag()
|
||||
return unless NylasEnv.config.get('core.workspace.showImportant')
|
||||
category = CategoryStore.getStandardCategory(account, 'important')
|
||||
if important
|
||||
task = TaskFactory.taskForApplyingCategory({threads, category})
|
||||
else
|
||||
task = TaskFactory.taskForRemovingCategory({threads, category})
|
||||
|
||||
Actions.queueTask(task)
|
||||
if important
|
||||
tasks = TaskFactory.tasksForApplyingCategories
|
||||
threads: threads
|
||||
categoriesToRemove: (accountId) -> []
|
||||
categoryToAdd: (accountId) ->
|
||||
CategoryStore.getStandardCategory(accountId, 'important')
|
||||
|
||||
else
|
||||
tasks = TaskFactory.tasksForApplyingCategories
|
||||
threads: threads
|
||||
categoriesToRemove: (accountId) ->
|
||||
important = CategoryStore.getStandardCategory(accountId, 'important')
|
||||
return [important] if important
|
||||
return []
|
||||
categoryToAdd: (accountId) -> null
|
||||
|
||||
Actions.queueTasks(tasks)
|
||||
|
||||
_onSetUnread: (unread) =>
|
||||
threads = @_threadsForKeyboardAction()
|
||||
return unless threads
|
||||
task = new ChangeUnreadTask
|
||||
threads: threads
|
||||
unread: unread
|
||||
Actions.queueTask(task)
|
||||
Actions.queueTask(new ChangeUnreadTask({threads, unread}))
|
||||
Actions.popSheet()
|
||||
|
||||
_onMarkAsSpam: =>
|
||||
threads = @_threadsForKeyboardAction()
|
||||
return unless threads
|
||||
tasks = TaskFactory.tasksForMarkingAsSpam(
|
||||
tasks = TaskFactory.tasksForMarkingAsSpam
|
||||
threads: threads
|
||||
)
|
||||
fromPerspective: FocusedPerspectiveStore.current()
|
||||
Actions.queueTasks(tasks)
|
||||
|
||||
_onRemoveFromView: =>
|
||||
threads = @_threadsForKeyboardAction()
|
||||
if threads
|
||||
current = FocusedPerspectiveStore.current()
|
||||
current.removeThreads(threads)
|
||||
Actions.popSheet()
|
||||
return unless threads
|
||||
current = FocusedPerspectiveStore.current()
|
||||
current.removeThreads(threads)
|
||||
Actions.popSheet()
|
||||
|
||||
_onArchiveItem: =>
|
||||
return unless FocusedPerspectiveStore.current().canArchiveThreads()
|
||||
|
|
|
@ -28,8 +28,8 @@
|
|||
.mail-label {
|
||||
// Note - these !important styles override values set by a style tag
|
||||
// since the color of the label is detemined programatically.
|
||||
background: none !important;
|
||||
box-shadow: 0 0.5px 0 @text-color-inverse, 0 -0.5px 0 @text-color-inverse, 0.5px 0 0 @text-color-inverse, -0.5px 0 0 @text-color-inverse !important;
|
||||
background: fade(@text-color-inverse, 20%) !important;
|
||||
box-shadow: none !important;
|
||||
-webkit-filter: brightness(600%) grayscale(100%);
|
||||
}
|
||||
|
||||
|
@ -48,16 +48,87 @@
|
|||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background-color: darken(@background-primary, 2%);
|
||||
.swipe-backing {
|
||||
background-color: darken(@background-primary, 10%);
|
||||
&::after {
|
||||
color: fade(white, 90%);
|
||||
padding-top: 45px;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
font-size: @font-size-small;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(0%);
|
||||
width: 80px;
|
||||
bottom: 0;
|
||||
opacity: 0.8;
|
||||
transition: opacity linear 150ms;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50% 35%;
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
&.swipe-trash {
|
||||
transition: background-color linear 150ms;
|
||||
background-color: mix(#ed304b, @background-primary, 75%);
|
||||
&::after {
|
||||
transition: left linear 150ms, transform linear 150ms;
|
||||
content: "Trash";
|
||||
left: 0;
|
||||
background-image: url(../static/images/swipe/icon-swipe-trash@2x.png);
|
||||
}
|
||||
&.confirmed {
|
||||
background-color: #ed304b;
|
||||
&::after {
|
||||
left: 100%;
|
||||
transform: translateX(-100%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.swipe-archive {
|
||||
transition: background-color linear 150ms;
|
||||
background-color: mix(#6cd420, @background-primary, 75%);
|
||||
&::after {
|
||||
transition: left linear 150ms, transform linear 150ms;
|
||||
content: "Archive";
|
||||
left: 0;
|
||||
background-image: url(../static/images/swipe/icon-swipe-archive@2x.png);
|
||||
}
|
||||
&.confirmed {
|
||||
background-color: #6cd420;
|
||||
&::after {
|
||||
left: 100%;
|
||||
transform: translateX(-100%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.swipe-snooze {
|
||||
background-color: mix(#8d6be3, @background-primary, 75%);
|
||||
&::after {
|
||||
content: "Snooze";
|
||||
left: 0;
|
||||
background-image: url(../static/images/swipe/icon-swipe-snooze@2x.png);
|
||||
}
|
||||
&.confirmed {
|
||||
background-color: #8d6be3;
|
||||
&::after {
|
||||
left: 100%;
|
||||
transform: translateX(-100%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-column {
|
||||
.list-item {
|
||||
background-color: darken(@background-primary, 2%);
|
||||
border-bottom: 1px solid fade(@list-border, 60%);
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.mail-important-icon {
|
||||
margin-top:1px;
|
||||
margin-left:6px;
|
||||
padding: 12px;
|
||||
vertical-align: initial;
|
||||
|
@ -85,8 +156,6 @@
|
|||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
top:2px;
|
||||
|
||||
&.no-recipients {
|
||||
color: @text-color-very-subtle;
|
||||
|
@ -95,14 +164,14 @@
|
|||
|
||||
.details {
|
||||
display:flex;
|
||||
align-items: center;
|
||||
|
||||
.subject {
|
||||
font-size: @font-size-small;
|
||||
font-weight: @font-weight-normal;
|
||||
padding-right: @padding-base-horizontal;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
top:2px;
|
||||
|
||||
// Shrink, but only after snippet has shrunk
|
||||
flex-shrink:0.1;
|
||||
|
@ -110,11 +179,9 @@
|
|||
.snippet {
|
||||
font-size: @font-size-small;
|
||||
font-weight: @font-weight-normal;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
opacity: 0.62;
|
||||
top:2px;
|
||||
flex: 1;
|
||||
}
|
||||
.thread-icon {
|
||||
|
@ -123,15 +190,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
.list-column-State {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: @font-size-small;
|
||||
font-weight: @font-weight-normal;
|
||||
position: relative;
|
||||
top:2px;
|
||||
text-align: right;
|
||||
min-width:70px;
|
||||
margin-right:@scrollbar-margin;
|
||||
display:inline-block;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
|
@ -146,9 +214,7 @@
|
|||
}
|
||||
|
||||
.unread:not(.focused):not(.selected):not(.next-is-selected) {
|
||||
.list-column {
|
||||
border-bottom: 1px solid @list-border;
|
||||
}
|
||||
border-bottom: 1px solid @list-border;
|
||||
}
|
||||
|
||||
.unread:not(.focused) {
|
||||
|
@ -171,11 +237,10 @@
|
|||
|
||||
.thread-injected-icons {
|
||||
vertical-align: top;
|
||||
line-height: 26px;
|
||||
}
|
||||
.thread-icon {
|
||||
width:26px;
|
||||
height:26px;
|
||||
width:25px;
|
||||
height:24px;
|
||||
flex-shrink:0;
|
||||
background-size: 15px;
|
||||
display:inline-block;
|
||||
|
@ -321,18 +386,24 @@ body.platform-win32 {
|
|||
}
|
||||
|
||||
.thread-list-narrow {
|
||||
.list-column {
|
||||
display:block;
|
||||
}
|
||||
.list-tabular-item {
|
||||
line-height: 21px;
|
||||
}
|
||||
.timestamp {
|
||||
order: 100;
|
||||
min-width: 0;
|
||||
}
|
||||
.participants {
|
||||
font-size: @font-size-base;
|
||||
}
|
||||
.thread-icon {
|
||||
margin-right:4px;
|
||||
margin-right:5px;
|
||||
}
|
||||
|
||||
.mail-important-icon {
|
||||
margin-top:1px;
|
||||
margin-left:1px;
|
||||
float:left;
|
||||
padding: 12px;
|
||||
|
@ -344,19 +415,30 @@ body.platform-win32 {
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
padding-top:2px;
|
||||
padding-bottom:2px;
|
||||
margin-left:30px;
|
||||
margin-right:@scrollbar-margin;
|
||||
}
|
||||
.snippet {
|
||||
font-size: @font-size-small;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.7;
|
||||
text-align: left;
|
||||
.snippet-and-labels {
|
||||
margin-left:30px;
|
||||
margin-right:@scrollbar-margin;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
|
||||
.mail-label {
|
||||
padding: 1px 8px;
|
||||
font-size: 0.8em;
|
||||
line-height: 17px;
|
||||
}
|
||||
.snippet {
|
||||
font-size: @font-size-small;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.7;
|
||||
text-align: left;
|
||||
min-height: 21px;
|
||||
margin-right:4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -388,10 +470,8 @@ body.is-blurred {
|
|||
.list-item {
|
||||
&.selected {
|
||||
background: fadeout(desaturate(@list-focused-bg, 100%), 65%);
|
||||
border-bottom: 1px solid fadeout(desaturate(@list-focused-border, 100%), 65%);
|
||||
color: @text-color;
|
||||
.list-column {
|
||||
border-bottom: 1px solid fadeout(desaturate(@list-focused-border, 100%), 65%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
0
internal_packages/thread-snooze/README.md
Normal file
After Width: | Height: | Size: 15 KiB |
57
internal_packages/thread-snooze/lib/components.jsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
/** @babel */
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import {RetinaImg} from 'nylas-component-kit';
|
||||
import SnoozePopover from './snooze-popover';
|
||||
|
||||
|
||||
const toolbarButton = (
|
||||
<button
|
||||
className="btn btn-toolbar btn-snooze"
|
||||
title="Snooze">
|
||||
<RetinaImg
|
||||
url="nylas://thread-snooze/assets/ic-toolbar-native-snooze@2x.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
)
|
||||
|
||||
const quickActionButton = (
|
||||
<div title="Snooze" className="btn action action-snooze" />
|
||||
)
|
||||
|
||||
|
||||
export class BulkThreadSnooze extends Component {
|
||||
static displayName = 'BulkThreadSnooze';
|
||||
|
||||
static propTypes = {
|
||||
selection: PropTypes.object,
|
||||
items: PropTypes.array,
|
||||
};
|
||||
|
||||
render() {
|
||||
return <SnoozePopover buttonComponent={toolbarButton} threads={this.props.items} />;
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolbarSnooze extends Component {
|
||||
static displayName = 'ToolbarSnooze';
|
||||
|
||||
static propTypes = {
|
||||
thread: PropTypes.object,
|
||||
};
|
||||
|
||||
render() {
|
||||
return <SnoozePopover buttonComponent={toolbarButton} threads={[this.props.thread]} />;
|
||||
}
|
||||
}
|
||||
|
||||
export class QuickActionSnooze extends Component {
|
||||
static displayName = 'QuickActionSnooze';
|
||||
|
||||
static propTypes = {
|
||||
thread: PropTypes.object,
|
||||
};
|
||||
|
||||
render() {
|
||||
return <SnoozePopover buttonComponent={quickActionButton} threads={[this.props.thread]} />;
|
||||
}
|
||||
}
|