add filters example

This commit is contained in:
dillon 2015-10-02 17:08:52 -07:00
parent 39266026d1
commit 0fa581e8bb
20 changed files with 1921 additions and 0 deletions

View file

@ -0,0 +1,42 @@
# Filters package for Edgehill
<img src="https://raw.githubusercontent.com/nylas/edgehill-plugins/master/filters/filters-screencap.png?token=ABx0UZ4A4Qd2ikF3y6kwHOK3MX_ZEf1lks5WEr6WwA%3D%3D">
## Who?
The source is annotated for people who are familiar with React, but not familiar with APIs from either Atom or N1.
As such, we will not annotate any code that is specific for React, but we'll annotate code for everything else.
## Why?
There's no native way to automate mail filtering in Edgehill. This package provides a lightweight interface and implementation of mail filters and mail rules to handle repetitive mail tasks for you.
## How?
This package works in two steps: managing the filters and applying the filters.
Managing the filters boils down to simple CRUD operations.
Applying the filters boils down to checking each incoming message, checking to see if the message matches any of the requirements for the filters, and, if there's a match, applying the actions on the thread.
Currently, this package supports only simple filter operations. The only criteria it supports are:
- exact match sender email
- exact match recipient email
- substring match with subject & body
- substring absense with subject & body
The only actions this package supports currently are:
- Marking as read
- Applying labels or folders
- Starring
- Deleting
- Archiving (skipping the inbox)
## Roadmap?
Right now, both managing the filters and applying the filters is done client-side.
The immediate objective is to implement an amazing user experience for managing mail filters.
The long-term objective is to remove the client-side implementation of applying filters and move this work to the backend.

View file

@ -0,0 +1,518 @@
/*--------------------- Typography ----------------------------*/
@font-face {
font-family: 'aller-light';
src: url('public/fonts/aller-light.eot');
src: url('public/fonts/aller-light.eot?#iefix') format('embedded-opentype'),
url('public/fonts/aller-light.woff') format('woff'),
url('public/fonts/aller-light.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'aller-bold';
src: url('public/fonts/aller-bold.eot');
src: url('public/fonts/aller-bold.eot?#iefix') format('embedded-opentype'),
url('public/fonts/aller-bold.woff') format('woff'),
url('public/fonts/aller-bold.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'roboto-black';
src: url('public/fonts/roboto-black.eot');
src: url('public/fonts/roboto-black.eot?#iefix') format('embedded-opentype'),
url('public/fonts/roboto-black.woff') format('woff'),
url('public/fonts/roboto-black.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
/*--------------------- Layout ----------------------------*/
html { height: 100%; }
body {
font-family: "aller-light";
font-size: 14px;
line-height: 18px;
color: #30404f;
margin: 0; padding: 0;
height:100%;
}
#container { min-height: 100%; }
a {
color: #000;
}
b, strong {
font-weight: normal;
font-family: "aller-bold";
}
p {
margin: 15px 0 0px;
}
.annotation ul, .annotation ol {
margin: 25px 0;
}
.annotation ul li, .annotation ol li {
font-size: 14px;
line-height: 18px;
margin: 10px 0;
}
h1, h2, h3, h4, h5, h6 {
color: #112233;
line-height: 1em;
font-weight: normal;
font-family: "roboto-black";
text-transform: uppercase;
margin: 30px 0 15px 0;
}
h1 {
margin-top: 40px;
}
h2 {
font-size: 1.26em;
}
hr {
border: 0;
background: 1px #ddd;
height: 1px;
margin: 20px 0;
}
pre, tt, code {
font-size: 12px; line-height: 16px;
font-family: Menlo, Monaco, Consolas, "Lucida Console", monospace;
margin: 0; padding: 0;
}
.annotation pre {
display: block;
margin: 0;
padding: 7px 10px;
background: #fcfcfc;
-moz-box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
-webkit-box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
overflow-x: auto;
}
.annotation pre code {
border: 0;
padding: 0;
background: transparent;
}
blockquote {
border-left: 5px solid #ccc;
margin: 0;
padding: 1px 0 1px 1em;
}
.sections blockquote p {
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 12px; line-height: 16px;
color: #999;
margin: 10px 0 0;
white-space: pre-wrap;
}
ul.sections {
list-style: none;
padding:0 0 5px 0;;
margin:0;
}
/*
Force border-box so that % widths fit the parent
container without overlap because of margin/padding.
More Info : http://www.quirksmode.org/css/box.html
*/
ul.sections > li > div {
-moz-box-sizing: border-box; /* firefox */
-ms-box-sizing: border-box; /* ie */
-webkit-box-sizing: border-box; /* webkit */
-khtml-box-sizing: border-box; /* konqueror */
box-sizing: border-box; /* css3 */
}
/*---------------------- Jump Page -----------------------------*/
#jump_to, #jump_page {
margin: 0;
background: white;
-webkit-box-shadow: 0 0 25px #777; -moz-box-shadow: 0 0 25px #777;
-webkit-border-bottom-left-radius: 5px; -moz-border-radius-bottomleft: 5px;
font: 16px Arial;
cursor: pointer;
text-align: right;
list-style: none;
}
#jump_to a {
text-decoration: none;
}
#jump_to a.large {
display: none;
}
#jump_to a.small {
font-size: 22px;
font-weight: bold;
color: #676767;
}
#jump_to, #jump_wrapper {
position: fixed;
right: 0; top: 0;
padding: 10px 15px;
margin:0;
}
#jump_wrapper {
display: none;
padding:0;
}
#jump_to:hover #jump_wrapper {
display: block;
}
#jump_page_wrapper{
position: fixed;
right: 0;
top: 0;
bottom: 0;
}
#jump_page {
padding: 5px 0 3px;
margin: 0 0 25px 25px;
max-height: 100%;
overflow: auto;
}
#jump_page .source {
display: block;
padding: 15px;
text-decoration: none;
border-top: 1px solid #eee;
}
#jump_page .source:hover {
background: #f5f5ff;
}
#jump_page .source:first-child {
}
/*---------------------- Low resolutions (> 320px) ---------------------*/
@media only screen and (min-width: 320px) {
.pilwrap { display: none; }
ul.sections > li > div {
display: block;
padding:5px 10px 0 10px;
}
ul.sections > li > div.annotation ul, ul.sections > li > div.annotation ol {
padding-left: 30px;
}
ul.sections > li > div.content {
overflow-x:auto;
-webkit-box-shadow: inset 0 0 5px #e5e5ee;
box-shadow: inset 0 0 5px #e5e5ee;
border: 1px solid #dedede;
margin:5px 10px 5px 10px;
padding-bottom: 5px;
}
ul.sections > li > div.annotation pre {
margin: 7px 0 7px;
padding-left: 15px;
}
ul.sections > li > div.annotation p tt, .annotation code {
background: #f8f8ff;
border: 1px solid #dedede;
font-size: 12px;
padding: 0 0.2em;
}
}
/*---------------------- (> 481px) ---------------------*/
@media only screen and (min-width: 481px) {
#container {
position: relative;
}
body {
background-color: #F5F5FF;
font-size: 15px;
line-height: 21px;
}
pre, tt, code {
line-height: 18px;
}
p, ul, ol {
margin: 0 0 15px;
}
#jump_to {
padding: 5px 10px;
}
#jump_wrapper {
padding: 0;
}
#jump_to, #jump_page {
font: 10px Arial;
text-transform: uppercase;
}
#jump_page .source {
padding: 5px 10px;
}
#jump_to a.large {
display: inline-block;
}
#jump_to a.small {
display: none;
}
#background {
position: absolute;
top: 0; bottom: 0;
width: 350px;
background: #fff;
border-right: 1px solid #e5e5ee;
z-index: -1;
}
ul.sections > li > div.annotation ul, ul.sections > li > div.annotation ol {
padding-left: 40px;
}
ul.sections > li {
white-space: nowrap;
}
ul.sections > li > div {
display: inline-block;
}
ul.sections > li > div.annotation {
max-width: 350px;
min-width: 350px;
min-height: 5px;
padding: 13px;
overflow-x: hidden;
white-space: normal;
vertical-align: top;
text-align: left;
}
ul.sections > li > div.annotation pre {
margin: 15px 0 15px;
padding-left: 15px;
}
ul.sections > li > div.content {
padding: 13px;
vertical-align: top;
border: none;
-webkit-box-shadow: none;
box-shadow: none;
}
.pilwrap {
position: relative;
display: inline;
}
.pilcrow {
font: 12px Arial;
text-decoration: none;
color: #454545;
position: absolute;
top: 3px; left: -20px;
padding: 1px 2px;
opacity: 0;
-webkit-transition: opacity 0.2s linear;
}
.for-h1 .pilcrow {
top: 47px;
}
.for-h2 .pilcrow, .for-h3 .pilcrow, .for-h4 .pilcrow {
top: 35px;
}
ul.sections > li > div.annotation:hover .pilcrow {
opacity: 1;
}
}
/*---------------------- (> 1025px) ---------------------*/
@media only screen and (min-width: 1025px) {
body {
font-size: 16px;
line-height: 24px;
}
#background {
width: 525px;
}
ul.sections > li > div.annotation {
max-width: 525px;
min-width: 525px;
padding: 10px 25px 1px 50px;
}
ul.sections > li > div.content {
padding: 9px 15px 16px 25px;
}
}
/*---------------------- Syntax Highlighting -----------------------------*/
td.linenos { background-color: #f0f0f0; padding-right: 10px; }
span.lineno { background-color: #f0f0f0; padding: 0 5px 0 5px; }
/*
github.com style (c) Vasily Polovnyov <vast@whiteants.net>
*/
pre code {
display: block; padding: 0.5em;
color: #000;
background: #f8f8ff
}
pre .hljs-comment,
pre .hljs-template_comment,
pre .hljs-diff .hljs-header,
pre .hljs-javadoc {
color: #408080;
font-style: italic
}
pre .hljs-keyword,
pre .hljs-assignment,
pre .hljs-literal,
pre .hljs-css .hljs-rule .hljs-keyword,
pre .hljs-winutils,
pre .hljs-javascript .hljs-title,
pre .hljs-lisp .hljs-title,
pre .hljs-subst {
color: #954121;
/*font-weight: bold*/
}
pre .hljs-number,
pre .hljs-hexcolor {
color: #40a070
}
pre .hljs-string,
pre .hljs-tag .hljs-value,
pre .hljs-phpdoc,
pre .hljs-tex .hljs-formula {
color: #219161;
}
pre .hljs-title,
pre .hljs-id {
color: #19469D;
}
pre .hljs-params {
color: #00F;
}
pre .hljs-javascript .hljs-title,
pre .hljs-lisp .hljs-title,
pre .hljs-subst {
font-weight: normal
}
pre .hljs-class .hljs-title,
pre .hljs-haskell .hljs-label,
pre .hljs-tex .hljs-command {
color: #458;
font-weight: bold
}
pre .hljs-tag,
pre .hljs-tag .hljs-title,
pre .hljs-rules .hljs-property,
pre .hljs-django .hljs-tag .hljs-keyword {
color: #000080;
font-weight: normal
}
pre .hljs-attribute,
pre .hljs-variable,
pre .hljs-instancevar,
pre .hljs-lisp .hljs-body {
color: #008080
}
pre .hljs-regexp {
color: #B68
}
pre .hljs-class {
color: #458;
font-weight: bold
}
pre .hljs-symbol,
pre .hljs-ruby .hljs-symbol .hljs-string,
pre .hljs-ruby .hljs-symbol .hljs-keyword,
pre .hljs-ruby .hljs-symbol .hljs-keymethods,
pre .hljs-lisp .hljs-keyword,
pre .hljs-tex .hljs-special,
pre .hljs-input_number {
color: #990073
}
pre .hljs-builtin,
pre .hljs-constructor,
pre .hljs-built_in,
pre .hljs-lisp .hljs-title {
color: #0086b3
}
pre .hljs-preprocessor,
pre .hljs-pi,
pre .hljs-doctype,
pre .hljs-shebang,
pre .hljs-cdata {
color: #999;
font-weight: bold
}
pre .hljs-deletion {
background: #fdd
}
pre .hljs-addition {
background: #dfd
}
pre .hljs-diff .hljs-change {
background: #0086b3
}
pre .hljs-chunk {
color: #aaa
}
pre .hljs-tex .hljs-formula {
opacity: 0.5;
}

View file

@ -0,0 +1,170 @@
<!DOCTYPE html>
<html>
<head>
<title>Filters</title>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, target-densitydpi=160dpi, initial-scale=1.0; maximum-scale=1.0; user-scalable=0;">
<link rel="stylesheet" media="all" href="docco.css" />
</head>
<body>
<div id="container">
<div id="background"></div>
<ul class="sections">
<li id="section-1">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-1">&#182;</a>
</div>
<h1 id="filters">Filters</h1>
<p>A way to apply filters, AKA mail rules, to incoming mail.</p>
</div>
<div class="content"><div class='highlight'><pre>
Filters = <span class="hljs-built_in">require</span> <span class="hljs-string">'./filters'</span></pre></div></div>
</li>
<li id="section-2">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-2">&#182;</a>
</div>
<p>Requiring nylas-exports is the way to access core N1 components.</p>
</div>
<div class="content"><div class='highlight'><pre>{WorkspaceStore, ComponentRegistry} = <span class="hljs-built_in">require</span> <span class="hljs-string">'nylas-exports'</span></pre></div></div>
</li>
<li id="section-3">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-3">&#182;</a>
</div>
<p>Your main.coffee (or main.cjsx) file needs to export an object for your
package to run.</p>
</div>
<div class="content"><div class='highlight'><pre><span class="hljs-built_in">module</span>.exports =</pre></div></div>
</li>
<li id="section-4">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-4">&#182;</a>
</div>
<p>When your package is loading, the <code>activate</code> method runs. <code>activate</code> is the
packages time to insert React components into the applicatio and also
listen to events.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-attribute">activate</span>: <span class="hljs-function">-&gt;</span></pre></div></div>
</li>
<li id="section-5">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-5">&#182;</a>
</div>
<p><code>WorkspaceStore.defineSheet</code> creates an N1 “sheet,” which is a large area
for you to inject React components. Sheets span the whole window.</p>
</div>
<div class="content"><div class='highlight'><pre> WorkspaceStore.defineSheet <span class="hljs-string">'Filters'</span>, {<span class="hljs-attribute">root</span>: <span class="hljs-literal">true</span>, <span class="hljs-attribute">name</span>: <span class="hljs-string">'Filters'</span>},
<span class="hljs-attribute">list</span>: [<span class="hljs-string">'RootSidebar'</span>, <span class="hljs-string">'Filters'</span>]</pre></div></div>
</li>
<li id="section-6">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-6">&#182;</a>
</div>
<p>Above, we named the sheet “Filters,” and were registering a React
component to live inside the “Filters” sheet.</p>
</div>
<div class="content"><div class='highlight'><pre> ComponentRegistry.register Filters,
<span class="hljs-attribute">location</span>: WorkspaceStore.Location.Filters</pre></div></div>
</li>
<li id="section-7">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-7">&#182;</a>
</div>
<p><code>WorkspaceStore.SidebarItem</code> is a React component which is meant to be
inserted into the navigation bar on the left of the main worksheet.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-property">@sidebarItem</span> = <span class="hljs-keyword">new</span> WorkspaceStore.SidebarItem
<span class="hljs-attribute">sheet</span>: WorkspaceStore.Sheet.Filters
<span class="hljs-attribute">id</span>: <span class="hljs-string">'Filters'</span>
<span class="hljs-attribute">name</span>: <span class="hljs-string">'Filters'</span></pre></div></div>
</li>
<li id="section-8">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-8">&#182;</a>
</div>
<p>And this is how we actually insert the SidebarItem into the sheet!</p>
</div>
<div class="content"><div class='highlight'><pre> WorkspaceStore.addSidebarItem(<span class="hljs-property">@sidebarItem</span>)</pre></div></div>
</li>
<li id="section-9">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-9">&#182;</a>
</div>
<p><code>deactivate</code> is called when packages are closing. Its a good time to
unregister React components.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-attribute">deactivate</span>: <span class="hljs-function">-&gt;</span>
ComponentRegistry.unregister Filters</pre></div></div>
</li>
</ul>
</div>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,375 @@
/*! normalize.css v2.0.1 | MIT License | git.io/normalize */
/* ==========================================================================
HTML5 display definitions
========================================================================== */
/*
* Corrects `block` display not defined in IE 8/9.
*/
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
nav,
section,
summary {
display: block;
}
/*
* Corrects `inline-block` display not defined in IE 8/9.
*/
audio,
canvas,
video {
display: inline-block;
}
/*
* Prevents modern browsers from displaying `audio` without controls.
* Remove excess height in iOS 5 devices.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/*
* Addresses styling for `hidden` attribute not present in IE 8/9.
*/
[hidden] {
display: none;
}
/* ==========================================================================
Base
========================================================================== */
/*
* 1. Sets default font family to sans-serif.
* 2. Prevents iOS text size adjust after orientation change, without disabling
* user zoom.
*/
html {
font-family: sans-serif; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-ms-text-size-adjust: 100%; /* 2 */
}
/*
* Removes default margin.
*/
body {
margin: 0;
}
/* ==========================================================================
Links
========================================================================== */
/*
* Addresses `outline` inconsistency between Chrome and other browsers.
*/
a:focus {
outline: thin dotted;
}
/*
* Improves readability when focused and also mouse hovered in all browsers.
*/
a:active,
a:hover {
outline: 0;
}
/* ==========================================================================
Typography
========================================================================== */
/*
* Addresses `h1` font sizes within `section` and `article` in Firefox 4+,
* Safari 5, and Chrome.
*/
h1 {
font-size: 2em;
}
/*
* Addresses styling not present in IE 8/9, Safari 5, and Chrome.
*/
abbr[title] {
border-bottom: 1px dotted;
}
/*
* Addresses style set to `bolder` in Firefox 4+, Safari 5, and Chrome.
*/
b,
strong {
font-weight: bold;
}
/*
* Addresses styling not present in Safari 5 and Chrome.
*/
dfn {
font-style: italic;
}
/*
* Addresses styling not present in IE 8/9.
*/
mark {
background: #ff0;
color: #000;
}
/*
* Corrects font family set oddly in Safari 5 and Chrome.
*/
code,
kbd,
pre,
samp {
font-family: monospace, serif;
font-size: 1em;
}
/*
* Improves readability of pre-formatted text in all browsers.
*/
pre {
white-space: pre;
white-space: pre-wrap;
word-wrap: break-word;
}
/*
* Sets consistent quote types.
*/
q {
quotes: "\201C" "\201D" "\2018" "\2019";
}
/*
* Addresses inconsistent and variable font size in all browsers.
*/
small {
font-size: 80%;
}
/*
* Prevents `sub` and `sup` affecting `line-height` in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -0.5em;
}
sub {
bottom: -0.25em;
}
/* ==========================================================================
Embedded content
========================================================================== */
/*
* Removes border when inside `a` element in IE 8/9.
*/
img {
border: 0;
}
/*
* Corrects overflow displayed oddly in IE 9.
*/
svg:not(:root) {
overflow: hidden;
}
/* ==========================================================================
Figures
========================================================================== */
/*
* Addresses margin not present in IE 8/9 and Safari 5.
*/
figure {
margin: 0;
}
/* ==========================================================================
Forms
========================================================================== */
/*
* Define consistent border, margin, and padding.
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
/*
* 1. Corrects color not being inherited in IE 8/9.
* 2. Remove padding so people aren't caught out if they zero out fieldsets.
*/
legend {
border: 0; /* 1 */
padding: 0; /* 2 */
}
/*
* 1. Corrects font family not being inherited in all browsers.
* 2. Corrects font size not being inherited in all browsers.
* 3. Addresses margins set differently in Firefox 4+, Safari 5, and Chrome
*/
button,
input,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 2 */
margin: 0; /* 3 */
}
/*
* Addresses Firefox 4+ setting `line-height` on `input` using `!important` in
* the UA stylesheet.
*/
button,
input {
line-height: normal;
}
/*
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
* and `video` controls.
* 2. Corrects inability to style clickable `input` types in iOS.
* 3. Improves usability and consistency of cursor style between image-type
* `input` and others.
*/
button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button; /* 2 */
cursor: pointer; /* 3 */
}
/*
* Re-set default cursor for disabled elements.
*/
button[disabled],
input[disabled] {
cursor: default;
}
/*
* 1. Addresses box sizing set to `content-box` in IE 8/9.
* 2. Removes excess padding in IE 8/9.
*/
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/*
* 1. Addresses `appearance` set to `searchfield` in Safari 5 and Chrome.
* 2. Addresses `box-sizing` set to `border-box` in Safari 5 and Chrome
* (include `-moz` to future-proof).
*/
input[type="search"] {
-webkit-appearance: textfield; /* 1 */
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box; /* 2 */
box-sizing: content-box;
}
/*
* Removes inner padding and search cancel button in Safari 5 and Chrome
* on OS X.
*/
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
* Removes inner padding and border in Firefox 4+.
*/
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0;
}
/*
* 1. Removes default vertical scrollbar in IE 8/9.
* 2. Improves readability and alignment in all browsers.
*/
textarea {
overflow: auto; /* 1 */
vertical-align: top; /* 2 */
}
/* ==========================================================================
Tables
========================================================================== */
/*
* Remove most spacing between table cells.
*/
table {
border-collapse: collapse;
border-spacing: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View file

@ -0,0 +1,186 @@
NylasStore = require 'nylas-store'
_ = require 'underscore'
_s = require 'underscore.string'
{Actions, CategoryStore, AccountStore, ChangeLabelsTask,
ChangeFolderTask, ArchiveThreadHelper, ChangeStarredTask,
ChangeUnreadTask, Utils} = require 'nylas-exports'
# The FiltersStore performs all business logic for filters: the single source
# of truth for any other code using filters, the gateway to persisting data
# for filters, the subscriber to Actions which affect filters, and the
# publisher for all React components which render filters.
class FiltersStore extends NylasStore
# The store's instantiation is the best time during the store life cycle
# to both set the store's initial state and also subscribe to Actions which
# will be published elsewhere.
constructor: ->
# ...here, we're setting initial state...
@_filters = @_loadFilters()
# ...and here, we're subscribing to Actions which could be fired by React
# components, other stores, or any other part of the application.
@listenTo Actions.deleteFilter, @_onDeleteFilter
@listenTo Actions.didPassivelyReceiveNewModels, @_onNewModels
@listenTo Actions.saveFilter, @_onSaveFilter
# This method is the application's single source of truth for filters.
# All FiltersStore consumers will invoke it to get the canonical filters at
# the present moment.
filters: =>
@_filters
# The callback for Action.deleteFilter. This action's publishers will pass to
# the callback a filter id for the filter to be deleted.
_onDeleteFilter: (id) =>
newFilters = @_filters.filter (f) ->
f.id isnt id
@_writeAndPublishChanges newFilters
# The callback for Action.saveFilter. This action's publishers will pass a
# filter object. If the published object contains an id, then we assume we're
# updating an existing filter. Otherwise, we assume we're creating a new one.
_onSaveFilter: (filter) =>
updatingExistingFilter = !!filter.id
if updatingExistingFilter
updatedFilter = _.find @_filters, (f) -> f.id is filter.id
index = _.indexOf @_filters, updatedFilter
@_filters[index] = filter
else
filter.id = Utils.generateTempId()
@_filters.push filter
@_writeAndPublishChanges @_filters
_writeAndPublishChanges: (filters) =>
@_saveFilters filters
@_filters = filters
# @trigger publishes to all React components subscribed to the FiltersStore.
# This tells the React components that the store's underlying data has
# changed. React components will update according to the new changes.
@trigger()
# For filters, an `action` is performed when an incoming message matches a
# filter's criteria. An action could be marking the message as read. These
# actions are just N1 `Task` instances which will be queued to run by
# `Actions.queueTask`.
_makeActions: (filters, thread) ->
_.chain filters
.pluck 'actions'
.map _.pairs
.flatten true
.map ([action, val]) ->
if action is "applyLabel"
label = _.find CategoryStore.getUserCategories(), (c) ->
c.id is val
new ChangeLabelsTask
labelsToAdd: [label]
threads: [thread]
else if action is "applyFolder"
folder = _.find CategoryStore.getUserCategories(), (c) ->
c.id is val
new ChangeFolderTask
folder: folder
threads: [thread]
else if action is "markAsRead" and val is true
new ChangeUnreadTask
unread: false
threads: [thread]
else if action is "archive" and val is true
ArchiveThreadHelper.getArchiveTask [thread]
else if action is "star" and val is true
new ChangeStarredTask
starred: true
threads: [thread]
else if action is "delete" and val is true
trash = CategoryStore.getStandardCategory "trash"
# Some email providers use labels, like Gmail, and others use folders,
# like Microsoft Exchange. Labels and folders behave very differently,
# so there are different Task classes to modify records for them.
if AccountStore.current().usesFolders()
new ChangeFolderTask
folder: trash
threads: [thread]
else
new ChangeLabelsTask
labelsToAdd: [trash]
threads: [thread]
.value()
_getPassedFilters: ({message, thread}) =>
@_filters.filter ({criteria}) ->
_.every criteria, (val, criterion) ->
if criterion is "from"
_.find message.from, (contact) -> contact.email is val
else if criterion is "to"
_.find message.to, (contact) -> contact.email is val
else if criterion is "subject"
_s.contains thread.subject, val
else if criterion is "has-words"
_s.contains(thread.subject, val) or _s.contains(message.body, val)
else if criterion is "doesnt-have"
not _s.contains(thread.subject, val) and
not _s.contains(message.body, val)
_getFilterActions: (incoming) =>
# The data structure representing all incoming models is a key-value hash
# with the model type as the key and an array of models as the value. Here,
# we're just accessing the models themselves from the `incoming` data
# structure.
message = incoming.message[0]
thread = incoming.thread[0]
passedFilters = @_getPassedFilters {message, thread}
@_makeActions passedFilters, thread
# The callback for Action.didPassivelyReceiveNewModels, a global action which
# is published every time the application receives new data from the server.
_onNewModels: (incoming) =>
# We ignore most incoming models, unless it's an incoming thread and
# message. Those are the only models which are relevant to filters.
if incoming.thread and incoming.message
actions = @_getFilterActions incoming
# Actions.queueTask will take N1 tasks, which we generically call
# `actions` in this method, and implement all remote-client syncing work.
# Actions.queueTask is N1's way of creating, updating, and deleting
# records in the backend while maintaining canonical data in the frontend.
Actions.queueTask(action) for action in actions
# The filters are stored in the config.cson file.
_loadFilters: =>
atom.config.get('filters') ? []
# Rewrite the filters to the config.cson file.
_saveFilters: (filters) =>
filters = @_trimFilters filters
if @_validateFilters(filters)
atom.config.set 'filters', filters
else
throw new Error("invalid filters")
# Prune the filters data for saving. We don't want to save malformed data!
_trimFilters: (filters) =>
for filter in filters
for attr in ["criteria", "actions"]
for key, val of filter[attr]
if not val
delete filter[attr][key]
return filters
# Simple validation to be run when _saveFilters is invoked.
_validateFilters: (filters) =>
Array.isArray(filters) and filters.every (f) ->
f.id? and f.criteria? and f.actions?
# A best practice is to export an instance of the FiltersStore, NOT the class!
module.exports = new FiltersStore()

View file

@ -0,0 +1,272 @@
React = require 'react'
_ = require 'underscore'
# Flux uses Stores to perform business logic and be the single source of truth
# for data to render in the application.
FiltersStore = require './filters-store'
{CategoryStore, Actions, Utils} = require 'nylas-exports'
# `RetinaImg` is a React component for optimistically rendering images for
# Retina displays but falling back on normal images if needed.
{RetinaImg} = require 'nylas-component-kit'
class Filters extends React.Component
# Having a `@displayName` is a React best practice to make debugging easier.
@displayName: 'Filters'
# Setting the component's initial state.
constructor: ->
@state = @_getStateFromStores()
# For Flux, we want the Stores to publish its changes to all subscribed React
# components. React components can subscribe to views in `componentDidMount`.
# When they receive new changes from stores, then the component's callbacks
# fire.
componentDidMount: =>
@_unsubscribers = []
@_unsubscribers.push FiltersStore.listen @_onFiltersChange
@_unsubscribers.push CategoryStore.listen @_onCategoriesChange
# Don't forget to unsubscribe from your stores on `componentWillUnmount`!
# If you don't, the callbacks will still exist, but the components that the
# callbacks are trying to update won't exist anymore. React will then throw
# an exception.
componentWillUnmount: =>
unsubscribe() for unsubscribe in @_unsubscribers
render: =>
if @state.focusedFilter
<div className="container-filters" style={padding: "0 15px"}>
{@_renderFocusedFilter @state.focusedFilter}
</div>
else
<div className="container-filters" style={padding: "0 15px"}>
{@state.filters.map @_renderFilter}
<div className="text-center" style={marginTop: 30}>
<button className="btn btn-large"
onClick={ => @_focus {} }>
Create a new filter
</button>
</div>
</div>
_renderFocusedFilter: ({criteria, actions}) =>
criteria ?= {}
actions ?= {}
# Gmail users have labels. Exchange users have Folders. They are implemented
# differently and frequently require different functionality when you're
# trying to manipulate either folders or labels.
if CategoryStore.categoryLabel() is "Labels"
applyCategory = <label>
<input type="checkbox"
checked={actions.applyLabel} />
Apply the label:
<select value={actions.applyLabel ? ""}
onChange={(e) => @_changeAttr "actions", "applyLabel", e.target.value}>
<option value="">Choose a label...</option>
{@state.categories.map (c) =>
<option value={c.id}>{c.displayName}</option>}
</select>
</label>
else
applyCategory = <label>
<input type="checkbox"
checked={actions.applyFolder} />
Apply the folder:
<select value={actions.applyFolder ? ""}
onChange={(e) => @_changeAttr "actions", "applyFolder", e.target.value}>
<option value="">Choose a folder...</option>
{@state.categories.map (c) =>
<option value={c.id}>{c.displayName}</option>}
</select>
</label>
<div>
<h4>Filter criteria:</h4>
<label className="filter-input-label">From:</label>
<div>
<input type="text"
className="filter-input"
value={criteria.from}
onChange={(e) => @_changeAttr "criteria", "from", e.target.value}
placeholder="sender@email.com" />
</div>
<div style={clear: "both"}></div>
<label className="filter-input-label">To:</label>
<div>
<input type="text"
className="filter-input"
value={criteria.to}
onChange={(e) => @_changeAttr "criteria", "to", e.target.value}
placeholder="recipient@email.com" />
</div>
<div style={clear: "both"}></div>
<label className="filter-input-label">Subject:</label>
<div>
<input type="text"
className="filter-input"
value={criteria.subject}
onChange={(e) => @_changeAttr "criteria", "subject", e.target.value}
placeholder="subject contains this phrase" />
</div>
<div style={clear: "both"}></div>
<label className="filter-input-label">Has words:</label>
<div>
<input type="text"
className="filter-input"
value={criteria["has-words"]}
onChange={(e) => @_changeAttr "criteria", "has-words", e.target.value}
placeholder="subject or body contains this phrase" />
</div>
<div style={clear: "both"}></div>
<label className="filter-input-label">Doesn't have:</label>
<div>
<input type="text"
className="filter-input"
value={criteria["doesnt-have"]}
onChange={(e) => @_changeAttr "criteria", "doesnt-have", e.target.value}
placeholder="subject and body don't contain this phrase" />
</div>
<div style={clear: "both"}></div>
<h4>When a message arrives that matches this search:</h4>
<div>
<label>
<input type="checkbox"
onChange={(e) => @_changeAttr "actions", "archive", e.target.checked}
checked={actions.archive} />
Skip the inbox (Archive it)
</label>
</div>
<div>
<label>
<input type="checkbox"
onChange={(e) => @_changeAttr "actions", "markAsRead", e.target.checked}
checked={actions.markAsRead} />
Mark as read
</label>
</div>
<div>
<label>
<input type="checkbox"
onChange={(e) => @_changeAttr "actions", "star", e.target.checked}
checked={actions.star} />
Star it
</label>
</div>
<div>
{applyCategory}
</div>
<div>
<label>
<input type="checkbox"
onChange={(e) => @_changeAttr "actions", "delete", e.target.checked}
checked={actions.delete} />
Delete it
</label>
</div>
<h1></h1>
<div>
<button className="btn pull-right"
onClick={@_save}>
Save filter
</button>
<button className="btn"
onClick={@_unfocus}>
Cancel
</button>
</div>
</div>
_renderFilter: (filter) =>
buttonStyles = paddingLeft: 15
lineItemStyles =
whiteSpace: "nowrap"
overflow: "auto"
<div className="filter-item" key={filter.id}>
<div>
<div className="pull-right action-button"
onClick={ => @_focus filter }>edit</div>
<div className="line-item">
<span>Matches: </span>
<strong>{@_criteriaDisplay filter.criteria}</strong>
</div>
</div>
<div>
<div className="pull-right action-button"
onClick={ => @_delete filter.id }>delete</div>
<div className="line-item">
<span>Do this: </span>
{@_actionsDisplay filter.actions}
</div>
</div>
</div>
_changeAttr: (attr1, attr2, val) =>
f = @state.focusedFilter
f[attr1] ?= {}
f[attr1][attr2] = val
@setState focusedFilter: f
_focus: (filter) =>
@setState focusedFilter: Utils.deepClone(filter)
_unfocus: =>
@setState focusedFilter: null
_delete: (id) =>
# React components trigger changes by firing Actions, which is simply
# invoking an Actions function with relevant data as arguments. Stores will
# listen to Actions and update themselves accordingly.
Actions.deleteFilter id;
_save: =>
Actions.saveFilter @state.focusedFilter
@_unfocus()
_criterionDisplay: (val, criterion) ->
"#{criterion}(#{val})"
_criteriaDisplay: (criteria) =>
_.map(criteria, @_criterionDisplay)
.join(" ")
_actionDisplay: (val, action) =>
if action is "applyLabel"
category = _.find @state.categories, (c) ->
c.id is val
"Apply label \"#{category.displayName}\""
else if action is "applyFolder"
category = _.find @state.categories, (c) ->
c.id is val
"Apply folder \"#{category.displayName}\""
else if action is "markAsRead" and val is true
"Mark as read"
else if action is "archive" and val is true
"Skip the inbox (Archive it)"
else if action is "star" and val is true
"Star it"
else if action is "delete" and val is true
"Delete it"
_actionsDisplay: (actions) =>
_.map(actions, @_actionDisplay)
.join(", ")
_getStateFromStores: =>
# A common N1 pattern is to dedicate a method solely to generating state,
# like right here. It's usually called by the constructor and by listener
# callbacks to Stores.
filters: FiltersStore.filters()
categories: CategoryStore.getUserCategories()
# Here's the callback that fires after Stores publish changes! `@setState`
# will trigger a render with new data.
_onFiltersChange: =>
@setState @_getStateFromStores()
module.exports = Filters

View file

@ -0,0 +1,272 @@
React = require 'react'
_ = require 'underscore'
# Flux uses Stores to perform business logic and be the single source of truth
# for data to render in the application.
FiltersStore = require './filters-store'
{CategoryStore, Actions, Utils} = require 'nylas-exports'
# `RetinaImg` is a React component for optimistically rendering images for
# Retina displays but falling back on normal images if needed.
{RetinaImg} = require 'nylas-component-kit'
class Filters extends React.Component
# Having a `@displayName` is a React best practice to make debugging easier.
@displayName: 'Filters'
# Setting the component's initial state.
constructor: ->
@state = @_getStateFromStores()
# For Flux, we want the Stores to publish its changes to all subscribed React
# components. React components can subscribe to views in `componentDidMount`.
# When they receive new changes from stores, then the component's callbacks
# fire.
componentDidMount: =>
@_unsubscribers = []
@_unsubscribers.push FiltersStore.listen @_onFiltersChange
@_unsubscribers.push CategoryStore.listen @_onCategoriesChange
# Don't forget to unsubscribe from your stores on `componentWillUnmount`!
# If you don't, the callbacks will still exist, but the components that the
# callbacks are trying to update won't exist anymore. React will then throw
# an exception.
componentWillUnmount: =>
unsubscribe() for unsubscribe in @_unsubscribers
render: =>
if @state.focusedFilter
React.createElement("div", {"className": "container-filters", "style": (padding: "0 15px")},
(@_renderFocusedFilter @state.focusedFilter)
)
else
React.createElement("div", {"className": "container-filters", "style": (padding: "0 15px")},
(@state.filters.map @_renderFilter),
React.createElement("div", {"className": "text-center", "style": (marginTop: 30)},
React.createElement("button", {"className": "btn btn-large", \
"onClick": ( => @_focus {} )}, """
Create a new filter
""")
)
)
_renderFocusedFilter: ({criteria, actions}) =>
criteria ?= {}
actions ?= {}
# Gmail users have labels. Exchange users have Folders. They are implemented
# differently and frequently require different functionality when you're
# trying to manipulate either folders or labels.
if CategoryStore.categoryLabel() is "Labels"
applyCategory = React.createElement("label", null,
React.createElement("input", {"type": "checkbox", \
"checked": (actions.applyLabel)}), """
Apply the label:
""", React.createElement("select", {"value": (actions.applyLabel ? ""), \
"onChange": ((e) => @_changeAttr "actions", "applyLabel", e.target.value)},
React.createElement("option", {"value": ""}, "Choose a label..."),
(@state.categories.map (c) =>
React.createElement("option", {"value": (c.id)}, (c.displayName)))
)
)
else
applyCategory = React.createElement("label", null,
React.createElement("input", {"type": "checkbox", \
"checked": (actions.applyFolder)}), """
Apply the folder:
""", React.createElement("select", {"value": (actions.applyFolder ? ""), \
"onChange": ((e) => @_changeAttr "actions", "applyFolder", e.target.value)},
React.createElement("option", {"value": ""}, "Choose a folder..."),
(@state.categories.map (c) =>
React.createElement("option", {"value": (c.id)}, (c.displayName)))
)
)
React.createElement("div", null,
React.createElement("h4", null, "Filter criteria:"),
React.createElement("label", {"className": "filter-input-label"}, "From:"),
React.createElement("div", null,
React.createElement("input", {"type": "text", \
"className": "filter-input", \
"value": (criteria.from), \
"onChange": ((e) => @_changeAttr "criteria", "from", e.target.value), \
"placeholder": "sender@email.com"})
),
React.createElement("div", {"style": (clear: "both")}),
React.createElement("label", {"className": "filter-input-label"}, "To:"),
React.createElement("div", null,
React.createElement("input", {"type": "text", \
"className": "filter-input", \
"value": (criteria.to), \
"onChange": ((e) => @_changeAttr "criteria", "to", e.target.value), \
"placeholder": "recipient@email.com"})
),
React.createElement("div", {"style": (clear: "both")}),
React.createElement("label", {"className": "filter-input-label"}, "Subject:"),
React.createElement("div", null,
React.createElement("input", {"type": "text", \
"className": "filter-input", \
"value": (criteria.subject), \
"onChange": ((e) => @_changeAttr "criteria", "subject", e.target.value), \
"placeholder": "subject contains this phrase"})
),
React.createElement("div", {"style": (clear: "both")}),
React.createElement("label", {"className": "filter-input-label"}, "Has words:"),
React.createElement("div", null,
React.createElement("input", {"type": "text", \
"className": "filter-input", \
"value": (criteria["has-words"]), \
"onChange": ((e) => @_changeAttr "criteria", "has-words", e.target.value), \
"placeholder": "subject or body contains this phrase"})
),
React.createElement("div", {"style": (clear: "both")}),
React.createElement("label", {"className": "filter-input-label"}, "Doesn\'t have:"),
React.createElement("div", null,
React.createElement("input", {"type": "text", \
"className": "filter-input", \
"value": (criteria["doesnt-have"]), \
"onChange": ((e) => @_changeAttr "criteria", "doesnt-have", e.target.value), \
"placeholder": "subject and body don't contain this phrase"})
),
React.createElement("div", {"style": (clear: "both")}),
React.createElement("h4", null, "When a message arrives that matches this search:"),
React.createElement("div", null,
React.createElement("label", null,
React.createElement("input", {"type": "checkbox", \
"onChange": ((e) => @_changeAttr "actions", "archive", e.target.checked), \
"checked": (actions.archive)}), """
Skip the inbox (Archive it)
""")
),
React.createElement("div", null,
React.createElement("label", null,
React.createElement("input", {"type": "checkbox", \
"onChange": ((e) => @_changeAttr "actions", "markAsRead", e.target.checked), \
"checked": (actions.markAsRead)}), """
Mark as read
""")
),
React.createElement("div", null,
React.createElement("label", null,
React.createElement("input", {"type": "checkbox", \
"onChange": ((e) => @_changeAttr "actions", "star", e.target.checked), \
"checked": (actions.star)}), """
Star it
""")
),
React.createElement("div", null,
(applyCategory)
),
React.createElement("div", null,
React.createElement("label", null,
React.createElement("input", {"type": "checkbox", \
"onChange": ((e) => @_changeAttr "actions", "delete", e.target.checked), \
"checked": (actions.delete)}), """
Delete it
""")
),
React.createElement("h1", null),
React.createElement("div", null,
React.createElement("button", {"className": "btn pull-right", \
"onClick": (@_save)}, """
Save filter
"""),
React.createElement("button", {"className": "btn", \
"onClick": (@_unfocus)}, """
Cancel
""")
)
)
_renderFilter: (filter) =>
buttonStyles = paddingLeft: 15
lineItemStyles =
whiteSpace: "nowrap"
overflow: "auto"
React.createElement("div", {"className": "filter-item", "key": (filter.id)},
React.createElement("div", null,
React.createElement("div", {"className": "pull-right action-button", \
"onClick": ( => @_focus filter )}, "edit"),
React.createElement("div", {"className": "line-item"},
React.createElement("span", null, "Matches: "),
React.createElement("strong", null, (@_criteriaDisplay filter.criteria))
)
),
React.createElement("div", null,
React.createElement("div", {"className": "pull-right action-button", \
"onClick": ( => @_delete filter.id )}, "delete"),
React.createElement("div", {"className": "line-item"},
React.createElement("span", null, "Do this: "),
(@_actionsDisplay filter.actions)
)
)
)
_changeAttr: (attr1, attr2, val) =>
f = @state.focusedFilter
f[attr1] ?= {}
f[attr1][attr2] = val
@setState focusedFilter: f
_focus: (filter) =>
@setState focusedFilter: Utils.deepClone(filter)
_unfocus: =>
@setState focusedFilter: null
_delete: (id) =>
# React components trigger changes by firing Actions, which is simply
# invoking an Actions function with relevant data as arguments. Stores will
# listen to Actions and update themselves accordingly.
Actions.deleteFilter id;
_save: =>
Actions.saveFilter @state.focusedFilter
@_unfocus()
_criterionDisplay: (val, criterion) ->
"#{criterion}(#{val})"
_criteriaDisplay: (criteria) =>
_.map(criteria, @_criterionDisplay)
.join(" ")
_actionDisplay: (val, action) =>
if action is "applyLabel"
category = _.find @state.categories, (c) ->
c.id is val
"Apply label \"#{category.displayName}\""
else if action is "applyFolder"
category = _.find @state.categories, (c) ->
c.id is val
"Apply folder \"#{category.displayName}\""
else if action is "markAsRead" and val is true
"Mark as read"
else if action is "archive" and val is true
"Skip the inbox (Archive it)"
else if action is "star" and val is true
"Star it"
else if action is "delete" and val is true
"Delete it"
_actionsDisplay: (actions) =>
_.map(actions, @_actionDisplay)
.join(", ")
_getStateFromStores: =>
# A common N1 pattern is to dedicate a method solely to generating state,
# like right here. It's usually called by the constructor and by listener
# callbacks to Stores.
filters: FiltersStore.filters()
categories: CategoryStore.getUserCategories()
# Here's the callback that fires after Stores publish changes! `@setState`
# will trigger a render with new data.
_onFiltersChange: =>
@setState @_getStateFromStores()
module.exports = Filters

View file

@ -0,0 +1,39 @@
# # Filters
#
# A way to apply filters, AKA mail rules, to incoming mail.
Filters = require './filters'
# Requiring 'nylas-exports' is the way to access core N1 components.
{WorkspaceStore, ComponentRegistry} = require 'nylas-exports'
# Your main.coffee (or main.cjsx) file needs to export an object for your
# package to run.
module.exports =
# When your package is loading, the `activate` method runs. `activate` is the
# package's time to insert React components into the applicatio and also
# listen to events.
activate: ->
# `WorkspaceStore.defineSheet` creates an N1 "sheet," which is a large area
# for you to inject React components. Sheets span the whole window.
WorkspaceStore.defineSheet 'Filters', {root: true, name: 'Filters'},
list: ['RootSidebar', 'Filters']
# Above, we named the sheet "Filters," and we're registering a React
# component to live inside the "Filters" sheet.
ComponentRegistry.register Filters,
location: WorkspaceStore.Location.Filters
# `WorkspaceStore.SidebarItem` is a React component which is meant to be
# inserted into the navigation bar on the left of the main worksheet.
@sidebarItem = new WorkspaceStore.SidebarItem
sheet: WorkspaceStore.Sheet.Filters
id: 'Filters'
name: 'Filters'
# And this is how we actually insert the SidebarItem into the sheet!
WorkspaceStore.addSidebarItem(@sidebarItem)
# `deactivate` is called when packages are closing. It's a good time to
# unregister React components.
deactivate: ->
ComponentRegistry.unregister Filters

View file

@ -0,0 +1,13 @@
{
"name": "preferences",
"version": "0.0.0",
"main": "./lib/main",
"description": "Mail filters, AKA mail rules",
"license": "Proprietary",
"private": true,
"engines": {
"atom": "#"
},
"dependencies": {
}
}

View file

@ -0,0 +1,34 @@
@import "ui-variables";
.container-filters {
.action-button {
padding-left: 15px;
}
.line-item {
white-space: nowrap;
overflow: auto;
}
.filter-item {
padding: 15px 0;
border-bottom: 1px solid @border-color;
}
.filter-input {
border-radius: @border-radius-base;
border: 1px solid @input-border;
margin-bottom: 15px;
float: left;
width: 75%;
}
.filter-input-label {
display: block;
float: left;
width: 25%;
text-align: right;
padding-right: 15px;
line-height: 27px;
}
}