Merge branch 'master' of github.com:nylas/N1

This commit is contained in:
Jackie Luo 2016-02-23 10:38:19 -08:00
commit 5ec5dfae93
196 changed files with 3712 additions and 1776 deletions

25
ISSUE_TEMPLATE.md Normal file
View 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?

View file

@ -6,6 +6,7 @@
[![Build Status](https://travis-ci.org/nylas/N1.svg?branch=master)](https://travis-ci.org/nylas/N1)
[![Slack Invite Button](http://slack-invite.nylas.com/badge.svg)](http://slack-invite.nylas.com)
[![GitHub issues On Deck](https://badge.waffle.io/nylas/N1.png?label=on deck&title=On Deck)](https://waffle.io/nylas/N1)
# Download N1
@ -33,6 +34,7 @@ We're working on building a plugin index that makes it super easy to add them to
- [Solarized Dark](https://github.com/NSHenry/N1-Solarized-Dark)
- [Berend](https://github.com/Frique/N1-Berend)
- [LevelUp](https://github.com/stolinski/level-up-nylas-n1-theme)
- [Darkside](http://jamiewilson.io/darkside/)
##### Composer
- [Translate](https://github.com/nylas/N1/tree/master/internal_packages/composer-translate) -- Works with 10 languages

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

@ -1 +1 @@
Subproject commit 8c4a86eb7ee1a06249a9ae35397e2084a09ad1dc
Subproject commit d5cfa779439aa7b67e7f805af63ae967ddf7628e

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 159 KiB

View file

@ -14,6 +14,7 @@ module.exports = (grunt) ->
'**/examples/**'
'**/src/tasks/**'
'**/node_modules/spellchecker/**'
'**/node_modules/windows-shortcuts/**'
]
unpack = "{#{unpack.join(',')}}"

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

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

View file

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

View file

@ -23,5 +23,5 @@
"default": true,
"composer": true
},
"license": "MIT"
"license": "GPL-3.0"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -8,6 +8,7 @@
}
.content-container {
height:185px;
width:170px;
overflow:scroll;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -50,6 +50,7 @@ describe 'ParticipantsTextField', ->
field={@fieldName}
tabIndex={@tabIndex}
visible={true}
draftReady={true}
participants={@participants}
change={@propChange} />
)

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

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

View file

@ -0,0 +1,4 @@
## Open Tracking
Adds tracking pixels to messages and tracks whether they have been opened.

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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

View file

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

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

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

View 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()
}

View 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"
}

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

View file

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

View file

@ -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>&nbsp;
<span className="participant-secondary" onClick={@_selectBracketedText}><{c.email}>{comma}</span>&nbsp;
<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>&nbsp;
<div className="participant-primary">
<span onClick={@_selectText}>{c.email}</span>{comma}
</div>
</div>
)

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
## Open Tracking
Adds tracking pixels to messages and tracks whether they have been opened.

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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()
}

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

View file

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

View 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>
);
}
}

View 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"
}

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -15,6 +15,6 @@
"engines": {
"nylas": ">=0.3.0 <0.5.0"
},
"dependencies": [],
"license": "MIT"
"dependencies": {},
"license": "GPL-3.0"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

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

View file

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

View file

@ -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]}`
);

View file

@ -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} &lt;${account.email}&gt;</span>

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -23,5 +23,5 @@
"default": true,
"composer": true
},
"license": "MIT"
"license": "GPL-3.0"
}

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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() {
}

View 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

View 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'

View 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

View 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 />
}
}

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

View 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"
}

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

View file

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

View 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>
}
}

View file

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

View file

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

View file

@ -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}&nbsp;</div>
<div style={flex: 1, flexShrink: 1}></div>
{labels}
</div>
</div>
module.exports =

View 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());
});
}
}

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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]} />;
}
}

Some files were not shown because too many files have changed in this diff Show more