mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-20 23:36:21 +08:00
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:
parent
3508e7b9d7
commit
3fc6582718
|
@ -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'])
|
||||
|
||||
|
|
|
@ -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: ->
|
29
internal_packages/composer-signature/lib/main.es6
Normal file
29
internal_packages/composer-signature/lib/main.es6
Normal 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 {};
|
||||
}
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
};
|
||||
|
|
|
@ -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>')
|
|
@ -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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
10
internal_packages/composer-spellcheck/lib/main.es6
Normal file
10
internal_packages/composer-spellcheck/lib/main.es6
Normal 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);
|
||||
}
|
|
@ -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
|
|
@ -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;
|
|
@ -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
|
|
@ -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});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
73
internal_packages/composer-templates/lib/template-editor.es6
Normal file
73
internal_packages/composer-templates/lib/template-editor.es6
Normal 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
|
|
@ -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" />
|
||||
|
||||
<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)
|
174
internal_packages/composer-translate/lib/main.jsx
Normal file
174
internal_packages/composer-translate/lib/main.jsx
Normal 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" />
|
||||
|
||||
<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);
|
||||
}
|
|
@ -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()
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)
|
106
internal_packages/github-contact-card/lib/github-user-store.es6
Normal file
106
internal_packages/github-contact-card/lib/github-user-store.es6
Normal 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();
|
|
@ -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)
|
38
internal_packages/github-contact-card/lib/main.jsx
Normal file
38
internal_packages/github-contact-card/lib/main.jsx
Normal 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);
|
||||
}
|
|
@ -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
|
|
@ -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;
|
|
@ -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
|
|
@ -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}#`;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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();
|
|
@ -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
|
37
internal_packages/message-autoload-images/lib/main.es6
Normal file
37
internal_packages/message-autoload-images/lib/main.es6
Normal 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);
|
||||
}
|
|
@ -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
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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()
|
|
@ -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();
|
|
@ -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);
|
||||
}
|
|
@ -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)
|
32
internal_packages/personal-level-indicators/lib/main.es6
Normal file
32
internal_packages/personal-level-indicators/lib/main.es6
Normal 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);
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
63
internal_packages/phishing-detection/lib/main.jsx
Normal file
63
internal_packages/phishing-detection/lib/main.jsx
Normal 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);
|
||||
}
|
|
@ -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
|
5
internal_packages/phishing-detection/spec/main-spec.jsx
Normal file
5
internal_packages/phishing-detection/spec/main-spec.jsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
describe("Phishing Detection Indicator", ()=> {
|
||||
it("should exhibit some behavior", ()=> {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
76
spec/models/model-with-metadata-spec.es6
Normal file
76
spec/models/model-with-metadata-spec.es6
Normal 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});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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})
|
||||
|
|
Loading…
Reference in a new issue