es6(*): convert 20+ source files used in example packages to ES2016

There could be a few lurking bugs. Please test!
This commit is contained in:
Ben Gotow 2016-02-29 18:47:22 -08:00
parent 3508e7b9d7
commit 3fc6582718
53 changed files with 1434 additions and 1217 deletions

View file

@ -362,7 +362,7 @@ module.exports = (grunt) ->
['coffee', 'cjsx', 'babel', 'prebuild-less', 'cson', 'peg'])
grunt.registerTask('lint',
['coffeelint', 'csslint', 'lesslint', 'nylaslint', 'eslint'])
['eslint', 'lesslint', 'nylaslint', 'coffeelint', 'csslint'])
grunt.registerTask('test', ['shell:kill-n1', 'run-unit-tests'])

View file

@ -1,23 +0,0 @@
{PreferencesUIStore, ExtensionRegistry} = require 'nylas-exports'
SignatureComposerExtension = require './signature-composer-extension'
SignatureStore = require './signature-store'
module.exports =
activate: ->
@preferencesTab = new PreferencesUIStore.TabItem
tabId: "Signatures"
displayName: "Signatures"
component: require "./preferences-signatures"
ExtensionRegistry.Composer.register(SignatureComposerExtension)
PreferencesUIStore.registerPreferencesTab(@preferencesTab)
@signatureStore = new SignatureStore()
@signatureStore.activate()
deactivate: ->
ExtensionRegistry.Composer.unregister(SignatureComposerExtension)
PreferencesUIStore.unregisterPreferencesTab(@preferencesTab.sectionId)
@signatureStore.deactivate()
serialize: ->

View file

@ -0,0 +1,29 @@
import {PreferencesUIStore, ExtensionRegistry} from 'nylas-exports';
import SignatureComposerExtension from './signature-composer-extension';
import SignatureStore from './signature-store';
import PreferencesSignatures from "./preferences-signatures";
export function activate() {
this.preferencesTab = new PreferencesUIStore.TabItem({
tabId: "Signatures",
displayName: "Signatures",
component: PreferencesSignatures,
});
ExtensionRegistry.Composer.register(SignatureComposerExtension);
PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
this.signatureStore = new SignatureStore();
this.signatureStore.activate();
}
export function deactivate() {
ExtensionRegistry.Composer.unregister(SignatureComposerExtension);
PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab.sectionId);
this.signatureStore.deactivate();
}
export function serialize() {
return {};
}

View file

@ -1,12 +0,0 @@
{ComposerExtension, AccountStore} = require 'nylas-exports'
SignatureUtils = require './signature-utils'
class SignatureComposerExtension extends ComposerExtension
@prepareNewDraft: ({draft}) ->
accountId = draft.accountId
signature = NylasEnv.config.get("nylas.account-#{accountId}.signature")
return unless signature
draft.body = SignatureUtils.applySignature(draft.body, signature)
module.exports = SignatureComposerExtension

View file

@ -0,0 +1,13 @@
import {ComposerExtension} from 'nylas-exports';
import SignatureUtils from './signature-utils';
export default class SignatureComposerExtension extends ComposerExtension {
static prepareNewDraft = ({draft})=> {
const accountId = draft.accountId;
const signature = NylasEnv.config.get(`nylas.account-${accountId}.signature`);
if (!signature) {
return;
}
draft.body = SignatureUtils.applySignature(draft.body, signature);
}
}

View file

@ -1,8 +1,7 @@
import {DraftStore, AccountStore, Actions} from 'nylas-exports';
import SignatureUtils from './signature-utils';
class SignatureStore {
export default class SignatureStore {
constructor() {
this.unsubscribe = ()=> {};
@ -15,19 +14,16 @@ class SignatureStore {
onParticipantsChanged(draftClientId, changes) {
if (!changes.from) { return; }
DraftStore.sessionForClientId(draftClientId).then((session)=> {
const draft = session.draft()
const draft = session.draft();
const {accountId} = AccountStore.accountForEmail(changes.from[0].email);
const signature = NylasEnv.config.get(`nylas.account-${accountId}.signature`) || "";
const body = SignatureUtils.applySignature(draft.body, signature);
session.changes.add({body})
session.changes.add({body});
});
}
deactivate() {
this.unsubscribe()
this.unsubscribe();
}
}
export default SignatureStore;

View file

@ -1,29 +1,23 @@
const SignatureUtils = {
export default {
applySignature(body, signature) {
const signatureRegex = /<div class="nylas-n1-signature">.*<\/div>/;
let signatureHTML = '<div class="nylas-n1-signature">' + signature + '</div>';
let insertionPoint = body.search(signatureRegex)
let insertionPoint = body.search(signatureRegex);
let newBody = body;
// If there is a signature already present
if (insertionPoint !== -1) {
// Remove it
newBody = newBody.replace(signatureRegex, "")
newBody = newBody.replace(signatureRegex, "");
} else {
insertionPoint = newBody.indexOf('<blockquote');
if (insertionPoint === -1) {
insertionPoint = newBody.length
signatureHTML = '<br/><br/>' + signatureHTML
insertionPoint = newBody.length;
signatureHTML = '<br/><br/>' + signatureHTML;
}
}
return newBody.slice(0, insertionPoint) + signatureHTML + newBody.slice(insertionPoint)
return newBody.slice(0, insertionPoint) + signatureHTML + newBody.slice(insertionPoint);
},
}
export default SignatureUtils;
};

View file

@ -1,54 +0,0 @@
{Message} = require 'nylas-exports'
SignatureComposerExtension = require '../lib/signature-composer-extension'
describe "SignatureComposerExtension", ->
describe "prepareNewDraft", ->
describe "when a signature is defined", ->
beforeEach ->
@signature = '<div id="signature">This is my signature.</div>'
spyOn(NylasEnv.config, 'get').andCallFake =>
@signature
it "should insert the signature at the end of the message or before the first blockquote and have a newline", ->
a = new Message
draft: true
body: 'This is a test! <blockquote>Hello world</blockquote>'
b = new Message
draft: true
body: 'This is a another test.'
SignatureComposerExtension.prepareNewDraft(draft: a)
expect(a.body).toEqual('This is a test! <div class="nylas-n1-signature"><div id="signature">This is my signature.</div></div><blockquote>Hello world</blockquote>')
SignatureComposerExtension.prepareNewDraft(draft: b)
expect(b.body).toEqual('This is a another test.<br/><br/><div class="nylas-n1-signature"><div id="signature">This is my signature.</div></div>')
it "should replace the signature if a signature is already present", ->
a = new Message
draft: true
body: 'This is a test! <div class="nylas-n1-signature"><div>SIG</div></div><blockquote>Hello world</blockquote>'
b = new Message
draft: true
body: 'This is a test! <div class="nylas-n1-signature"><div>SIG</div></div>'
c = new Message
draft: true
body: 'This is a test! <div class="nylas-n1-signature"></div>'
SignatureComposerExtension.prepareNewDraft(draft: a)
expect(a.body).toEqual("This is a test! <div class=\"nylas-n1-signature\">#{@signature}</div><blockquote>Hello world</blockquote>")
SignatureComposerExtension.prepareNewDraft(draft: b)
expect(b.body).toEqual("This is a test! <div class=\"nylas-n1-signature\">#{@signature}</div>")
SignatureComposerExtension.prepareNewDraft(draft: c)
expect(c.body).toEqual("This is a test! <div class=\"nylas-n1-signature\">#{@signature}</div>")
describe "when a signature is not defined", ->
beforeEach ->
spyOn(NylasEnv.config, 'get').andCallFake ->
null
it "should not do anything", ->
a = new Message
draft: true
body: 'This is a test! <blockquote>Hello world</blockquote>'
SignatureComposerExtension.prepareNewDraft(draft: a)
expect(a.body).toEqual('This is a test! <blockquote>Hello world</blockquote>')

View file

@ -0,0 +1,65 @@
import {Message} from 'nylas-exports';
import SignatureComposerExtension from '../lib/signature-composer-extension';
describe("SignatureComposerExtension", ()=> {
describe("prepareNewDraft", ()=> {
describe("when a signature is defined", ()=> {
beforeEach(()=> {
this.signature = '<div id="signature">This is my signature.</div>';
spyOn(NylasEnv.config, 'get').andCallFake(()=> this.signature);
});
it("should insert the signature at the end of the message or before the first blockquote and have a newline", ()=> {
const a = new Message({
draft: true,
body: 'This is a test! <blockquote>Hello world</blockquote>',
});
const b = new Message({
draft: true,
body: 'This is a another test.',
});
SignatureComposerExtension.prepareNewDraft({draft: a});
expect(a.body).toEqual('This is a test! <div class="nylas-n1-signature"><div id="signature">This is my signature.</div></div><blockquote>Hello world</blockquote>');
SignatureComposerExtension.prepareNewDraft({draft: b});
expect(b.body).toEqual('This is a another test.<br/><br/><div class="nylas-n1-signature"><div id="signature">This is my signature.</div></div>');
});
it("should replace the signature if a signature is already present", ()=> {
const a = new Message({
draft: true,
body: 'This is a test! <div class="nylas-n1-signature"><div>SIG</div></div><blockquote>Hello world</blockquote>',
})
const b = new Message({
draft: true,
body: 'This is a test! <div class="nylas-n1-signature"><div>SIG</div></div>',
})
const c = new Message({
draft: true,
body: 'This is a test! <div class="nylas-n1-signature"></div>',
})
SignatureComposerExtension.prepareNewDraft({draft: a});
expect(a.body).toEqual(`This is a test! <div class="nylas-n1-signature">${this.signature}</div><blockquote>Hello world</blockquote>`);
SignatureComposerExtension.prepareNewDraft({draft: b});
expect(b.body).toEqual(`This is a test! <div class="nylas-n1-signature">${this.signature}</div>`);
SignatureComposerExtension.prepareNewDraft({draft: c});
expect(c.body).toEqual(`This is a test! <div class="nylas-n1-signature">${this.signature}</div>`);
});
});
describe("when a signature is not defined", ()=> {
beforeEach(()=> {
spyOn(NylasEnv.config, 'get').andCallFake(()=> null);
});
it("should not do anything", ()=> {
const a = new Message({
draft: true,
body: 'This is a test! <blockquote>Hello world</blockquote>',
});
SignatureComposerExtension.prepareNewDraft({draft: a});
expect(a.body).toEqual('This is a test! <blockquote>Hello world</blockquote>');
});
});
});
});

View file

@ -1,11 +0,0 @@
{ExtensionRegistry} = require 'nylas-exports'
SpellcheckComposerExtension = require './spellcheck-composer-extension'
module.exports =
activate: (@state={}) ->
ExtensionRegistry.Composer.register(SpellcheckComposerExtension)
deactivate: ->
ExtensionRegistry.Composer.unregister(SpellcheckComposerExtension)
serialize: -> @state

View file

@ -0,0 +1,10 @@
import {ExtensionRegistry} from 'nylas-exports';
import SpellcheckComposerExtension from './spellcheck-composer-extension';
export function activate() {
ExtensionRegistry.Composer.register(SpellcheckComposerExtension);
}
export function deactivate() {
ExtensionRegistry.Composer.unregister(SpellcheckComposerExtension);
}

View file

@ -1,160 +0,0 @@
{ComposerExtension, AccountStore, DOMUtils, NylasSpellchecker} = require 'nylas-exports'
_ = require 'underscore'
{remote} = require('electron')
MenuItem = remote.require('menu-item')
spellchecker = NylasSpellchecker
SpellcheckCache = {}
class SpellcheckComposerExtension extends ComposerExtension
@isMisspelled: (word) ->
SpellcheckCache[word] ?= spellchecker.isMisspelled(word)
SpellcheckCache[word]
@onContentChanged: ({editor}) =>
@update(editor)
@onShowContextMenu: ({editor, event, menu}) =>
selection = editor.currentSelection()
range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0)
word = range.toString()
if @isMisspelled(word)
corrections = spellchecker.getCorrectionsForMisspelling(word)
if corrections.length > 0
corrections.forEach (correction) =>
menu.append(new MenuItem({
label: correction,
click: @applyCorrection.bind(@, editor, range, selection, correction)
}))
else
menu.append(new MenuItem({ label: 'No Guesses Found', enabled: false}))
menu.append(new MenuItem({ type: 'separator' }))
menu.append(new MenuItem({
label: 'Learn Spelling',
click: @learnSpelling.bind(@, editor, word)
}))
menu.append(new MenuItem({ type: 'separator' }))
@applyCorrection: (editor, range, selection, correction) =>
DOMUtils.Mutating.applyTextInRange(range, selection, correction)
@update(editor)
@learnSpelling: (editor, word) =>
spellchecker.add(word)
delete SpellcheckCache[word]
@update(editor)
@update: (editor) =>
@_unwrapWords(editor)
@_wrapMisspelledWords(editor)
# Creates a shallow copy of a selection object where anchorNode / focusNode
# can be changed, and provides it to the callback provided. After the callback
# runs, it applies the new selection if `snapshot.modified` has been set.
#
# Note: This is different from ExposedSelection because the nodes are not cloned.
# In the callback functions, we need to check whether the anchor/focus nodes
# are INSIDE the nodes we're adjusting.
#
@_whileApplyingSelectionChanges: (cb) =>
selection = document.getSelection()
selectionSnapshot =
anchorNode: selection.anchorNode
anchorOffset: selection.anchorOffset
focusNode: selection.focusNode
focusOffset: selection.focusOffset
modified: false
cb(selectionSnapshot)
if selectionSnapshot.modified
selection.setBaseAndExtent(selectionSnapshot.anchorNode, selectionSnapshot.anchorOffset, selectionSnapshot.focusNode, selectionSnapshot.focusOffset)
# Removes all of the <spelling> nodes found in the provided `editor`.
# It normalizes the DOM after removing spelling nodes to ensure that words
# are not split between text nodes. (ie: doesn, 't => doesn't)
@_unwrapWords: (editor) =>
@_whileApplyingSelectionChanges (selectionSnapshot) =>
spellingNodes = editor.rootNode.querySelectorAll('spelling')
for node in spellingNodes
if selectionSnapshot.anchorNode is node
selectionSnapshot.anchorNode = node.firstChild
if selectionSnapshot.focusNode is node
selectionSnapshot.focusNode = node.firstChild
selectionSnapshot.modified = true
node.parentNode.insertBefore(node.firstChild, node) while (node.firstChild)
node.parentNode.removeChild(node)
editor.rootNode.normalize()
# Traverses all of the text nodes within the provided `editor`. If it finds a
# text node with a misspelled word, it splits it, wraps the misspelled word
# with a <spelling> node and updates the selection to account for the change.
@_wrapMisspelledWords: (editor) =>
@_whileApplyingSelectionChanges (selectionSnapshot) =>
treeWalker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT)
nodeList = []
nodeMisspellingsFound = 0
while (treeWalker.nextNode())
nodeList.push(treeWalker.currentNode)
# Note: As a performance optimization, we stop spellchecking after encountering
# 30 misspelled words. This keeps the runtime of this method bounded!
while (node = nodeList.shift())
break if nodeMisspellingsFound > 30
str = node.textContent
# https://regex101.com/r/bG5yC4/1
wordRegexp = /(\w[\w'-]*\w|\w)/g
while ((match = wordRegexp.exec(str)) isnt null)
break if nodeMisspellingsFound > 30
misspelled = @isMisspelled(match[0])
if misspelled
# The insertion point is currently at the end of this misspelled word.
# Do not mark it until the user types a space or leaves.
if selectionSnapshot.focusNode is node and selectionSnapshot.focusOffset is match.index + match[0].length
continue
if match.index is 0
matchNode = node
else
matchNode = node.splitText(match.index)
afterMatchNode = matchNode.splitText(match[0].length)
spellingSpan = document.createElement('spelling')
spellingSpan.classList.add('misspelled')
spellingSpan.innerText = match[0]
matchNode.parentNode.replaceChild(spellingSpan, matchNode)
for prop in ['anchor', 'focus']
if selectionSnapshot["#{prop}Node"] is node
if selectionSnapshot["#{prop}Offset"] > match.index + match[0].length
selectionSnapshot.modified = true
selectionSnapshot["#{prop}Node"] = afterMatchNode
selectionSnapshot["#{prop}Offset"] -= match.index + match[0].length
else if selectionSnapshot["#{prop}Offset"] > match.index
selectionSnapshot.modified = true
selectionSnapshot["#{prop}Node"] = spellingSpan.childNodes[0]
selectionSnapshot["#{prop}Offset"] -= match.index
nodeMisspellingsFound += 1
nodeList.unshift(afterMatchNode)
break
@finalizeSessionBeforeSending: ({session}) ->
body = session.draft().body
clean = body.replace(/<\/?spelling[^>]*>/g, '')
if body != clean
return session.changes.add(body: clean)
SpellcheckComposerExtension.SpellcheckCache = SpellcheckCache
module.exports = SpellcheckComposerExtension

View file

@ -0,0 +1,193 @@
import {DOMUtils, ComposerExtension, NylasSpellchecker} from 'nylas-exports';
import {remote} from 'electron';
const MenuItem = remote.require('menu-item');
const SpellcheckCache = {};
export default class SpellcheckComposerExtension extends ComposerExtension {
static isMisspelled(word) {
if (SpellcheckCache[word] === undefined) {
SpellcheckCache[word] = NylasSpellchecker.isMisspelled(word);
}
return SpellcheckCache[word];
}
static onContentChanged({editor}) {
SpellcheckComposerExtension.update(editor);
}
static onShowContextMenu = ({editor, menu})=> {
const selection = editor.currentSelection();
const range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0);
const word = range.toString();
if (SpellcheckComposerExtension.isMisspelled(word)) {
const corrections = NylasSpellchecker.getCorrectionsForMisspelling(word);
if (corrections.length > 0) {
corrections.forEach((correction)=> {
menu.append(new MenuItem({
label: correction,
click: SpellcheckComposerExtension.applyCorrection.bind(SpellcheckComposerExtension, editor, range, selection, correction),
}));
});
} else {
menu.append(new MenuItem({ label: 'No Guesses Found', enabled: false}));
}
menu.append(new MenuItem({ type: 'separator' }));
menu.append(new MenuItem({
label: 'Learn Spelling',
click: SpellcheckComposerExtension.learnSpelling.bind(SpellcheckComposerExtension, editor, word),
}));
menu.append(new MenuItem({ type: 'separator' }));
}
}
static applyCorrection = (editor, range, selection, correction)=> {
DOMUtils.Mutating.applyTextInRange(range, selection, correction);
SpellcheckComposerExtension.update(editor);
}
static learnSpelling = (editor, word)=> {
NylasSpellchecker.add(word);
delete SpellcheckCache[word];
SpellcheckComposerExtension.update(editor);
}
static update = (editor) => {
SpellcheckComposerExtension._unwrapWords(editor);
SpellcheckComposerExtension._wrapMisspelledWords(editor);
}
// Creates a shallow copy of a selection object where anchorNode / focusNode
// can be changed, and provides it to the callback provided. After the callback
// runs, it applies the new selection if `snapshot.modified` has been set.
// Note: This is different from ExposedSelection because the nodes are not cloned.
// In the callback functions, we need to check whether the anchor/focus nodes
// are INSIDE the nodes we're adjusting.
static _whileApplyingSelectionChanges = (cb)=> {
const selection = document.getSelection();
const selectionSnapshot = {
anchorNode: selection.anchorNode,
anchorOffset: selection.anchorOffset,
focusNode: selection.focusNode,
focusOffset: selection.focusOffset,
modified: false,
};
cb(selectionSnapshot);
if (selectionSnapshot.modified) {
selection.setBaseAndExtent(selectionSnapshot.anchorNode, selectionSnapshot.anchorOffset, selectionSnapshot.focusNode, selectionSnapshot.focusOffset);
}
}
// Removes all of the <spelling> nodes found in the provided `editor`.
// It normalizes the DOM after removing spelling nodes to ensure that words
// are not split between text nodes. (ie: doesn, 't => doesn't)
static _unwrapWords = (editor)=> {
SpellcheckComposerExtension._whileApplyingSelectionChanges((selectionSnapshot)=> {
const spellingNodes = editor.rootNode.querySelectorAll('spelling');
for (let ii = 0; ii < spellingNodes.length; ii++) {
const node = spellingNodes[ii];
if (selectionSnapshot.anchorNode === node) {
selectionSnapshot.anchorNode = node.firstChild;
}
if (selectionSnapshot.focusNode === node) {
selectionSnapshot.focusNode = node.firstChild;
}
selectionSnapshot.modified = true;
while (node.firstChild) {
node.parentNode.insertBefore(node.firstChild, node);
}
node.parentNode.removeChild(node);
}
});
editor.rootNode.normalize();
}
// Traverses all of the text nodes within the provided `editor`. If it finds a
// text node with a misspelled word, it splits it, wraps the misspelled word
// with a <spelling> node and updates the selection to account for the change.
static _wrapMisspelledWords = (editor)=> {
SpellcheckComposerExtension._whileApplyingSelectionChanges((selectionSnapshot)=> {
const treeWalker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT);
const nodeList = [];
while (treeWalker.nextNode()) {
nodeList.push(treeWalker.currentNode);
}
// Note: As a performance optimization, we stop spellchecking after encountering
// 30 misspelled words. This keeps the runtime of this method bounded!
let nodeMisspellingsFound = 0;
while (true) {
const node = nodeList.shift();
if ((node === undefined) || (nodeMisspellingsFound > 30)) {
break;
}
const nodeContent = node.textContent;
const nodeWordRegexp = /(\w[\w'-]*\w|\w)/g; // https://regex101.com/r/bG5yC4/1
while (true) {
const match = nodeWordRegexp.exec(nodeContent);
if ((match === null) || (nodeMisspellingsFound > 30)) {
break;
}
if (SpellcheckComposerExtension.isMisspelled(match[0])) {
// The insertion point is currently at the end of this misspelled word.
// Do not mark it until the user types a space or leaves.
if ((selectionSnapshot.focusNode === node) && (selectionSnapshot.focusOffset === match.index + match[0].length)) {
continue;
}
const matchNode = (match.index === 0) ? node : node.splitText(match.index);
const afterMatchNode = matchNode.splitText(match[0].length);
const spellingSpan = document.createElement('spelling');
spellingSpan.classList.add('misspelled');
spellingSpan.innerText = match[0];
matchNode.parentNode.replaceChild(spellingSpan, matchNode);
for (const prop of ['anchor', 'focus']) {
if (selectionSnapshot[`${prop}Node`] === node) {
if (selectionSnapshot[`${prop}Offset`] > match.index + match[0].length) {
selectionSnapshot[`${prop}Node`] = afterMatchNode;
selectionSnapshot[`${prop}Offset`] -= match.index + match[0].length;
selectionSnapshot.modified = true;
} else if (selectionSnapshot[`${prop}Offset`] > match.index) {
selectionSnapshot[`${prop}Node`] = spellingSpan.childNodes[0];
selectionSnapshot[`${prop}Offset`] -= match.index;
selectionSnapshot.modified = true;
}
}
}
nodeMisspellingsFound += 1;
nodeList.unshift(afterMatchNode);
break;
}
}
}
});
}
static finalizeSessionBeforeSending = ({session})=> {
const body = session.draft().body;
const clean = body.replace(/<\/?spelling[^>]*>/g, '');
if (body !== clean) {
return session.changes.add({body: clean});
} else {
return Promise.resolve();
}
}
}
SpellcheckComposerExtension.SpellcheckCache = SpellcheckCache;

View file

@ -1,39 +0,0 @@
SpellcheckComposerExtension = require '../lib/spellcheck-composer-extension'
fs = require 'fs'
_ = require 'underscore'
initialHTML = fs.readFileSync(__dirname + '/fixtures/california-with-misspellings-before.html').toString()
expectedHTML = fs.readFileSync(__dirname + '/fixtures/california-with-misspellings-after.html').toString()
describe "SpellcheckComposerExtension", ->
beforeEach ->
# Avoid differences between node-spellcheck on different platforms
spellings = JSON.parse(fs.readFileSync(__dirname + '/fixtures/california-spelling-lookup.json'))
spyOn(SpellcheckComposerExtension, 'isMisspelled').andCallFake (word) ->
spellings[word]
describe "update", ->
it "correctly walks a DOM tree and surrounds mispelled words", ->
dom = document.createElement('div')
dom.innerHTML = initialHTML
editor =
rootNode: dom
whilePreservingSelection: (cb) -> cb()
SpellcheckComposerExtension.update(editor)
expect(dom.innerHTML).toEqual(expectedHTML)
describe "finalizeSessionBeforeSending", ->
it "removes the annotations it inserted", ->
session =
draft: ->
body: expectedHTML
changes:
add: jasmine.createSpy('add').andReturn Promise.resolve()
waitsForPromise ->
SpellcheckComposerExtension.finalizeSessionBeforeSending({session}).then ->
expect(session.changes.add).toHaveBeenCalledWith(body: initialHTML)
module.exports = SpellcheckComposerExtension

View file

@ -0,0 +1,55 @@
/* global waitsForPromise */
import fs from 'fs';
import path from 'path';
import SpellcheckComposerExtension from '../lib/spellcheck-composer-extension';
const initialHTML = fs.readFileSync(path.join(__dirname, 'fixtures', 'california-with-misspellings-before.html')).toString();
const expectedHTML = fs.readFileSync(path.join(__dirname, 'fixtures', 'california-with-misspellings-after.html')).toString();
describe("SpellcheckComposerExtension", ()=> {
beforeEach(()=> {
// Avoid differences between node-spellcheck on different platforms
const spellings = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'california-spelling-lookup.json')));
spyOn(SpellcheckComposerExtension, 'isMisspelled').andCallFake(word=> spellings[word])
});
describe("update", ()=> {
it("correctly walks a DOM tree and surrounds mispelled words", ()=> {
const node = document.createElement('div');
node.innerHTML = initialHTML;
const editor = {
rootNode: node,
whilePreservingSelection: (cb)=> {
return cb();
},
};
SpellcheckComposerExtension.update(editor);
expect(node.innerHTML).toEqual(expectedHTML);
});
});
describe("finalizeSessionBeforeSending", ()=> {
it("removes the annotations it inserted", ()=> {
const session = {
draft: ()=> {
return {
body: expectedHTML,
};
},
changes: {
add: jasmine.createSpy('add').andReturn(Promise.resolve()),
},
};
waitsForPromise(()=> {
return SpellcheckComposerExtension.finalizeSessionBeforeSending({session}).then(()=> {
expect(session.changes.add).toHaveBeenCalledWith({body: initialHTML});
});
});
});
});
});

View file

@ -3,26 +3,26 @@ import TemplatePicker from './template-picker';
import TemplateStatusBar from './template-status-bar';
import TemplateComposerExtension from './template-composer-extension';
module.exports = {
activate(state = {}) {
this.state = state;
this.preferencesTab = new PreferencesUIStore.TabItem({
tabId: 'Quick Replies',
displayName: 'Quick Replies',
component: require('./preferences-templates'),
});
ComponentRegistry.register(TemplatePicker, {role: 'Composer:ActionButton'});
ComponentRegistry.register(TemplateStatusBar, {role: 'Composer:Footer'});
PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
ExtensionRegistry.Composer.register(TemplateComposerExtension);
},
export function activate(state = {}) {
this.state = state;
this.preferencesTab = new PreferencesUIStore.TabItem({
tabId: 'Quick Replies',
displayName: 'Quick Replies',
component: require('./preferences-templates'),
});
ComponentRegistry.register(TemplatePicker, {role: 'Composer:ActionButton'});
ComponentRegistry.register(TemplateStatusBar, {role: 'Composer:Footer'});
PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
ExtensionRegistry.Composer.register(TemplateComposerExtension);
}
deactivate() {
ComponentRegistry.unregister(TemplatePicker);
ComponentRegistry.unregister(TemplateStatusBar);
PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab.tabId);
ExtensionRegistry.Composer.unregister(TemplateComposerExtension);
},
export function deactivate() {
ComponentRegistry.unregister(TemplatePicker);
ComponentRegistry.unregister(TemplateStatusBar);
PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab.tabId);
ExtensionRegistry.Composer.unregister(TemplateComposerExtension);
}
serialize() { return this.state; },
};
export function serialize() {
return this.state;
}

View file

@ -1,52 +0,0 @@
{DOMUtils, ContenteditableExtension} = require 'nylas-exports'
class TemplateEditor extends ContenteditableExtension
@onContentChanged: ({editor}) ->
# Run through and remove all code nodes that are invalid
codeNodes = editor.rootNode.querySelectorAll("code.var.empty")
for codeNode in codeNodes
# remove any style that was added by contenteditable
codeNode.removeAttribute("style")
# grab the text content and the indexable text content
text = codeNode.textContent
indexText = DOMUtils.getIndexedTextContent(codeNode).map( ({text}) -> text ).join("")
# unwrap any code nodes that don't start/end with {{}}, and any with line breaks inside
if not text.startsWith("{{") or not text.endsWith("}}") or indexText.indexOf("\n")>-1
editor.whilePreservingSelection ->
DOMUtils.unwrapNode(codeNode)
# Attempt to sanitize extra nodes that may have been created by contenteditable on certain text editing
# operations (insertion/deletion of line breaks, etc.). These are generally <span>, but can also be
# <font>, <b>, and possibly others. The extra nodes often grab CSS styles from neighboring elements
# as inline style, including the yellow text from <code> nodes that we insert. This is contenteditable
# trying to be "smart" and preserve styles, which is very undesirable for the <code> node styles. The
# below code is a hack to prevent yellow text from appearing.
for node in editor.rootNode.querySelectorAll("*")
if not node.className and node.style.color == "#c79b11"
editor.whilePreservingSelection ->
DOMUtils.unwrapNode(node)
for node in editor.rootNode.querySelectorAll("font")
if node.color == "#c79b11"
editor.whilePreservingSelection ->
DOMUtils.unwrapNode(node)
# Find all {{}} and wrap them in code nodes if they aren't already
# Regex finds any {{ <contents> }} that doesn't contain {, }, or \n
# https://regex101.com/r/jF2oF4/1
ranges = editor.regExpSelectorAll(/\{\{[^\n{}]*?\}\}/g)
for range in ranges
if not DOMUtils.isWrapped(range, "CODE")
# Preserve the selection based on text index within the range matched by the regex
selIndex = editor.getSelectionTextIndex(range)
codeNode = DOMUtils.wrap(range,"CODE")
codeNode.className = "var empty"
codeNode.textContent = codeNode.textContent # Sets node contents to just its textContent, strips HTML
if selIndex?
editor.restoreSelectionByTextIndex(codeNode, selIndex.startIndex, selIndex.endIndex)
module.exports = TemplateEditor

View file

@ -0,0 +1,73 @@
import {DOMUtils, ContenteditableExtension} from 'nylas-exports';
export default class TemplateEditor extends ContenteditableExtension {
static onContentChanged = ({editor})=> {
// Run through and remove all code nodes that are invalid
const codeNodes = editor.rootNode.querySelectorAll("code.var.empty");
for (let ii = 0; ii < codeNodes.length; ii++) {
const codeNode = codeNodes[ii];
// remove any style that was added by contenteditable
codeNode.removeAttribute("style");
// grab the text content and the indexable text content
const codeNodeText = codeNode.textContent;
const indexText = DOMUtils.getIndexedTextContent(codeNode).map(({text})=> text).join("");
// unwrap any code nodes that don't start/end with {{}}, and any with line breaks inside
if ((!codeNodeText.startsWith("{{")) || (!codeNodeText.endsWith("}}")) || (indexText.indexOf("\n") > -1)) {
editor.whilePreservingSelection(()=> {
DOMUtils.unwrapNode(codeNode);
});
}
}
// Attempt to sanitize extra nodes that may have been created by contenteditable on certain text editing
// operations (insertion/deletion of line breaks, etc.). These are generally <span>, but can also be
// <font>, <b>, and possibly others. The extra nodes often grab CSS styles from neighboring elements
// as inline style, including the yellow text from <code> nodes that we insert. This is contenteditable
// trying to be "smart" and preserve styles, which is very undesirable for the <code> node styles. The
// below code is a hack to prevent yellow text from appearing.
const starNodes = editor.rootNode.querySelectorAll("*");
for (let ii = 0; ii < starNodes.length; ii++) {
const node = starNodes[ii];
if ((!node.className) && (node.style.color === "#c79b11")) {
editor.whilePreservingSelection(()=> {
DOMUtils.unwrapNode(node);
});
}
}
const fontNodes = editor.rootNode.querySelectorAll("font");
for (let ii = 0; ii < fontNodes.length; ii++) {
const node = fontNodes[ii];
if (node.color === "#c79b11") {
editor.whilePreservingSelection(()=> {
DOMUtils.unwrapNode(node);
});
}
}
// Find all {{}} and wrap them in code nodes if they aren't already
// Regex finds any {{ <contents> }} that doesn't contain {, }, or \n
// https://regex101.com/r/jF2oF4/1
for (const range of editor.regExpSelectorAll(/\{\{[^\n{}]*?\}\}/g)) {
if (!DOMUtils.isWrapped(range, "CODE")) {
// Preserve the selection based on text index within the range matched by the regex
const selIndex = editor.getSelectionTextIndex(range);
const codeNode = DOMUtils.wrap(range, "CODE");
codeNode.className = "var empty";
// Sets node contents to just its textContent, strips HTML
codeNode.textContent = codeNode.textContent;
if (selIndex !== undefined) {
editor.restoreSelectionByTextIndex(codeNode, selIndex.startIndex, selIndex.endIndex);
}
}
}
}
}
module.exports = TemplateEditor

View file

@ -1,143 +0,0 @@
# # Translation Plugin
# Last Revised: April 23, 2015 by Ben Gotow
#
# TranslateButton is a simple React component that allows you to select
# a language from a popup menu and translates draft text into that language.
#
request = require 'request'
{React,
ComponentRegistry,
QuotedHTMLTransformer,
DraftStore} = require 'nylas-exports'
{Menu,
RetinaImg,
Popover} = require 'nylas-component-kit'
YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate'
YandexTranslationKey = 'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e'
YandexLanguages =
'English': 'en'
'Spanish': 'es'
'Russian': 'ru'
'Chinese': 'zh'
'French': 'fr'
'German': 'de'
'Italian': 'it'
'Japanese': 'ja'
'Portuguese': 'pt'
'Korean': 'ko'
class TranslateButton extends React.Component
# Adding a `displayName` makes debugging React easier
@displayName: 'TranslateButton'
# Since our button is being injected into the Composer Footer,
# we receive the local id of the current draft as a `prop` (a read-only
# property). Since our code depends on this prop, we mark it as a requirement.
#
@propTypes:
draftClientId: React.PropTypes.string.isRequired
# The `render` method returns a React Virtual DOM element. This code looks
# like HTML, but don't be fooled. The CJSX preprocessor converts
#
# `<a href="http://facebook.github.io/react/">Hello!</a>`
#
# into Javascript objects which describe the HTML you want:
#
# `React.createElement('a', {href: 'http://facebook.github.io/react/'}, 'Hello!')`
#
# We're rendering a `Popover` with a `Menu` inside. These components are part
# of N1's standard `nylas-component-kit` library, and make it easy to build
# interfaces that match the rest of N1's UI.
#
render: =>
headerComponents = [
<span>Translate:</span>
]
<Popover ref="popover"
className="translate-language-picker pull-right"
buttonComponent={@_renderButton()}>
<Menu items={ Object.keys(YandexLanguages) }
itemKey={ (item) -> item }
itemContent={ (item) -> item }
headerComponents={headerComponents}
defaultSelectedIndex={-1}
onSelect={@_onTranslate}
/>
</Popover>
# Helper method to render the button that will activate the popover. Using the
# `RetinaImg` component makes it easy to display an image from our package.
# `RetinaImg` will automatically chose the best image format for our display.
#
_renderButton: =>
<button className="btn btn-toolbar" title="Translate email body…">
<RetinaImg mode={RetinaImg.Mode.ContentIsMask} url="nylas://composer-translate/assets/icon-composer-translate@2x.png" />
&nbsp;
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
_onTranslate: (lang) =>
@refs.popover.close()
# Obtain the session for the current draft. The draft session provides us
# the draft object and also manages saving changes to the local cache and
# Nilas API as multiple parts of the application touch the draft.
#
session = DraftStore.sessionForClientId(@props.draftClientId).then (session) =>
draftHtml = session.draft().body
text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml)
query =
key: YandexTranslationKey
lang: YandexLanguages[lang]
text: text
format: 'html'
# Use Node's `request` library to perform the translation using the Yandex API.
request {url: YandexTranslationURL, qs: query}, (error, resp, data) =>
return @_onError(error) unless resp.statusCode is 200
json = JSON.parse(data)
# The new text of the draft is our translated response, plus any quoted text
# that we didn't process.
translated = json.text.join('')
translated = QuotedHTMLTransformer.appendQuotedHTML(translated, draftHtml)
# To update the draft, we add the new body to it's session. The session object
# automatically marshalls changes to the database and ensures that others accessing
# the same draft are notified of changes.
session.changes.add(body: translated)
session.changes.commit()
_onError: (error) =>
@refs.popover.close()
dialog = require('remote').require('dialog')
dialog.showErrorBox('Language Conversion Failed', error.toString())
module.exports =
# Activate is called when the package is loaded. If your package previously
# saved state using `serialize` it is provided.
#
activate: (@state) ->
ComponentRegistry.register TranslateButton,
role: 'Composer:ActionButton'
# Serialize is called when your package is about to be unmounted.
# You can return a state object that will be passed back to your package
# when it is re-activated.
#
serialize: ->
# This **optional** method is called when the window is shutting down,
# or when your package is being updated or disabled. If your package is
# watching any files, holding external resources, providing commands or
# subscribing to events, release them here.
#
deactivate: ->
ComponentRegistry.unregister(TranslateButton)

View file

@ -0,0 +1,174 @@
// // Translation Plugin
// Last Revised: Feb. 29, 2016 by Ben Gotow
// TranslateButton is a simple React component that allows you to select
// a language from a popup menu and translates draft text into that language.
import request from 'request'
import {
React,
ComponentRegistry,
QuotedHTMLTransformer,
DraftStore,
} from 'nylas-exports';
import {
Menu,
RetinaImg,
Popover,
} from 'nylas-component-kit';
const YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate';
const YandexTranslationKey = 'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e';
const YandexLanguages = {
'English': 'en',
'Spanish': 'es',
'Russian': 'ru',
'Chinese': 'zh',
'French': 'fr',
'German': 'de',
'Italian': 'it',
'Japanese': 'ja',
'Portuguese': 'pt',
'Korean': 'ko',
};
class TranslateButton extends React.Component {
// Adding a `displayName` makes debugging React easier
static displayName = 'TranslateButton'
// Since our button is being injected into the Composer Footer,
// we receive the local id of the current draft as a `prop` (a read-only
// property). Since our code depends on this prop, we mark it as a requirement.
static propTypes = {
draftClientId: React.PropTypes.string.isRequired,
}
_onError(error) {
this.refs.popover.close();
const dialog = require('remote').require('dialog');
dialog.showErrorBox('Language Conversion Failed', error.toString());
}
_onTranslate = (lang) => {
this.refs.popover.close();
// Obtain the session for the current draft. The draft session provides us
// the draft object and also manages saving changes to the local cache and
// Nilas API as multiple parts of the application touch the draft.
DraftStore.sessionForClientId(this.props.draftClientId).then((session)=> {
const draftHtml = session.draft().body;
const text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml);
const query = {
key: YandexTranslationKey,
lang: YandexLanguages[lang],
text: text,
format: 'html',
};
// Use Node's `request` library to perform the translation using the Yandex API.
request({url: YandexTranslationURL, qs: query}, (error, resp, data)=> {
if (resp.statusCode !== 200) {
this._onError(error);
return;
}
const json = JSON.parse(data);
let translated = json.text.join('');
// The new text of the draft is our translated response, plus any quoted text
// that we didn't process.
translated = QuotedHTMLTransformer.appendQuotedHTML(translated, draftHtml);
// To update the draft, we add the new body to it's session. The session object
// automatically marshalls changes to the database and ensures that others accessing
// the same draft are notified of changes.
session.changes.add({body: translated});
session.changes.commit();
});
});
}
// Helper method to render the button that will activate the popover. Using the
// `RetinaImg` component makes it easy to display an image from our package.
// `RetinaImg` will automatically chose the best image format for our display.
_renderButton() {
return (
<button
className="btn btn-toolbar"
title="Translate email body…">
<RetinaImg
mode={RetinaImg.Mode.ContentIsMask}
url="nylas://composer-translate/assets/icon-composer-translate@2x.png" />
&nbsp;
<RetinaImg
name="icon-composer-dropdown.png"
mode={RetinaImg.Mode.ContentIsMask}/>
</button>
)
}
// The `render` method returns a React Virtual DOM element. This code looks
// like HTML, but don't be fooled. The CJSX preprocessor converts
// `<a href="http://facebook.github.io/react/">Hello!</a>`
// into Javascript objects which describe the HTML you want:
// `React.createElement('a', {href: 'http://facebook.github.io/react/'}, 'Hello!')`
// We're rendering a `Popover` with a `Menu` inside. These components are part
// of N1's standard `nylas-component-kit` library, and make it easy to build
// interfaces that match the rest of N1's UI.
render() {
const headerComponents = [
<span>Translate:</span>,
];
return (
<Popover ref="popover"
className="translate-language-picker pull-right"
buttonComponent={this._renderButton()}>
<Menu items={ Object.keys(YandexLanguages) }
itemKey={ (item)=> item }
itemContent={ (item)=> item }
headerComponents={headerComponents}
defaultSelectedIndex={-1}
onSelect={this._onTranslate}
/>
</Popover>
);
}
}
/*
All packages must export a basic object that has at least the following 3
methods:
1. `activate` - Actions to take once the package gets turned on.
Pre-enabled packages get activated on N1 bootup. They can also be
activated manually by a user.
2. `deactivate` - Actions to take when a package gets turned off. This can
happen when a user manually disables a package.
3. `serialize` - A simple serializable object that gets saved to disk
before N1 quits. This gets passed back into `activate` next time N1 boots
up or your package is manually activated.
*/
export function activate() {
ComponentRegistry.register(TranslateButton, {
role: 'Composer:ActionButton',
});
}
export function serialize() {
}
export function deactivate() {
ComponentRegistry.unregister(TranslateButton);
}

View file

@ -1,76 +0,0 @@
_ = require 'underscore'
GithubUserStore = require "./github-user-store"
{React} = require 'nylas-exports'
# Small React component that renders a single Github repository
class GithubRepo extends React.Component
@displayName: 'GithubRepo'
@propTypes:
# This component takes a `repo` object as a prop. Listing props is optional
# but enables nice React warnings when our expectations aren't met
repo: React.PropTypes.object.isRequired
render: =>
<div className="repo">
<div className="stars">{@props.repo.stargazers_count}</div>
<a href={@props.repo.html_url}>{@props.repo.full_name}</a>
</div>
# Small React component that renders the user's Github profile.
class GithubProfile extends React.Component
@displayName: 'GithubProfile'
@propTypes:
# This component takes a `profile` object as a prop. Listing props is optional
# but enables nice React warnings when our expectations aren't met.
profile: React.PropTypes.object.isRequired
render: =>
# Transform the profile's array of repos into an array of React <GithubRepo> elements
repoElements = _.map @props.profile.repos, (repo) ->
<GithubRepo key={repo.id} repo={repo} />
# Remember - this looks like HTML, but it's actually CJSX, which is converted into
# Coffeescript at transpile-time. We're actually creating a nested tree of Javascript
# objects here that *represent* the DOM we want.
<div className="profile">
<img className="logo" src="nylas://github-contact-card/assets/github.png"/>
<a href={@props.profile.html_url}>{@props.profile.login}</a>
<div>{repoElements}</div>
</div>
module.exports =
class GithubContactCardSection extends React.Component
@displayName: 'GithubContactCardSection'
constructor: (@props) ->
@state = @_getStateFromStores()
componentDidMount: =>
# When our component mounts, start listening to the GithubUserStore.
# When the store `triggers`, our `_onChange` method will fire and allow
# us to replace our state.
@unsubscribe = GithubUserStore.listen @_onChange
componentWillUnmount: =>
@unsubscribe()
render: =>
<div className="sidebar-github-profile">
<h2>Github</h2>
{@_renderInner()}
</div>
_renderInner: =>
# Handle various loading states by returning early
return <div>Loading...</div> if @state.loading
return <div>No Matching Profile</div> if not @state.profile
<GithubProfile profile={@state.profile} />
# The data vended by the GithubUserStore has changed. Calling `setState:`
# will cause React to re-render our view to reflect the new values.
_onChange: =>
@setState(@_getStateFromStores())
_getStateFromStores: =>
profile: GithubUserStore.profileForFocusedContact()
loading: GithubUserStore.loading()

View file

@ -0,0 +1,113 @@
import _ from 'underscore';
import GithubUserStore from "./github-user-store";
import {React} from 'nylas-exports';
// Small React component that renders a single Github repository
class GithubRepo extends React.Component {
static displayName = 'GithubRepo';
static propTypes = {
// This component takes a `repo` object as a prop. Listing props is optional
// but enables nice React warnings when our expectations aren't met
repo: React.PropTypes.object.isRequired,
};
render() {
const {repo} = this.props;
return (
<div className="repo">
<div className="stars">{repo.stargazers_count}</div>
<a href={repo.html_url}>{repo.full_name}</a>
</div>
);
}
}
// Small React component that renders the user's Github profile.
class GithubProfile extends React.Component {
static displayName = 'GithubProfile';
static propTypes = {
// This component takes a `profile` object as a prop. Listing props is optional
// but enables nice React warnings when our expectations aren't met.
profile: React.PropTypes.object.isRequired,
}
render() {
const {profile} = this.props;
// Transform the profile's array of repos into an array of React <GithubRepo> elements
const repoElements = _.map(profile.repos, (repo)=> {
return <GithubRepo key={repo.id} repo={repo} />
});
// Remember - this looks like HTML, but it's actually CJSX, which is converted into
// Coffeescript at transpile-time. We're actually creating a nested tree of Javascript
// objects here that *represent* the DOM we want.
return (
<div className="profile">
<img className="logo" src="nylas://github-contact-card/assets/github.png"/>
<a href={profile.html_url}>{profile.login}</a>
<div>{repoElements}</div>
</div>
);
}
}
export default class GithubContactCardSection extends React.Component {
static displayName = 'GithubContactCardSection';
constructor(props) {
super(props);
this.state = this._getStateFromStores();
}
componentDidMount() {
// When our component mounts, start listening to the GithubUserStore.
// When the store `triggers`, our `_onChange` method will fire and allow
// us to replace our state.
this._unsubscribe = GithubUserStore.listen(this._onChange);
}
componentWillUnmount() {
this._unsubscribe();
}
_getStateFromStores = ()=> {
return {
profile: GithubUserStore.profileForFocusedContact(),
loading: GithubUserStore.loading(),
};
}
// The data vended by the GithubUserStore has changed. Calling `setState:`
// will cause React to re-render our view to reflect the new values.
_onChange = ()=> {
this.setState(this._getStateFromStores())
}
_renderInner() {
// Handle various loading states by returning early
if (this.state.loading) {
return (<div>Loading...</div>);
}
if (!this.state.profile) {
return (<div>No Matching Profile</div>);
}
return (
<GithubProfile profile={this.state.profile} />
);
}
render() {
return (
<div className="sidebar-github-profile">
<h2>Github</h2>
{this._renderInner()}
</div>
);
}
}

View file

@ -1,84 +0,0 @@
_ = require 'underscore'
Reflux = require 'reflux'
request = require 'request'
{FocusedContactsStore} = require 'nylas-exports'
module.exports =
# This package uses the Flux pattern - our Store is a small singleton that
# observes other parts of the application and vends data to our React
# component. If the user could interact with the GithubSidebar, this store
# would also listen for `Actions` emitted by our React components.
GithubUserStore = Reflux.createStore
init: ->
@_profile = null
@_cache = {}
@_loading = false
@_error = null
# Register a callback with the FocusedContactsStore. This will tell us
# whenever the selected person has changed so we can refresh our data.
@listenTo FocusedContactsStore, @_onFocusedContactChanged
# Getter Methods
profileForFocusedContact: ->
@_profile
loading: ->
@_loading
error: ->
@_error
# Called when the FocusedContactStore `triggers`, notifying us that the data
# it vends has changed.
_onFocusedContactChanged: ->
# Grab the new focused contact
contact = FocusedContactsStore.focusedContact()
# First, clear the contact that we're currently showing and `trigger`. Since
# our React component observes our store, `trigger` causes our React component
# to re-render.
@_error = null
@_profile = null
if contact
@_profile = @_cache[contact.email]
# Make a Github search request to find the matching user profile
@_githubFetchProfile(contact.email) unless @_profile?
@trigger(@)
_githubFetchProfile: (email) ->
@_loading = true
@_githubRequest "https://api.github.com/search/users?q=#{email}", (err, resp, data) =>
return if err or not data
console.warn(data.message) if data.message?
# Sometimes we get rate limit errors, etc., so we need to check and make
# sure we've gotten items before pulling the first one.
profile = data?.items?[0] ? false
# If a profile was found, make a second request for the user's public
# repositories.
if profile
profile.repos = []
@_githubRequest profile.repos_url, (err, resp, repos) =>
# Sort the repositories by their stars (`-` for descending order)
profile.repos = _.sortBy repos, (repo) -> -repo.stargazers_count
# Trigger so that our React components refresh their state and display
# the updated data.
@trigger(@)
@_loading = false
@_profile = @_cache[email] = profile
@trigger(@)
# Wrap the Node `request` library and pass the User-Agent header, which is required
# by Github's API. Also pass `json:true`, which causes responses to be automatically
# parsed.
_githubRequest: (url, callback) ->
request({url: url, headers: {'User-Agent': 'request'}, json: true}, callback)

View file

@ -0,0 +1,106 @@
import _ from 'underscore';
import request from 'request';
import NylasStore from 'nylas-store';
import {FocusedContactsStore} from 'nylas-exports';
// This package uses the Flux pattern - our Store is a small singleton that
// observes other parts of the application and vends data to our React
// component. If the user could interact with the GithubSidebar, this store
// would also listen for `Actions` emitted by our React components.
class GithubUserStore extends NylasStore {
constructor() {
super();
this._profile = null;
this._cache = {};
this._loading = false;
this._error = null;
// Register a callback with the FocusedContactsStore. This will tell us
// whenever the selected person has changed so we can refresh our data.
this.listenTo(FocusedContactsStore, this._onFocusedContactChanged);
}
// Getter Methods
profileForFocusedContact() {
return this._profile;
}
loading() {
return this._loading;
}
error() {
return this._error;
}
// Called when the FocusedContactStore `triggers`, notifying us that the data
// it vends has changed.
_onFocusedContactChanged = ()=> {
// Grab the new focused contact
const contact = FocusedContactsStore.focusedContact();
// First, clear the contact that we're currently showing and `trigger`. Since
// our React component observes our store, `trigger` causes our React component
// to re-render.
this._error = null;
this._profile = null;
if (contact) {
this._profile = this._cache[contact.email];
if (this._profile === undefined) {
// Make a Github search request to find the matching user profile
this._githubFetchProfile(contact.email);
}
}
this.trigger(this);
}
_githubFetchProfile(email) {
this._loading = true
this._githubRequest(`https://api.github.com/search/users?q=${email}`, (err, resp, data)=> {
if (err || !data) {
return;
}
if (data.message !== undefined) {
console.warn(data.message);
}
// Sometimes we get rate limit errors, etc., so we need to check and make
// sure we've gotten items before pulling the first one.
let profile = false;
if (data && data.items && data.items[0]) {
profile = data.items[0];
}
// If a profile was found, make a second request for the user's public
// repositories.
if (profile !== false) {
profile.repos = [];
this._githubRequest(profile.repos_url, (reposErr, reposResp, repos)=> {
// Sort the repositories by their stars (`-` for descending order)
profile.repos = _.sortBy(repos, (repo)=> -repo.stargazers_count);
// Trigger so that our React components refresh their state and display
// the updated data.
this.trigger(this);
});
}
this._loading = false;
this._profile = this._cache[email] = profile;
this.trigger(this);
});
}
// Wrap the Node `request` library and pass the User-Agent header, which is required
// by Github's API. Also pass `json:true`, which causes responses to be automatically
// parsed.
_githubRequest(url, callback) {
return request({url: url, headers: {'User-Agent': 'request'}, json: true}, callback);
}
}
export default new GithubUserStore();

View file

@ -1,30 +0,0 @@
_ = require 'underscore'
GithubContactCardSection = require "./github-contact-card-section"
{ComponentRegistry,
WorkspaceStore} = require "nylas-exports"
module.exports =
# Activate is called when the package is loaded. If your package previously
# saved state using `serialize` it is provided.
#
activate: (@state={}) ->
# Register our sidebar so that it appears in the Message List sidebar.
# This sidebar is to the right of the Message List in both split pane mode
# and list mode.
ComponentRegistry.register GithubContactCardSection,
role: "MessageListSidebar:ContactCard"
# Serialize is called when your package is about to be unmounted.
# You can return a state object that will be passed back to your package
# when it is re-activated.
#
serialize: ->
# This **optional** method is called when the window is shutting down,
# or when your package is being updated or disabled. If your package is
# watching any files, holding external resources, providing commands or
# subscribing to events, release them here.
#
deactivate: ->
# Unregister our component
ComponentRegistry.unregister(GithubContactCardSection)

View file

@ -0,0 +1,38 @@
import GithubContactCardSection from "./github-contact-card-section";
import {
ComponentRegistry,
} from "nylas-exports";
/*
All packages must export a basic object that has at least the following 3
methods:
1. `activate` - Actions to take once the package gets turned on.
Pre-enabled packages get activated on N1 bootup. They can also be
activated manually by a user.
2. `deactivate` - Actions to take when a package gets turned off. This can
happen when a user manually disables a package.
3. `serialize` - A simple serializable object that gets saved to disk
before N1 quits. This gets passed back into `activate` next time N1 boots
up or your package is manually activated.
*/
export function activate() {
// Register our sidebar so that it appears in the Message List sidebar.
// This sidebar is to the right of the Message List in both split pane mode
// and list mode.
ComponentRegistry.register(GithubContactCardSection, {
role: "MessageListSidebar:ContactCard",
});
}
export function serialize() {
return {};
}
export function deactivate() {
ComponentRegistry.unregister(GithubContactCardSection);
}

View file

@ -1,12 +0,0 @@
Reflux = require 'reflux'
Actions = [
'temporarilyEnableImages'
'permanentlyEnableImages'
]
for key in Actions
Actions[key] = Reflux.createAction(name)
Actions[key].sync = true
module.exports = Actions

View file

@ -0,0 +1,13 @@
import Reflux from 'reflux';
const ActionNames = [
'temporarilyEnableImages',
'permanentlyEnableImages',
];
const Actions = Reflux.createActions(ActionNames);
ActionNames.forEach((name)=> {
Actions[name].sync = true;
});
export default Actions;

View file

@ -1,11 +0,0 @@
AutoloadImagesStore = require './autoload-images-store'
{MessageViewExtension} = require 'nylas-exports'
class AutoloadImagesExtension extends MessageViewExtension
@formatMessageBody: ({message}) ->
if AutoloadImagesStore.shouldBlockImagesIn(message)
message.body = message.body.replace AutoloadImagesStore.ImagesRegexp, (match, prefix, imageUrl) ->
"#{prefix}#"
module.exports = AutoloadImagesExtension

View file

@ -0,0 +1,12 @@
import AutoloadImagesStore from './autoload-images-store';
import {MessageViewExtension} from 'nylas-exports';
export default class AutoloadImagesExtension extends MessageViewExtension {
static formatMessageBody = ({message})=> {
if (AutoloadImagesStore.shouldBlockImagesIn(message)) {
message.body = message.body.replace(AutoloadImagesStore.ImagesRegexp, (match, prefix)=> {
return `${prefix}#`;
});
}
}
}

View file

@ -1,24 +0,0 @@
React = require 'react'
AutoloadImagesStore = require './autoload-images-store'
Actions = require './autoload-images-actions'
{Message} = require 'nylas-exports'
class AutoloadImagesHeader extends React.Component
@displayName: 'AutoloadImagesHeader'
@propTypes:
message: React.PropTypes.instanceOf(Message).isRequired
constructor: (@props) ->
render: =>
if AutoloadImagesStore.shouldBlockImagesIn(@props.message)
<div className="autoload-images-header">
<a className="option" onClick={ => Actions.temporarilyEnableImages(@props.message) }>Show Images</a>
<span style={paddingLeft: 10, paddingRight: 10}>|</span>
<a className="option" onClick={ => Actions.permanentlyEnableImages(@props.message) }>Always show images from {@props.message.fromContact().toString()}</a>
</div>
else
<div></div>
module.exports = AutoloadImagesHeader

View file

@ -0,0 +1,34 @@
import React from 'react';
import AutoloadImagesStore from './autoload-images-store';
import Actions from './autoload-images-actions';
import {Message} from 'nylas-exports';
export default class AutoloadImagesHeader extends React.Component {
static displayName = 'AutoloadImagesHeader';
static propTypes = {
message: React.PropTypes.instanceOf(Message).isRequired,
}
render() {
const {message} = this.props;
if (AutoloadImagesStore.shouldBlockImagesIn(message) === false) {
return (
<div></div>
);
}
return (
<div className="autoload-images-header">
<a className="option" onClick={ ()=> Actions.temporarilyEnableImages(message) }>
Show Images
</a>
<span style={{paddingLeft: 10, paddingRight: 10}}>|</span>
<a className="option" onClick={ ()=> Actions.permanentlyEnableImages(message) }>
Always show images from {message.fromContact().toString()}
</a>
</div>
);
}
}

View file

@ -1,60 +0,0 @@
NylasStore = require 'nylas-store'
fs = require 'fs'
path = require 'path'
{Utils, MessageBodyProcessor} = require 'nylas-exports'
AutoloadImagesActions = require './autoload-images-actions'
# Match:
# - any of the DOM attributes supporting images starting with a protocol
# (src, background, placeholder, icon, poster, or srcset)
# - any url() value
#
ImagesRegexp = /((?:src|background|placeholder|icon|background|poster|srcset)\s*=\s*['"]?(?=\w*:\/\/)|:\s*url\()+([^"'\)]*)/gi
class AutoloadImagesStore extends NylasStore
constructor: ->
@_whitelistEmails = {}
@_whitelistMessageIds = {}
@_whitelistEmailsPath = path.join(NylasEnv.getConfigDirPath(), 'autoload-images-whitelist.txt')
@_loadWhitelist()
@listenTo AutoloadImagesActions.temporarilyEnableImages, @_onTemporarilyEnableImages
@listenTo AutoloadImagesActions.permanentlyEnableImages, @_onPermanentlyEnableImages
NylasEnv.config.onDidChange 'core.reading.autoloadImages', =>
MessageBodyProcessor.resetCache()
shouldBlockImagesIn: (message) =>
return false if NylasEnv.config.get('core.reading.autoloadImages') is true
return false if @_whitelistEmails[Utils.toEquivalentEmailForm(message.fromContact().email)]
return false if @_whitelistMessageIds[message.id]
return false unless ImagesRegexp.test(message.body)
true
_loadWhitelist: =>
fs.exists @_whitelistEmailsPath, (exists) =>
return unless exists
fs.readFile @_whitelistEmailsPath, (err, body) =>
return console.log(err) if err or not body
@_whitelistEmails = {}
for email in body.toString().split(/[\n\r]+/)
@_whitelistEmails[Utils.toEquivalentEmailForm(email)] = true
_saveWhitelist: =>
data = Object.keys(@_whitelistEmails).join('\n')
fs.writeFile @_whitelistEmailsPath, data, (err) =>
console.error("AutoloadImagesStore could not save whitelist: #{err.toString()}") if err
_onTemporarilyEnableImages: (message) ->
@_whitelistMessageIds[message.id] = true
MessageBodyProcessor.resetCache()
_onPermanentlyEnableImages: (message) ->
@_whitelistEmails[Utils.toEquivalentEmailForm(message.fromContact().email)] = true
MessageBodyProcessor.resetCache()
setTimeout(@_saveWhitelist, 1)
module.exports = new AutoloadImagesStore
module.exports.ImagesRegexp = ImagesRegexp

View file

@ -0,0 +1,81 @@
import NylasStore from 'nylas-store';
import fs from 'fs';
import path from 'path';
import {Utils, MessageBodyProcessor} from 'nylas-exports';
import AutoloadImagesActions from './autoload-images-actions';
const ImagesRegexp = /((?:src|background|placeholder|icon|background|poster|srcset)\s*=\s*['"]?(?=\w*:\/\/)|:\s*url\()+([^"'\)]*)/gi;
class AutoloadImagesStore extends NylasStore {
constructor() {
super();
this.ImagesRegexp = ImagesRegexp;
this._whitelistEmails = {}
this._whitelistMessageIds = {}
this._whitelistEmailsPath = path.join(NylasEnv.getConfigDirPath(), 'autoload-images-whitelist.txt');
this._loadWhitelist();
this.listenTo(AutoloadImagesActions.temporarilyEnableImages, this._onTemporarilyEnableImages);
this.listenTo(AutoloadImagesActions.permanentlyEnableImages, this._onPermanentlyEnableImages);
NylasEnv.config.onDidChange('core.reading.autoloadImages', ()=> {
MessageBodyProcessor.resetCache()
});
}
shouldBlockImagesIn = (message)=> {
if (NylasEnv.config.get('core.reading.autoloadImages') === true) {
return false;
}
if (this._whitelistEmails[Utils.toEquivalentEmailForm(message.fromContact().email)]) {
return false;
}
if (this._whitelistMessageIds[message.id]) {
return false;
}
return ImagesRegexp.test(message.body);
}
_loadWhitelist = ()=> {
fs.exists(this._whitelistEmailsPath, (exists)=> {
if (!exists) { return; }
fs.readFile(this._whitelistEmailsPath, (err, body)=> {
if (err || !body) { return console.log(err); }
this._whitelistEmails = {}
body.toString().split(/[\n\r]+/).forEach((email)=> {
this._whitelistEmails[Utils.toEquivalentEmailForm(email)] = true;
});
});
});
}
_saveWhitelist = ()=> {
const data = Object.keys(this._whitelistEmails).join('\n');
fs.writeFile(this._whitelistEmailsPath, data, (err) => {
if (err) {
console.error(`AutoloadImagesStore could not save whitelist: ${err.toString()}`);
}
});
}
_onTemporarilyEnableImages = (message)=> {
this._whitelistMessageIds[message.id] = true;
MessageBodyProcessor.resetCache();
}
_onPermanentlyEnableImages = (message)=> {
const email = Utils.toEquivalentEmailForm(message.fromContact().email);
this._whitelistEmails[email] = true;
MessageBodyProcessor.resetCache();
setTimeout(this._saveWhitelist, 1);
}
}
export default new AutoloadImagesStore();

View file

@ -1,21 +0,0 @@
{ComponentRegistry,
ExtensionRegistry,
WorkspaceStore} = require 'nylas-exports'
AutoloadImagesExtension = require './autoload-images-extension'
AutoloadImagesHeader = require './autoload-images-header'
module.exports =
item: null # The DOM item the main React component renders into
activate: (@state={}) ->
# Register Message List Actions we provide globally
ExtensionRegistry.MessageView.register AutoloadImagesExtension
ComponentRegistry.register AutoloadImagesHeader,
role: 'message:BodyHeader'
deactivate: ->
ExtensionRegistry.MessageView.unregister AutoloadImagesExtension
ComponentRegistry.unregister(AutoloadImagesHeader)
serialize: -> @state

View file

@ -0,0 +1,37 @@
import {
ComponentRegistry,
ExtensionRegistry,
} from 'nylas-exports';
import AutoloadImagesExtension from './autoload-images-extension';
import AutoloadImagesHeader from './autoload-images-header';
/*
All packages must export a basic object that has at least the following 3
methods:
1. `activate` - Actions to take once the package gets turned on.
Pre-enabled packages get activated on N1 bootup. They can also be
activated manually by a user.
2. `deactivate` - Actions to take when a package gets turned off. This can
happen when a user manually disables a package.
3. `serialize` - A simple serializable object that gets saved to disk
before N1 quits. This gets passed back into `activate` next time N1 boots
up or your package is manually activated.
*/
export function activate() {
// Register Message List Actions we provide globally
ExtensionRegistry.MessageView.register(AutoloadImagesExtension);
ComponentRegistry.register(AutoloadImagesHeader, {
role: 'message:BodyHeader',
});
}
export function serialize() {}
export function deactivate() {
ExtensionRegistry.MessageView.unregister(AutoloadImagesExtension);
ComponentRegistry.unregister(AutoloadImagesHeader);
}

View file

@ -1,24 +0,0 @@
fs = require 'fs'
AutoloadImagesExtension = require '../lib/autoload-images-extension'
AutoloadImagesStore = require '../lib/autoload-images-store'
describe "AutoloadImagesExtension", ->
describe "formatMessageBody", ->
scenarios = []
fixtures = path.resolve(path.join(__dirname, 'fixtures'))
for filename in fs.readdirSync(fixtures)
if filename[-8..-1] is '-in.html'
scenarios.push
name: filename[0..-9]
in: fs.readFileSync(path.join(fixtures, filename)).toString()
out: fs.readFileSync(path.join(fixtures, "#{filename[0..-9]}-out.html")).toString()
scenarios.forEach (scenario) =>
it "should process #{scenario.name}", ->
spyOn(AutoloadImagesStore, 'shouldBlockImagesIn').andReturn(true)
message =
body: scenario.in
AutoloadImagesExtension.formatMessageBody({message})
expect(message.body == scenario.out).toBe(true)
module.exports = AutoloadImagesExtension

View file

@ -0,0 +1,32 @@
import fs from 'fs';
import AutoloadImagesExtension from '../lib/autoload-images-extension';
import AutoloadImagesStore from '../lib/autoload-images-store';
describe("AutoloadImagesExtension", ()=> {
describe("formatMessageBody", ()=> {
const scenarios = [];
const fixtures = path.resolve(path.join(__dirname, 'fixtures'));
fs.readdirSync(fixtures).forEach((filename)=> {
if (filename.endsWith('-in.html')) {
const name = filename.replace('-in.html', '');
scenarios.push({
name: name,
in: fs.readFileSync(path.join(fixtures, filename)).toString(),
out: fs.readFileSync(path.join(fixtures, `${name}-out.html`)).toString(),
});
}
});
scenarios.forEach((scenario)=> {
it(`should process ${scenario.name}`, ()=> {
spyOn(AutoloadImagesStore, 'shouldBlockImagesIn').andReturn(true);
message = {
body: scenario.in
};
AutoloadImagesExtension.formatMessageBody({message});
expect(message.body == scenario.out).toBe(true);
});
});
});
});

View file

@ -1,85 +0,0 @@
_ = require 'underscore'
NylasStore = require 'nylas-store'
{MessageStore} = require 'nylas-exports'
###
The GithubStore is responsible for preparing the data we need (in this case just the Github url) for the `ViewOnGithubButton` to display.
When thinking how to build this store, the first consideration was where we'd get our data. The next consideration was when that data would be available.
This Store simply looks for the presence of a "view it on github" link in an email from Github.
This means we're going to need a message body to parse. Furthermore, we're going to need the message bodies of just the thread the user's currently looking at.
We could have gone at this a couple ways. One way would be to grab the messages for the currently focused thread straight from the Database.
We need to be careful since the message bodies (which could be HUGE) are stored in a different table then the message metadata.
Luckily, after looking through the available stores, we see that the {MessageStore} does all of this lookup logic for us. It even already listens to whenever the thread changes and loads only the correct messages (and their bodies) into its cache.
Instead of writing the Database lookup code ourselves, and creating another, potentially very expensive query, we'll use the {MessageStore} instead.
This also means we need to know when the {MessageStore} changes. It'll change under a variety of circumstances, but the most common one will be when the currently focused thread changes.
We setup the listener for that change in our constructor and provide a callback.
Our callback, `_onMessageStoreChanged`, will grab the messages, check if they're relevant (they come from Github), and parse our the links, if any.
It will then cache that result in `this._link`, and finally `trigger()` to let the `ViewOnGithubButton` know it's time to fetch new data.
###
class GithubStore extends NylasStore
# It's very common practive for {NylasStore}s to listen to other sources
# of data upon their construction. Since Stores are singletons and
# constructed only once during the initial `require`, there is no
# teardown step to turn off listeners.
constructor: ->
@listenTo MessageStore, @_onMessageStoreChanged
# This is the only public method on `GithubStore` and it's read only.
# All {NylasStore}s ONLY have reader methods. No setter methods. Use an
# `Action` instead!
#
# This is the computed & cached value that our `ViewOnGithubButton` will
# render.
link: -> @_link
#### "Private" methods ####
_onMessageStoreChanged: ->
return unless MessageStore.threadId()
itemIds = _.pluck(MessageStore.items(), "id")
return if itemIds.length is 0 or _.isEqual(itemIds, @_lastItemIds)
@_lastItemIds = itemIds
@_link = if @_isRelevantThread() then @_findGitHubLink() else null
@trigger()
_findGitHubLink: ->
msg = MessageStore.items()[0]
if not msg.body
# The msg body may be null if it's collapsed. In that case, use the
# last message. This may be less relaiable since the last message
# might be a side-thread that doesn't contain the link in the quoted
# text.
msg = _.last(MessageStore.items())
# Yep!, this is a very quick and dirty way to figure out what object
# on Github we're referring to.
# https://regex101.com/r/aW8bI4/2
re = /<a.*?href=['"](.*?)['"].*?view.*?it.*?on.*?github.*?\/a>/gmi
firstMatch = re.exec(msg.body)
if firstMatch
link = firstMatch[1] # [0] is the full match and [1] is the matching group
return link
else return null
_isRelevantThread: ->
_.any (MessageStore.thread().participants ? []), (contact) ->
(/@github\.com/gi).test(contact.email)
# IMPORTANT NOTE:
#
# All {NylasStore}s are constructed upon their first `require` by another
# module. Since `require` is cached, they are only constructed once and
# are therefore singletons.
module.exports = new GithubStore()

View file

@ -0,0 +1,78 @@
import _ from 'underscore';
import NylasStore from 'nylas-store';
import {MessageStore} from 'nylas-exports';
class GithubStore extends NylasStore {
// It's very common practive for {NylasStore}s to listen to other parts of N1.
// Since Stores are singletons and constructed once on `require`, there is no
// teardown step to turn off listeners.
constructor() {
super();
this.listenTo(MessageStore, this._onMessageStoreChanged);
}
// This is the only public method on `GithubStore` and it's read only.
// All {NylasStore}s ONLY have reader methods. No setter methods. Use an
// `Action` instead!
//
// This is the computed & cached value that our `ViewOnGithubButton` will
// render.
link() {
return this.link;
}
// Private methods
_onMessageStoreChanged() {
if (!MessageStore.threadId()) {
return;
}
const itemIds = _.pluck(MessageStore.items(), "id");
if ((itemIds.length === 0) || _.isEqual(itemIds, this._lastItemIds)) {
return;
}
this._lastItemIds = itemIds;
this._link = this._isRelevantThread() ? this._findGitHubLink() : null;
this.trigger();
}
_findGitHubLink() {
let msg = MessageStore.items()[0];
if (!msg.body) {
// The msg body may be null if it's collapsed. In that case, use the
// last message. This may be less relaiable since the last message
// might be a side-thread that doesn't contain the link in the quoted
// text.
msg = _.last(MessageStore.items());
}
// Use a regex to parse the message body for GitHub URLs - this is a quick
// and dirty method to determine the GitHub object the email is about:
// https://regex101.com/r/aW8bI4/2
const re = /<a.*?href=['"](.*?)['"].*?view.*?it.*?on.*?github.*?\/a>/gmi;
const firstMatch = re.exec(msg.body);
if (firstMatch) {
// [0] is the full match and [1] is the matching group
return firstMatch[1];
}
return null;
}
_isRelevantThread() {
const participants = MessageStore.thread().participants || [];
const githubDomainRegex = /@github\.com/gi;
return _.any(participants, contact=> githubDomainRegex.test(contact.email));
}
}
/*
IMPORTANT NOTE:
All {NylasStore}s are constructed upon their first `require` by another
module. Since `require` is cached, they are only constructed once and
are therefore singletons.
*/
export default new GithubStore();

View file

@ -1,4 +1,4 @@
###
/*
This package displays a "Vew on Github Button" whenever the message you're
looking at contains a "view it on Github" link.
@ -26,12 +26,12 @@ The `GithubStore` is responsible for figuring out what message you're
looking at, if it has a relevant Github link, and what that link is. Once
it figures that out, it makes that data available for the
`ViewOnGithubButton` to display.
###
*/
{ComponentRegistry} = require 'nylas-exports'
ViewOnGithubButton = require "./view-on-github-button"
import {ComponentRegistry} from 'nylas-exports';
import ViewOnGithubButton from "./view-on-github-button";
###
/*
All packages must export a basic object that has at least the following 3
methods:
@ -39,19 +39,23 @@ methods:
Pre-enabled packages get activated on N1 bootup. They can also be
activated manually by a user.
1. `deactivate` - Actions to take when a package gets turned off. This can
2. `deactivate` - Actions to take when a package gets turned off. This can
happen when a user manually disables a package.
1. `serialize` - A simple serializable object that gets saved to disk
3. `serialize` - A simple serializable object that gets saved to disk
before N1 quits. This gets passed back into `activate` next time N1 boots
up or your package is manually activated.
###
module.exports =
activate: (@state={}) ->
ComponentRegistry.register ViewOnGithubButton,
roles: ['message:Toolbar']
*/
export function activate() {
ComponentRegistry.register(ViewOnGithubButton, {
roles: ['message:Toolbar'],
});
}
deactivate: ->
ComponentRegistry.unregister(ViewOnGithubButton)
export function serialize() {
return {};
}
serialize: -> @state
export function deactivate() {
ComponentRegistry.unregister(ViewOnGithubButton);
}

View file

@ -1,22 +0,0 @@
# The `ComponentRegistry` allows you to add and remove React components.
{ComponentRegistry} = require 'nylas-exports'
PersonalLevelIcon = require './personal-level-icon'
# The `main.coffee` file (sometimes `main.cjsx`) is the file that initializes
# the entire package. It should export an object with some package life cycle
# methods.
module.exports =
# This gets called on the package's initiation. This is the time to register
# your React components to the `ComponentRegistry`.
activate: (@state) ->
# The `role` tells the `ComponentRegistry` where to put the React component.
ComponentRegistry.register PersonalLevelIcon,
role: 'ThreadListIcon'
# This **optional** method is called when the window is shutting down,
# or when your package is being updated or disabled. If your package is
# watching any files, holding external resources, providing commands or
# subscribing to events, release them here.
deactivate: ->
ComponentRegistry.unregister(PersonalLevelIcon)

View file

@ -0,0 +1,32 @@
import {ComponentRegistry} from 'nylas-exports'
import PersonalLevelIcon from './personal-level-icon'
/*
All packages must export a basic object that has at least the following 3
methods:
1. `activate` - Actions to take once the package gets turned on.
Pre-enabled packages get activated on N1 bootup. They can also be
activated manually by a user.
2. `deactivate` - Actions to take when a package gets turned off. This can
happen when a user manually disables a package.
3. `serialize` - A simple serializable object that gets saved to disk
before N1 quits. This gets passed back into `activate` next time N1 boots
up or your package is manually activated.
*/
export function activate() {
ComponentRegistry.register(PersonalLevelIcon, {
role: 'ThreadListIcon',
});
}
export function serialize() {
return {};
}
export function deactivate() {
ComponentRegistry.unregister(PersonalLevelIcon);
}

View file

@ -1,54 +0,0 @@
# # Personal Level Icon
#
# Show an icon for each thread to indicate whether you're the only recipient,
# one of many recipients, or a member of a mailing list.
# Access core components by requiring `nylas-exports`.
{Utils, DraftStore, React} = require 'nylas-exports'
# Access N1 React components by requiring `nylas-component-kit`.
{RetinaImg} = require 'nylas-component-kit'
class PersonalLevelIcon extends React.Component
# Note: You should assign a new displayName to avoid naming
# conflicts when injecting your item
@displayName: 'PersonalLevelIcon'
# In the constructor, we're setting the component's initial state.
constructor: (@props) ->
@state =
level: @_calculateLevel(@props.thread)
# React components' `render` methods return a virtual DOM element to render.
# The returned DOM fragment is a result of the component's `state` and
# `props`. In that sense, `render` methods are deterministic.
render: =>
<div className="personal-level-icon">
{@_renderIcon()}
</div>
# Some application logic which is specific to this package to decide which
# character to render.
_renderIcon: =>
switch @state.level
when 0 then ""
when 1 then "\u3009"
when 2 then "\u300b"
when 3 then "\u21ba"
# Some more application logic which is specific to this package to decide
# what level of personalness is related to the `thread`.
_calculateLevel: (thread) =>
hasMe = (thread.participants.filter (p) -> p.isMe()).length > 0
numOthers = thread.participants.length - hasMe
if not hasMe
return 0
if numOthers > 1
return 1
if numOthers is 1
return 2
else
return 3
module.exports = PersonalLevelIcon

View file

@ -1,54 +0,0 @@
# # Personal Level Icon
#
# Show an icon for each thread to indicate whether you're the only recipient,
# one of many recipients, or a member of a mailing list.
# Access core components by requiring `nylas-exports`.
{Utils, DraftStore, React} = require 'nylas-exports'
# Access N1 React components by requiring `nylas-component-kit`.
{RetinaImg} = require 'nylas-component-kit'
class PersonalLevelIcon extends React.Component
# Note: You should assign a new displayName to avoid naming
# conflicts when injecting your item
@displayName: 'PersonalLevelIcon'
# In the constructor, we're setting the component's initial state.
constructor: (@props) ->
@state =
level: @_calculateLevel(@props.thread)
# React components' `render` methods return a virtual DOM element to render.
# The returned DOM fragment is a result of the component's `state` and
# `props`. In that sense, `render` methods are deterministic.
render: =>
React.createElement("div", {"className": "personal-level-icon"},
(@_renderIcon())
)
# Some application logic which is specific to this package to decide which
# character to render.
_renderIcon: =>
switch @state.level
when 0 then ""
when 1 then "\u3009"
when 2 then "\u300b"
when 3 then "\u21ba"
# Some more application logic which is specific to this package to decide
# what level of personalness is related to the `thread`.
_calculateLevel: (thread) =>
hasMe = (thread.participants.filter (p) -> p.isMe()).length > 0
numOthers = thread.participants.length - hasMe
if not hasMe
return 0
if numOthers > 1
return 1
if numOthers is 1
return 2
else
return 3
module.exports = PersonalLevelIcon

View file

@ -0,0 +1,47 @@
import {React} from 'nylas-exports';
export default class PersonalLevelIcon extends React.Component {
// Note: You should assign a new displayName to avoid naming
// conflicts when injecting your item
static displayName = 'PersonalLevelIcon';
static propTypes = {
thread: React.PropTypes.object.isRequired,
};
// In the constructor, we're setting the component's initial state.
constructor(props) {
super(props);
this.state = {
level: this._calculateLevel(this.props.thread),
};
}
// Some more application logic which is specific to this package to decide
// what level of personalness is related to the `thread`.
_calculateLevel = (thread)=> {
const hasMe = thread.participants.filter(p=> p.isMe()).length > 0;
const numOthers = hasMe ? thread.participants.length - 1 : thread.participants.length;
if (!hasMe) { return 0; }
if (numOthers > 1) { return 1; }
if (numOthers === 1) { return 2; }
return 3;
}
// React components' `render` methods return a virtual DOM element to render.
// The returned DOM fragment is a result of the component's `state` and
// `props`. In that sense, `render` methods are deterministic.
render() {
const levelCharacter = ["", "\u3009", "\u300b", "\u21ba"][this.state.level];
return (
<div className="personal-level-icon">
{levelCharacter}
</div>
);
}
}
module.exports = PersonalLevelIcon

View file

@ -1,93 +0,0 @@
# # Phishing Detection
#
# This is a simple package to notify N1 users if an email is a potential
# phishing scam.
# You can access N1 dependencies by requiring 'nylas-exports'
{React,
# The ComponentRegistry manages all React components in N1.
ComponentRegistry,
# A `Store` is a Flux component which contains all business logic and data
# models to be consumed by React components to render markup.
MessageStore} = require 'nylas-exports'
# Notice that this file is `main.cjsx` rather than `main.coffee`. We use the
# `.cjsx` filetype because we use the CJSX DSL to describe markup for React to
# render. Without the CJSX, we could just name this file `main.coffee` instead.
class PhishingIndicator extends React.Component
# Adding a @displayName to a React component helps for debugging.
@displayName: 'PhishingIndicator'
# @propTypes is an object which validates the datatypes of properties that
# this React component can receive.
@propTypes:
thread: React.PropTypes.object.isRequired
# A React component's `render` method returns a virtual DOM element described
# in CJSX. `render` is deterministic: with the same input, it will always
# render the same output. Here, the input is provided by @isPhishingAttempt.
# `@state` and `@props` are popular inputs as well.
render: =>
# Our inputs for the virtual DOM to render come from @isPhishingAttempt.
[from, reply_to] = @isPhishingAttempt()
# We add some more application logic to decide how to render.
if from isnt null and reply_to isnt null
<div className="phishingIndicator">
<b>This message looks suspicious!</b>
<p>It originates from {from} but replies will go to {reply_to}.</p>
</div>
# If you don't want a React component to render anything at all, then your
# `render` method should return `null` or `undefined`.
else
null
isPhishingAttempt: =>
# In this package, the MessageStore is the source of our data which will be
# the input for the `render` function. @isPhishingAttempt is performing some
# domain-specific application logic to prepare the data for `render`.
message = MessageStore.items()[0]
# This package's strategy to ascertain whether or not the email is a
# phishing attempt boils down to checking the `replyTo` attributes on
# `Message` models from `MessageStore`.
if message?.replyTo? and message.replyTo.length != 0
# The `from` and `replyTo` attributes on `Message` models both refer to
# arrays of `Contact` models, which in turn have `email` attributes.
from = message.from[0].email
reply_to = message.replyTo[0].email
# This is our core logic for our whole package! If the `from` and
# `replyTo` emails are different, then we want to show a phishing warning.
if reply_to isnt from
return [from, reply_to]
return [null, null]
module.exports =
# Activate is called when the package is loaded. If your package previously
# saved state using `serialize` it is provided.
activate: (@state) ->
# This is a good time to tell the `ComponentRegistry` to insert our
# React component into the `'MessageListHeaders'` part of the application.
ComponentRegistry.register PhishingIndicator,
role: 'MessageListHeaders'
# Serialize is called when your package is about to be unmounted.
# You can return a state object that will be passed back to your package
# when it is re-activated.
serialize: ->
# This **optional** method is called when the window is shutting down,
# or when your package is being updated or disabled. If your package is
# watching any files, holding external resources, providing commands or
# subscribing to events, release them here.
deactivate: ->
ComponentRegistry.unregister(PhishingIndicator)

View file

@ -0,0 +1,63 @@
import {
React,
// The ComponentRegistry manages all React components in N1.
ComponentRegistry,
// A `Store` is a Flux component which contains all business logic and data
// models to be consumed by React components to render markup.
MessageStore,
} from 'nylas-exports';
// Notice that this file is `main.cjsx` rather than `main.coffee`. We use the
// `.cjsx` filetype because we use the CJSX DSL to describe markup for React to
// render. Without the CJSX, we could just name this file `main.coffee` instead.
class PhishingIndicator extends React.Component {
// Adding a displayName to a React component helps for debugging.
static displayName = 'PhishingIndicator';
// @propTypes is an object which validates the datatypes of properties that
// this React component can receive.
static propTypes = {
thread: React.PropTypes.object.isRequired,
};
// A React component's `render` method returns a virtual DOM element described
// in CJSX. `render` is deterministic: with the same input, it will always
// render the same output. Here, the input is provided by @isPhishingAttempt.
// `@state` and `@props` are popular inputs as well.
render() {
const message = MessageStore.items()[0];
// This package's strategy to ascertain whether or not the email is a
// phishing attempt boils down to checking the `replyTo` attributes on
// `Message` models from `MessageStore`.
if (message && message.replyTo && message.replyTo.length !== 0) {
const from = message.from[0].email;
const replyTo = message.replyTo[0].email;
if (replyTo !== from) {
return (
<div className="phishingIndicator">
<b>This message looks suspicious!</b>
<p>It originates from {from} but replies will go to {replyTo}.</p>
</div>
);
}
}
return null;
}
}
export function activate() {
ComponentRegistry.register(PhishingIndicator, {
role: 'MessageListHeaders',
});
}
export function serialize() {
}
export function deactivate() {
ComponentRegistry.unregister(PhishingIndicator);
}

View file

@ -1,12 +0,0 @@
describe "AccountSidebarStore", ->
xit "should update it's selected ID when the focusTag action fires", ->
true
xit "should update when the DatabaseStore emits changes to tags", ->
true
xit "should update when the NamespaceStore emits", ->
true
xit "should provide an array of sections to the sidebar view", ->
true

View file

@ -0,0 +1,5 @@
describe("Phishing Detection Indicator", ()=> {
it("should exhibit some behavior", ()=> {
expect(true).toBe(true);
});
});

View file

@ -0,0 +1,76 @@
import ModelWithMetadata from '../../src/flux/models/model-with-metadata'
class TestModel extends ModelWithMetadata {
};
describe("ModelWithMetadata", ()=>{
it("should initialize pluginMetadata to an empty array", ()=> {
model = new TestModel();
expect(model.pluginMetadata).toEqual([]);
});
describe("metadataForPluginId", ()=> {
beforeEach(()=> {
this.model = new TestModel();
this.model.applyPluginMetadata('plugin-id-a', {a: true});
this.model.applyPluginMetadata('plugin-id-b', {b: false});
})
it("returns the metadata value for the provided pluginId", ()=> {
expect(this.model.metadataForPluginId('plugin-id-b')).toEqual({b: false});
});
it("returns null if no value is found", ()=> {
expect(this.model.metadataForPluginId('plugin-id-c')).toEqual(null);
});
});
describe("metadataObjectForPluginId", ()=> {
it("returns the metadata object for the provided pluginId", ()=> {
model = new TestModel();
model.applyPluginMetadata('plugin-id-a', {a: true});
model.applyPluginMetadata('plugin-id-b', {b: false});
expect(model.metadataObjectForPluginId('plugin-id-a')).toEqual(model.pluginMetadata[0]);
expect(model.metadataObjectForPluginId('plugin-id-b')).toEqual(model.pluginMetadata[1]);
expect(model.metadataObjectForPluginId('plugin-id-c')).toEqual(undefined);
});
});
describe("applyPluginMetadata", ()=> {
it("creates or updates the appropriate metadata object", ()=> {
model = new TestModel();
expect(model.pluginMetadata.length).toEqual(0);
// create new metadata object with correct value
model.applyPluginMetadata('plugin-id-a', {a: true});
obj = model.metadataObjectForPluginId('plugin-id-a');
expect(model.pluginMetadata.length).toEqual(1);
expect(obj.pluginId).toBe('plugin-id-a');
expect(obj.id).toBe('plugin-id-a');
expect(obj.version).toBe(0);
expect(obj.value.a).toBe(true);
// update existing metadata object
model.applyPluginMetadata('plugin-id-a', {a: false});
expect(obj.value.a).toBe(false);
});
});
describe("clonePluginMetadataFrom", ()=> {
it("applies the pluginMetadata from the other model, copying values but resetting versions", ()=> {
model = new TestModel();
model.applyPluginMetadata('plugin-id-a', {a: true});
model.applyPluginMetadata('plugin-id-b', {b: false});
model.metadataObjectForPluginId('plugin-id-a').version = 2;
model.metadataObjectForPluginId('plugin-id-b').version = 3;
created = new TestModel();
created.clonePluginMetadataFrom(model);
aMetadatum = created.metadataObjectForPluginId('plugin-id-a');
bMetadatum = created.metadataObjectForPluginId('plugin-id-b');
expect(aMetadatum.version).toEqual(0);
expect(aMetadatum.value).toEqual({a: true});
expect(bMetadatum.version).toEqual(0);
expect(bMetadatum.value).toEqual({b: false});
});
});
});

View file

@ -87,8 +87,8 @@ export default class ModelWithMetadata extends Model {
return this;
}
setPluginMetadata(pluginMetadata) {
this.pluginMetadata = pluginMetadata.map(({pluginId, value})=> {
clonePluginMetadataFrom(otherModel) {
this.pluginMetadata = otherModel.pluginMetadata.map(({pluginId, value})=> {
return new PluginMetadata({pluginId, value});
})
return this;

View file

@ -160,7 +160,7 @@ class SendDraftTask extends Task
@message.clientId = @draft.clientId
@message.draft = false
# Create new metadata objs on the message based on the existing ones in the draft
@message.setPluginMetadata(@draft.pluginMetadata)
@message.clonePluginMetadataFrom(@draft)
return DatabaseStore.inTransaction (t) =>
DatabaseStore.findBy(Message, {clientId: @draft.clientId})