Adopt prettier , upgrade ESLint

This commit is contained in:
Ben Gotow 2017-09-26 11:33:08 -07:00
parent 38ecc23188
commit 0f54aa11b5
631 changed files with 26762 additions and 17613 deletions

View file

@ -1,6 +1,14 @@
{ {
"parser": "babel-eslint", "parser": "babel-eslint",
"extends": "airbnb", "parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
"modules": true,
"jsx": true
}
},
"extends": ["react-app", "prettier", "prettier/react"],
"globals": { "globals": {
"NylasEnv": false, "NylasEnv": false,
"$n": false, "$n": false,
@ -16,59 +24,20 @@
"node": true, "node": true,
"jasmine": true "jasmine": true
}, },
"plugins": ["prettier"],
"rules": { "rules": {
"arrow-body-style": "off", "prettier/prettier": "error"
"arrow-parens": "off",
"class-methods-use-this": "off",
"prefer-arrow-callback": ["error", {"allowNamedFunctions": true}],
"eqeqeq": ["error", "smart"],
"id-length": "off",
"object-curly-spacing": "off",
"max-len": "off",
"new-cap": ["error", {"capIsNew": false}],
"newline-per-chained-call": "off",
"no-bitwise": "off",
"no-lonely-if": "off",
"no-console": "off",
"no-continue": "off",
"no-constant-condition": "off",
"no-loop-func": "off",
"no-plusplus": "off",
"no-shadow": "error",
"no-underscore-dangle": "off",
"object-shorthand": "off",
"quotes": "off",
"quote-props": ["error", "consistent-as-needed", { "keywords": true }],
"no-param-reassign": ["error", { "props": false }],
"semi": "off",
"no-mixed-operators": "off",
"import/extensions": ["error", "never", { "json": "always" }],
"import/no-unresolved": ["error", {"ignore": ["nylas-exports", "nylas-component-kit", "electron", "nylas-store", "react-dom/server", "nylas-observables", "windows-shortcuts", "moment-round", "better-sqlite3", "chrono-node", "event-kit", "enzyme"]}],
"import/no-extraneous-dependencies": "off",
"import/newline-after-import": "off",
"import/prefer-default-export": "off",
"react/no-multi-comp": "off",
"react/no-find-dom-node": "off",
"react/no-string-refs": "off",
"react/no-unused-prop-types": "off",
"react/forbid-prop-types": "off",
"jsx-a11y/no-static-element-interactions": "off",
"react/prop-types": ["error", {"ignore": ["children"]}],
"react/sort-comp": "error",
"no-restricted-syntax": [
"error", "ForInStatement", "LabeledStatement", "WithStatement"
],
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "ignore"
}],
"no-useless-return": "off"
}, },
"settings": { "settings": {
"import/core-modules": [ "nylas-exports", "nylas-component-kit", "electron", "nylas-store", "nylas-observables" ], "import/core-modules": [
"import/resolver": {"node": {"extensions": [".es6", ".jsx", ".coffee", ".json", ".cjsx", ".js"]}} "nylas-exports",
"nylas-component-kit",
"electron",
"nylas-store",
"nylas-observables"
],
"import/resolver": {
"node": { "extensions": [".es6", ".jsx", ".coffee", ".json", ".cjsx", ".js"] }
}
} }
} }

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "es5"
}

View file

@ -2,7 +2,7 @@
/* eslint import/no-dynamic-require: 0 */ /* eslint import/no-dynamic-require: 0 */
const path = require('path'); const path = require('path');
module.exports = (grunt) => { module.exports = grunt => {
if (!grunt.option('platform')) { if (!grunt.option('platform')) {
grunt.option('platform', process.platform); grunt.option('platform', process.platform);
} }
@ -15,17 +15,17 @@ module.exports = (grunt) => {
const appDir = path.resolve(path.join('app')); const appDir = path.resolve(path.join('app'));
const buildDir = path.join(appDir, 'build'); const buildDir = path.join(appDir, 'build');
const tasksDir = path.join(buildDir, 'tasks'); const tasksDir = path.join(buildDir, 'tasks');
const taskHelpers = require(path.join(tasksDir, 'task-helpers'))(grunt) const taskHelpers = require(path.join(tasksDir, 'task-helpers'))(grunt);
// This allows all subsequent paths to the relative to the root of the repo // This allows all subsequent paths to the relative to the root of the repo
grunt.config.init({ grunt.config.init({
'taskHelpers': taskHelpers, taskHelpers: taskHelpers,
'rootDir': path.resolve('./'), rootDir: path.resolve('./'),
'buildDir': buildDir, buildDir: buildDir,
'appDir': appDir, appDir: appDir,
'classDocsOutputDir': path.join(buildDir, 'docs_src', 'classes'), classDocsOutputDir: path.join(buildDir, 'docs_src', 'classes'),
'outputDir': path.join(appDir, 'dist'), outputDir: path.join(appDir, 'dist'),
'appJSON': grunt.file.readJSON(path.join(appDir, 'package.json')), appJSON: grunt.file.readJSON(path.join(appDir, 'package.json')),
'source:coffeescript': [ 'source:coffeescript': [
'internal_packages/**/*.cjsx', 'internal_packages/**/*.cjsx',
'internal_packages/**/*.coffee', 'internal_packages/**/*.coffee',
@ -59,35 +59,18 @@ module.exports = (grunt) => {
grunt.loadTasks(tasksDir); grunt.loadTasks(tasksDir);
grunt.file.setBase(appDir); grunt.file.setBase(appDir);
grunt.registerTask('docs', [ grunt.registerTask('docs', ['docs-build', 'docs-render']);
'docs-build',
'docs-render',
]);
grunt.registerTask('lint', [ grunt.registerTask('lint', ['eslint', 'lesslint', 'nylaslint', 'coffeelint', 'csslint']);
'eslint',
'lesslint',
'nylaslint',
'coffeelint',
'csslint',
]);
if (grunt.option('platform') === 'win32') { if (grunt.option('platform') === 'win32') {
grunt.registerTask("build-client", [ grunt.registerTask('build-client', [
"package", 'package',
// The Windows electron-winstaller task must be run outside of grunt // The Windows electron-winstaller task must be run outside of grunt
]); ]);
} else if (grunt.option('platform') === 'darwin') { } else if (grunt.option('platform') === 'darwin') {
grunt.registerTask("build-client", [ grunt.registerTask('build-client', ['package', 'create-mac-zip', 'create-mac-dmg']);
"package",
"create-mac-zip",
"create-mac-dmg",
]);
} else if (grunt.option('platform') === 'linux') { } else if (grunt.option('platform') === 'linux') {
grunt.registerTask("build-client", [ grunt.registerTask('build-client', ['package', 'create-deb-installer', 'create-rpm-installer']);
"package",
"create-deb-installer",
"create-rpm-installer",
]);
} }
} };

View file

@ -4,10 +4,10 @@
* directly from a powershell command. * directly from a powershell command.
*/ */
const path = require('path'); const path = require('path');
const {createWindowsInstaller} = require('electron-winstaller'); const { createWindowsInstaller } = require('electron-winstaller');
const appDir = path.join(__dirname, ".."); const appDir = path.join(__dirname, '..');
const {version} = require(path.join(appDir, 'package.json')); const { version } = require(path.join(appDir, 'package.json'));
const config = { const config = {
usePackageJson: false, usePackageJson: false,
@ -17,23 +17,25 @@ const config = {
iconUrl: 'http://edgehill.s3.amazonaws.com/static/mailspring.ico', iconUrl: 'http://edgehill.s3.amazonaws.com/static/mailspring.ico',
certificateFile: process.env.CERTIFICATE_FILE, certificateFile: process.env.CERTIFICATE_FILE,
certificatePassword: process.env.WINDOWS_CODESIGN_KEY_PASSWORD, certificatePassword: process.env.WINDOWS_CODESIGN_KEY_PASSWORD,
description: "Mailspring", description: 'Mailspring',
version: version, version: version,
title: "mailspring", title: 'mailspring',
authors: 'Foundry 376, LLC', authors: 'Foundry 376, LLC',
setupIcon: path.join(appDir, 'build', 'resources', 'win', 'mailspring.ico'), setupIcon: path.join(appDir, 'build', 'resources', 'win', 'mailspring.ico'),
setupExe: 'MailspringSetup.exe', setupExe: 'MailspringSetup.exe',
exe: 'mailspring.exe', exe: 'mailspring.exe',
name: 'Mailspring', name: 'Mailspring',
} };
console.log(config); console.log(config);
console.log("---> Starting") console.log('---> Starting');
createWindowsInstaller(config).then(() => { createWindowsInstaller(config)
console.log("createWindowsInstaller succeeded.") .then(() => {
process.exit(0); console.log('createWindowsInstaller succeeded.');
}).catch((e) => { process.exit(0);
console.error(`createWindowsInstaller failed: ${e.message}`); })
process.exit(1); .catch(e => {
}); console.error(`createWindowsInstaller failed: ${e.message}`);
process.exit(1);
});

View file

@ -1,25 +1,17 @@
module.exports = (grunt) => { module.exports = grunt => {
grunt.config.merge({ grunt.config.merge({
coffeelint: { coffeelint: {
'options': { options: {
configFile: 'build/config/coffeelint.json', configFile: 'build/config/coffeelint.json',
}, },
'src': grunt.config('source:coffeescript'), src: grunt.config('source:coffeescript'),
'build': [ build: ['build/tasks/**/*.coffee'],
'build/tasks/**/*.coffee', test: ['spec/**/*.cjsx', 'spec/**/*.coffee'],
], static: ['static/**/*.coffee', 'static/**/*.cjsx'],
'test': [ target: grunt.option('target') ? grunt.option('target').split(' ') : [],
'spec/**/*.cjsx',
'spec/**/*.coffee',
],
'static': [
'static/**/*.coffee',
'static/**/*.cjsx',
],
'target': (grunt.option("target") ? grunt.option("target").split(" ") : []),
}, },
}); });
grunt.loadNpmTasks('grunt-contrib-coffee'); grunt.loadNpmTasks('grunt-contrib-coffee');
grunt.loadNpmTasks('grunt-coffeelint-cjsx'); grunt.loadNpmTasks('grunt-coffeelint-cjsx');
} };

View file

@ -1,25 +1,34 @@
const path = require('path'); const path = require('path');
const createDMG = require('electron-installer-dmg') const createDMG = require('electron-installer-dmg');
module.exports = (grunt) => { module.exports = grunt => {
grunt.registerTask('create-mac-dmg', 'Create DMG for Mailspring', function pack() { grunt.registerTask('create-mac-dmg', 'Create DMG for Mailspring', function pack() {
const done = this.async(); const done = this.async();
const dmgPath = path.join(grunt.config('outputDir'), "Mailspring.dmg"); const dmgPath = path.join(grunt.config('outputDir'), 'Mailspring.dmg');
createDMG({ createDMG(
appPath: path.join(grunt.config('outputDir'), "Mailspring-darwin-x64", "Mailspring.app"), {
name: "Mailspring", appPath: path.join(grunt.config('outputDir'), 'Mailspring-darwin-x64', 'Mailspring.app'),
background: path.resolve(grunt.config('appDir'), 'build', 'resources', 'mac', 'DMG-background.png'), name: 'Mailspring',
icon: path.resolve(grunt.config('appDir'), 'build', 'resources', 'mac', 'mailspring.icns'), background: path.resolve(
overwrite: true, grunt.config('appDir'),
out: grunt.config('outputDir'), 'build',
}, (err) => { 'resources',
if (err) { 'mac',
done(err); 'DMG-background.png'
return ),
} icon: path.resolve(grunt.config('appDir'), 'build', 'resources', 'mac', 'mailspring.icns'),
overwrite: true,
out: grunt.config('outputDir'),
},
err => {
if (err) {
done(err);
return;
}
grunt.log.writeln(`>> Created ${dmgPath}`); grunt.log.writeln(`>> Created ${dmgPath}`);
done(null); done(null);
}) }
);
}); });
}; };

View file

@ -3,33 +3,36 @@
/* eslint quote-props: 0 */ /* eslint quote-props: 0 */
const path = require('path'); const path = require('path');
module.exports = (grunt) => { module.exports = grunt => {
const {spawn} = grunt.config('taskHelpers') const { spawn } = grunt.config('taskHelpers');
grunt.registerTask('create-mac-zip', 'Zip up Mailspring', function pack() { grunt.registerTask('create-mac-zip', 'Zip up Mailspring', function pack() {
const done = this.async(); const done = this.async();
const zipPath = path.join(grunt.config('outputDir'), 'Mailspring.zip'); const zipPath = path.join(grunt.config('outputDir'), 'Mailspring.zip');
if (grunt.file.exists(zipPath)) { if (grunt.file.exists(zipPath)) {
grunt.file.delete(zipPath, {force: true}); grunt.file.delete(zipPath, { force: true });
} }
const orig = process.cwd(); const orig = process.cwd();
process.chdir(path.join(grunt.config('outputDir'), 'Mailspring-darwin-x64')); process.chdir(path.join(grunt.config('outputDir'), 'Mailspring-darwin-x64'));
spawn({ spawn(
cmd: "zip", {
args: ["-9", "-y", "-r", "-9", "-X", zipPath, 'Mailspring.app'], cmd: 'zip',
}, (error) => { args: ['-9', '-y', '-r', '-9', '-X', zipPath, 'Mailspring.app'],
process.chdir(orig); },
error => {
process.chdir(orig);
if (error) { if (error) {
done(error); done(error);
return; return;
}
grunt.log.writeln(`>> Created ${zipPath}`);
done(null);
} }
);
grunt.log.writeln(`>> Created ${zipPath}`);
done(null);
});
}); });
}; };

View file

@ -1,4 +1,4 @@
module.exports = (grunt) => { module.exports = grunt => {
grunt.config.merge({ grunt.config.merge({
csslint: { csslint: {
options: { options: {
@ -11,9 +11,9 @@ module.exports = (grunt) => {
'display-property-grouping': false, 'display-property-grouping': false,
'fallback-colors': false, 'fallback-colors': false,
'font-sizes': false, 'font-sizes': false,
'gradients': false, gradients: false,
'ids': false, ids: false,
'important': false, important: false,
'known-properties': false, 'known-properties': false,
'outline-none': false, 'outline-none': false,
'overqualified-elements': false, 'overqualified-elements': false,
@ -23,11 +23,9 @@ module.exports = (grunt) => {
'vendor-prefix': false, 'vendor-prefix': false,
'duplicate-properties': false, // doesn't place nice with mixins 'duplicate-properties': false, // doesn't place nice with mixins
}, },
src: [ src: ['static/**/*.css'],
'static/**/*.css',
],
}, },
}); });
grunt.loadNpmTasks('grunt-contrib-csslint'); grunt.loadNpmTasks('grunt-contrib-csslint');
} };

View file

@ -10,19 +10,24 @@ const joanna = require('joanna');
const tello = require('tello'); const tello = require('tello');
module.exports = function(grunt) { module.exports = function(grunt) {
let { cp, mkdir, rm } = grunt.config('taskHelpers');
let {cp, mkdir, rm} = grunt.config('taskHelpers');
let getClassesToInclude = function() { let getClassesToInclude = function() {
let modulesPath = path.resolve(__dirname, '..', '..', 'internal_packages'); let modulesPath = path.resolve(__dirname, '..', '..', 'internal_packages');
let classes = {}; let classes = {};
fs.traverseTreeSync(modulesPath, function(modulePath) { fs.traverseTreeSync(modulesPath, function(modulePath) {
// Don't traverse inside dependencies // Don't traverse inside dependencies
if (modulePath.match(/node_modules/g)) { return false; } if (modulePath.match(/node_modules/g)) {
return false;
}
// Don't traverse blacklisted packages (that have docs, but we don't want to include) // Don't traverse blacklisted packages (that have docs, but we don't want to include)
if (path.basename(modulePath) !== 'package.json') { return true; } if (path.basename(modulePath) !== 'package.json') {
if (!fs.isFileSync(modulePath)) { return true; } return true;
}
if (!fs.isFileSync(modulePath)) {
return true;
}
let apiPath = path.join(path.dirname(modulePath), 'api.json'); let apiPath = path.join(path.dirname(modulePath), 'api.json');
if (fs.isFileSync(apiPath)) { if (fs.isFileSync(apiPath)) {
@ -42,12 +47,10 @@ module.exports = function(grunt) {
}; };
return grunt.registerTask('docs-build', 'Builds the API docs in src', function() { return grunt.registerTask('docs-build', 'Builds the API docs in src', function() {
grunt.log.writeln('Time to build the docs!');
grunt.log.writeln("Time to build the docs!")
let done = this.async(); let done = this.async();
let classDocsOutputDir = grunt.config.get('classDocsOutputDir'); let classDocsOutputDir = grunt.config.get('classDocsOutputDir');
let cjsxOutputDir = path.join(classDocsOutputDir, 'temp-cjsx'); let cjsxOutputDir = path.join(classDocsOutputDir, 'temp-cjsx');
@ -58,9 +61,7 @@ module.exports = function(grunt) {
let srcPath = path.resolve(__dirname, '..', '..', 'src'); let srcPath = path.resolve(__dirname, '..', '..', 'src');
const blacklist = ['/K2/', const blacklist = ['/K2/', 'legacy-edgehill-api', 'edgehill-api'];
'legacy-edgehill-api',
'edgehill-api'];
let in_blacklist = function(file) { let in_blacklist = function(file) {
for (var i = 0; i < blacklist.length; i++) { for (var i = 0; i < blacklist.length; i++) {
@ -72,90 +73,90 @@ module.exports = function(grunt) {
}; };
fs.traverseTreeSync(srcPath, function(file) { fs.traverseTreeSync(srcPath, function(file) {
if (in_blacklist(file)) { if (in_blacklist(file)) {
console.log("Skipping " + file); console.log('Skipping ' + file);
// Skip K2 // Skip K2
} } else if (path.extname(file) === '.cjsx') {
// Convert CJSX into coffeescript that can be read by Donna
// Convert CJSX into coffeescript that can be read by Donna
else if (path.extname(file) === '.cjsx') {
let transformed = cjsxtransform(grunt.file.read(file)); let transformed = cjsxtransform(grunt.file.read(file));
// Only attempt to parse this file as documentation if it contains // Only attempt to parse this file as documentation if it contains
// real Coffeescript classes. // real Coffeescript classes.
if (transformed.indexOf('\nclass ') > 0) { if (transformed.indexOf('\nclass ') > 0) {
grunt.log.writeln('Found class in file: ' + file);
grunt.log.writeln("Found class in file: " + file) grunt.file.write(
path.join(
grunt.file.write(path.join(cjsxOutputDir, path.basename(file).slice(0, -5 + 1 || undefined)+'coffee'), transformed); cjsxOutputDir,
path.basename(file).slice(0, -5 + 1 || undefined) + 'coffee'
),
transformed
);
} }
} } else if (path.extname(file) === '.jsx') {
else if (path.extname(file) === '.jsx') { console.log('Transforming ' + file);
console.log('Transforming ' + file)
let fileStr = grunt.file.read(file); let fileStr = grunt.file.read(file);
let transformed = require("babel-core").transform(fileStr, { let transformed = require('babel-core').transform(fileStr, {
plugins: ["transform-react-jsx", plugins: ['transform-react-jsx', 'transform-class-properties'],
"transform-class-properties"], presets: ['react', 'electron'],
presets: ['react', 'electron']
}); });
grunt.file.write(path.join(cjsxOutputDir, path.basename(file).slice(0, -3 || undefined)+'js'), transformed.code); grunt.file.write(
} path.join(cjsxOutputDir, path.basename(file).slice(0, -3 || undefined) + 'js'),
else if (path.extname(file) == '.es6') { transformed.code
console.log(file); );
} else if (path.extname(file) == '.es6') {
console.log(file);
let fileStr = grunt.file.read(file); let fileStr = grunt.file.read(file);
let transformed = require("babel-core").transform(fileStr, { let transformed = require('babel-core').transform(fileStr, {
plugins: ["transform-class-properties", plugins: ['transform-class-properties', 'transform-function-bind'],
"transform-function-bind"], presets: ['react', 'electron'],
presets: ['react', 'electron']
}); });
if (transformed.code.indexOf('class ') > 0) { if (transformed.code.indexOf('class ') > 0) {
grunt.log.writeln("Found class in file: " + file) grunt.log.writeln('Found class in file: ' + file);
grunt.file.write(path.join(cjsxOutputDir, path.basename(file).slice(0, -3 || undefined)+'js'), transformed.code); grunt.file.write(
path.join(cjsxOutputDir, path.basename(file).slice(0, -3 || undefined) + 'js'),
transformed.code
);
} }
} } else if (path.extname(file) == '.coffee' || path.extname(file) == '.js') {
else if (path.extname(file) == '.coffee' ||
path.extname(file) == '.js') {
let dest_path = path.join(cjsxOutputDir, path.basename(file)); let dest_path = path.join(cjsxOutputDir, path.basename(file));
console.log("Copying " + file + " to " + dest_path); console.log('Copying ' + file + ' to ' + dest_path);
fs_extra.copySync(file, dest_path); fs_extra.copySync(file, dest_path);
} }
return true; return true;
}); });
grunt.log.ok('Done transforming, starting donna extraction') grunt.log.ok('Done transforming, starting donna extraction');
grunt.log.writeln('cjsxOutputDir: ' + cjsxOutputDir) grunt.log.writeln('cjsxOutputDir: ' + cjsxOutputDir);
// Process coffeescript source // Process coffeescript source
let metadata = donna.generateMetadata([cjsxOutputDir]); let metadata = donna.generateMetadata([cjsxOutputDir]);
grunt.log.ok('---- Done with Donna (cjsx metadata)----'); grunt.log.ok('---- Done with Donna (cjsx metadata)----');
// DEBUG // DEBUG
// Use to check individual files // Use to check individual files
var js_files = [] var js_files = [];
fs.traverseTreeSync(cjsxOutputDir, function(file) { fs.traverseTreeSync(cjsxOutputDir, function(file) {
if (path.extname(file) === '.js') { if (path.extname(file) === '.js') {
console.log('testing joanna on ' + file) console.log('testing joanna on ' + file);
let meta = joanna([file]) let meta = joanna([file]);
console.log('testing tello on ' + file) console.log('testing tello on ' + file);
tello.digest(meta) tello.digest(meta);
console.log('passed') console.log('passed');
} }
}); });
var js_files = [] var js_files = [];
fs.traverseTreeSync(cjsxOutputDir, function(file) { fs.traverseTreeSync(cjsxOutputDir, function(file) {
if (path.extname(file) === '.js') { if (path.extname(file) === '.js') {
js_files.push(file.toString()) js_files.push(file.toString());
} }
}); });
@ -164,32 +165,28 @@ module.exports = function(grunt) {
let jsx_metadata = joanna(js_files); let jsx_metadata = joanna(js_files);
grunt.log.ok('---- Done with Joanna (jsx metadata)----'); grunt.log.ok('---- Done with Joanna (jsx metadata)----');
Object.assign(metadata[0].files, jsx_metadata.files); Object.assign(metadata[0].files, jsx_metadata.files);
console.log(metadata[0]); console.log(metadata[0]);
grunt.file.write('/tmp/metadata.json', JSON.stringify(metadata, null, 2)); grunt.file.write('/tmp/metadata.json', JSON.stringify(metadata, null, 2));
try { try {
api = tello.digest(metadata); api = tello.digest(metadata);
} catch (e) { } catch (e) {
console.log(e) console.log(e);
console.log(e.stack); console.log(e.stack);
console.log(metadata) console.log(metadata);
return; return;
} }
console.log('---- Done with Tello ----'); console.log('---- Done with Tello ----');
Object.assign(api.classes, getClassesToInclude()); Object.assign(api.classes, getClassesToInclude());
console.log(api.classes) console.log(api.classes);
api.classes = sortClasses(api.classes); api.classes = sortClasses(api.classes);
console.log(api.classes) console.log(api.classes);
let apiJson = JSON.stringify(api, null, 2); let apiJson = JSON.stringify(api, null, 2);
let apiJsonPath = path.join(classDocsOutputDir, 'api.json'); let apiJsonPath = path.join(classDocsOutputDir, 'api.json');
@ -197,6 +194,4 @@ module.exports = function(grunt) {
return done(); return done();
}); });
}); });
}; };

View file

@ -7,10 +7,11 @@ const _ = require('underscore');
marked.setOptions({ marked.setOptions({
highlight(code) { highlight(code) {
return require('highlight.js').highlightAuto(code).value; return require('highlight.js').highlightAuto(code).value;
} },
}); });
let standardClassURLRoot = 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/'; let standardClassURLRoot =
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/';
let standardClasses = [ let standardClasses = [
'string', 'string',
@ -29,22 +30,21 @@ let standardClasses = [
'typeerror', 'typeerror',
'syntaxerror', 'syntaxerror',
'referenceerror', 'referenceerror',
'rangeerror' 'rangeerror',
]; ];
let thirdPartyClasses = { let thirdPartyClasses = {
'react.component': 'https://facebook.github.io/react/docs/component-api.html', 'react.component': 'https://facebook.github.io/react/docs/component-api.html',
'promise': 'https://github.com/petkaantonov/bluebird/blob/master/API.md', promise: 'https://github.com/petkaantonov/bluebird/blob/master/API.md',
'range': 'https://developer.mozilla.org/en-US/docs/Web/API/Range', range: 'https://developer.mozilla.org/en-US/docs/Web/API/Range',
'selection': 'https://developer.mozilla.org/en-US/docs/Web/API/Selection', selection: 'https://developer.mozilla.org/en-US/docs/Web/API/Selection',
'node': 'https://developer.mozilla.org/en-US/docs/Web/API/Node', node: 'https://developer.mozilla.org/en-US/docs/Web/API/Node',
}; };
module.exports = function(grunt) { module.exports = function(grunt) {
let { cp, mkdir, rm } = grunt.config('taskHelpers');
let {cp, mkdir, rm} = grunt.config('taskHelpers'); let relativePathForClass = classname => classname + '.html';
let relativePathForClass = classname => classname+'.html';
let outputPathFor = function(relativePath) { let outputPathFor = function(relativePath) {
let classDocsOutputDir = grunt.config.get('classDocsOutputDir'); let classDocsOutputDir = grunt.config.get('classDocsOutputDir');
@ -53,8 +53,12 @@ module.exports = function(grunt) {
var processFields = function(json, fields, tasks) { var processFields = function(json, fields, tasks) {
let val; let val;
if (fields == null) { fields = []; } if (fields == null) {
if (tasks == null) { tasks = []; } fields = [];
}
if (tasks == null) {
tasks = [];
}
if (json instanceof Array) { if (json instanceof Array) {
return (() => { return (() => {
let result = []; let result = [];
@ -86,7 +90,6 @@ module.exports = function(grunt) {
}; };
return grunt.registerTask('docs-render', 'Builds html from the API docs', function() { return grunt.registerTask('docs-render', 'Builds html from the API docs', function() {
let documentation, filename, html, match, meta, name, result, section, val; let documentation, filename, html, match, meta, name, result, section, val;
let classDocsOutputDir = grunt.config.get('classDocsOutputDir'); let classDocsOutputDir = grunt.config.get('classDocsOutputDir');
@ -96,7 +99,6 @@ module.exports = function(grunt) {
let apiJsonPath = path.join(classDocsOutputDir, 'api.json'); let apiJsonPath = path.join(classDocsOutputDir, 'api.json');
let apiJSON = JSON.parse(grunt.file.read(apiJsonPath)); let apiJSON = JSON.parse(grunt.file.read(apiJsonPath));
for (var classname in apiJSON.classes) { for (var classname in apiJSON.classes) {
// Parse a "@Section" out of the description if one is present // Parse a "@Section" out of the description if one is present
let contents = apiJSON.classes[classname]; let contents = apiJSON.classes[classname];
@ -111,36 +113,34 @@ module.exports = function(grunt) {
// Replace superClass "React" with "React.Component". The Coffeescript Lexer // Replace superClass "React" with "React.Component". The Coffeescript Lexer
// is so bad. // is so bad.
if (contents.superClass === "React") { if (contents.superClass === 'React') {
contents.superClass = "React.Component"; contents.superClass = 'React.Component';
} }
classes.push({ classes.push({
name: classname, name: classname,
documentation: contents, documentation: contents,
section section,
}); });
} }
// Build Sidebar metadata we can hand off to each of the templates to // Build Sidebar metadata we can hand off to each of the templates to
// generate the sidebar // generate the sidebar
let sidebar = {}; let sidebar = {};
for (var i = 0; i < classes.length; i++) { for (var i = 0; i < classes.length; i++) {
var current_class = classes[i]; var current_class = classes[i];
console.log(current_class.name + ' ' + current_class.section) console.log(current_class.name + ' ' + current_class.section);
if (!(current_class.section in sidebar)) { if (!(current_class.section in sidebar)) {
sidebar[current_class.section] = [] sidebar[current_class.section] = [];
} }
sidebar[current_class.section].push(current_class.name) sidebar[current_class.section].push(current_class.name);
} }
// Prepare to render by loading handlebars partials // Prepare to render by loading handlebars partials
let templatesPath = path.resolve(grunt.config('buildDir'), 'docs_templates'); let templatesPath = path.resolve(grunt.config('buildDir'), 'docs_templates');
grunt.file.recurse(templatesPath, function(abspath, root, subdir, filename) { grunt.file.recurse(templatesPath, function(abspath, root, subdir, filename) {
if ((filename[0] === '_') && (path.extname(filename) === '.html')) { if (filename[0] === '_' && path.extname(filename) === '.html') {
return Handlebars.registerPartial(filename, grunt.file.read(abspath)); return Handlebars.registerPartial(filename, grunt.file.read(abspath));
} }
}); });
@ -153,7 +153,6 @@ module.exports = function(grunt) {
knownClassnames[classname.toLowerCase()] = val; knownClassnames[classname.toLowerCase()] = val;
} }
let expandTypeReferences = function(val) { let expandTypeReferences = function(val) {
let refRegex = /{([\w.]*)}/g; let refRegex = /{([\w.]*)}/g;
while ((match = refRegex.exec(val)) !== null) { while ((match = refRegex.exec(val)) !== null) {
@ -161,12 +160,12 @@ module.exports = function(grunt) {
let label = match[1]; let label = match[1];
let url = false; let url = false;
if (Array.from(standardClasses).includes(term)) { if (Array.from(standardClasses).includes(term)) {
url = standardClassURLRoot+term; url = standardClassURLRoot + term;
} else if (thirdPartyClasses[term]) { } else if (thirdPartyClasses[term]) {
url = thirdPartyClasses[term]; url = thirdPartyClasses[term];
} else if (knownClassnames[term]) { } else if (knownClassnames[term]) {
url = relativePathForClass(knownClassnames[term].name); url = relativePathForClass(knownClassnames[term].name);
grunt.log.ok("Found: " + term) grunt.log.ok('Found: ' + term);
} else { } else {
console.warn(`Cannot find class named ${term}`); console.warn(`Cannot find class named ${term}`);
} }
@ -205,28 +204,26 @@ module.exports = function(grunt) {
let classTemplatePath = path.join(templatesPath, 'class.md'); let classTemplatePath = path.join(templatesPath, 'class.md');
let classTemplate = Handlebars.compile(grunt.file.read(classTemplatePath)); let classTemplate = Handlebars.compile(grunt.file.read(classTemplatePath));
for ({name, documentation, section} of Array.from(classes)) { for ({ name, documentation, section } of Array.from(classes)) {
// Recursively process `description` and `type` fields to process markdown, // Recursively process `description` and `type` fields to process markdown,
// expand references to types, functions and other files. // expand references to types, functions and other files.
processFields(documentation, ['description'], [expandFuncReferences]); processFields(documentation, ['description'], [expandFuncReferences]);
processFields(documentation, ['type'], [expandTypeReferences]); processFields(documentation, ['type'], [expandTypeReferences]);
result = classTemplate({name, documentation, section}); result = classTemplate({ name, documentation, section });
grunt.file.write(outputPathFor(name + '.md'), result); grunt.file.write(outputPathFor(name + '.md'), result);
} }
let sidebarTemplatePath = path.join(templatesPath, 'sidebar.md'); let sidebarTemplatePath = path.join(templatesPath, 'sidebar.md');
let sidebarTemplate = Handlebars.compile(grunt.file.read(sidebarTemplatePath)); let sidebarTemplate = Handlebars.compile(grunt.file.read(sidebarTemplatePath));
grunt.file.write(outputPathFor('Sidebar.md'), grunt.file.write(outputPathFor('Sidebar.md'), sidebarTemplate({ sidebar }));
sidebarTemplate({sidebar}));
// Remove temp cjsx output // Remove temp cjsx output
return fs.removeSync(outputPathFor("temp-cjsx")); return fs.removeSync(outputPathFor('temp-cjsx'));
}); });
}; };
function __guard__(value, transform) { function __guard__(value, transform) {
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined; return typeof value !== 'undefined' && value !== null ? transform(value) : undefined;
} }

View file

@ -1,7 +1,7 @@
const chalk = require('chalk'); const chalk = require('chalk');
const eslint = require('eslint'); const eslint = require('eslint');
module.exports = (grunt) => { module.exports = grunt => {
grunt.config.merge({ grunt.config.merge({
eslint: { eslint: {
options: { options: {

View file

@ -3,8 +3,8 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const _ = require('underscore'); const _ = require('underscore');
module.exports = (grunt) => { module.exports = grunt => {
const {spawn} = grunt.config('taskHelpers'); const { spawn } = grunt.config('taskHelpers');
const outputDir = grunt.config.get('outputDir'); const outputDir = grunt.config.get('outputDir');
const contentsDir = path.join(grunt.config('outputDir'), `mailspring-linux-${process.arch}`); const contentsDir = path.join(grunt.config('outputDir'), `mailspring-linux-${process.arch}`);
@ -17,23 +17,23 @@ module.exports = (grunt) => {
// a few helpers // a few helpers
const writeFromTemplate = (filePath, data) => { const writeFromTemplate = (filePath, data) => {
const template = _.template(String(fs.readFileSync(filePath))) const template = _.template(String(fs.readFileSync(filePath)));
const finishedPath = path.join(outputDir, path.basename(filePath).replace('.in', '')); const finishedPath = path.join(outputDir, path.basename(filePath).replace('.in', ''));
grunt.file.write(finishedPath, template(data)); grunt.file.write(finishedPath, template(data));
return finishedPath; return finishedPath;
} };
const getInstalledSize = (dir, callback) => { const getInstalledSize = (dir, callback) => {
const cmd = 'du'; const cmd = 'du';
const args = ['-sk', dir]; const args = ['-sk', dir];
spawn({cmd, args}, (error, {stdout}) => { spawn({ cmd, args }, (error, { stdout }) => {
const installedSize = stdout.split(/\s+/).shift() || '200000'; // default to 200MB const installedSize = stdout.split(/\s+/).shift() || '200000'; // default to 200MB
callback(null, installedSize); callback(null, installedSize);
}); });
} };
grunt.registerTask('create-rpm-installer', 'Create rpm package', function mkrpmf() { grunt.registerTask('create-rpm-installer', 'Create rpm package', function mkrpmf() {
const done = this.async() const done = this.async();
if (!arch) { if (!arch) {
done(new Error(`Unsupported arch ${process.arch}`)); done(new Error(`Unsupported arch ${process.arch}`));
return; return;
@ -41,7 +41,7 @@ module.exports = (grunt) => {
const rpmDir = path.join(grunt.config('outputDir'), 'rpm'); const rpmDir = path.join(grunt.config('outputDir'), 'rpm');
if (grunt.file.exists(rpmDir)) { if (grunt.file.exists(rpmDir)) {
grunt.file.delete(rpmDir, {force: true}); grunt.file.delete(rpmDir, { force: true });
} }
const templateData = { const templateData = {
@ -52,19 +52,19 @@ module.exports = (grunt) => {
linuxShareDir: '/usr/local/share/mailspring', linuxShareDir: '/usr/local/share/mailspring',
linuxAssetsDir: linuxAssetsDir, linuxAssetsDir: linuxAssetsDir,
contentsDir: contentsDir, contentsDir: contentsDir,
} };
// This populates mailspring.spec // This populates mailspring.spec
const specInFilePath = path.join(linuxAssetsDir, 'redhat', 'mailspring.spec.in') const specInFilePath = path.join(linuxAssetsDir, 'redhat', 'mailspring.spec.in');
writeFromTemplate(specInFilePath, templateData) writeFromTemplate(specInFilePath, templateData);
// This populates mailspring.desktop // This populates mailspring.desktop
const desktopInFilePath = path.join(linuxAssetsDir, 'mailspring.desktop.in') const desktopInFilePath = path.join(linuxAssetsDir, 'mailspring.desktop.in');
writeFromTemplate(desktopInFilePath, templateData) writeFromTemplate(desktopInFilePath, templateData);
const cmd = path.join(grunt.config('appDir'), 'script', 'mkrpm') const cmd = path.join(grunt.config('appDir'), 'script', 'mkrpm');
const args = [outputDir, contentsDir, linuxAssetsDir] const args = [outputDir, contentsDir, linuxAssetsDir];
spawn({cmd, args}, (error) => { spawn({ cmd, args }, error => {
if (error) { if (error) {
return done(error); return done(error);
} }
@ -74,7 +74,7 @@ module.exports = (grunt) => {
}); });
grunt.registerTask('create-deb-installer', 'Create debian package', function mkdebf() { grunt.registerTask('create-deb-installer', 'Create debian package', function mkdebf() {
const done = this.async() const done = this.async();
if (!arch) { if (!arch) {
done(`Unsupported arch ${process.arch}`); done(`Unsupported arch ${process.arch}`);
return; return;
@ -97,20 +97,20 @@ module.exports = (grunt) => {
section: 'devel', section: 'devel',
maintainer: 'Mailspring Team <support@getmailspring.com>', maintainer: 'Mailspring Team <support@getmailspring.com>',
installedSize: installedSize, installedSize: installedSize,
} };
writeFromTemplate(path.join(linuxAssetsDir, 'debian', 'control.in'), data) writeFromTemplate(path.join(linuxAssetsDir, 'debian', 'control.in'), data);
writeFromTemplate(path.join(linuxAssetsDir, 'mailspring.desktop.in'), data) writeFromTemplate(path.join(linuxAssetsDir, 'mailspring.desktop.in'), data);
const icon = path.join(grunt.config('appDir'), 'build', 'resources', 'mailspring.png') const icon = path.join(grunt.config('appDir'), 'build', 'resources', 'mailspring.png');
const cmd = path.join(grunt.config('appDir'), 'script', 'mkdeb'); const cmd = path.join(grunt.config('appDir'), 'script', 'mkdeb');
const args = [version, arch, icon, linuxAssetsDir, contentsDir, outputDir]; const args = [version, arch, icon, linuxAssetsDir, contentsDir, outputDir];
spawn({cmd, args}, (spawnError) => { spawn({ cmd, args }, spawnError => {
if (spawnError) { if (spawnError) {
return done(spawnError); return done(spawnError);
} }
grunt.log.ok(`Created ${outputDir}/mailspring-${version}-${arch}.deb`); grunt.log.ok(`Created ${outputDir}/mailspring-${version}-${arch}.deb`);
return done() return done();
}); });
}); });
}); });
} };

View file

@ -1,11 +1,7 @@
module.exports = (grunt) => { module.exports = grunt => {
grunt.config.merge({ grunt.config.merge({
lesslint: { lesslint: {
src: [ src: ['internal_packages/**/*.less', 'dot-nylas/**/*.less', 'static/**/*.less'],
'internal_packages/**/*.less',
'dot-nylas/**/*.less',
'static/**/*.less',
],
options: { options: {
less: { less: {
paths: ['static', 'static/variables/'], paths: ['static', 'static/variables/'],
@ -17,4 +13,4 @@ module.exports = (grunt) => {
grunt.loadNpmTasks('grunt-contrib-less'); grunt.loadNpmTasks('grunt-contrib-less');
grunt.loadNpmTasks('grunt-lesslint'); grunt.loadNpmTasks('grunt-lesslint');
} };

View file

@ -3,159 +3,175 @@ const path = require('path');
const fs = require('fs-plus'); const fs = require('fs-plus');
function normalizeRequirePath(requirePath, fPath) { function normalizeRequirePath(requirePath, fPath) {
if (requirePath[0] === ".") { if (requirePath[0] === '.') {
return path.normalize(path.join(path.dirname(fPath), requirePath)); return path.normalize(path.join(path.dirname(fPath), requirePath));
} }
return requirePath; return requirePath;
} }
module.exports = grunt => {
module.exports = (grunt) => {
grunt.config.merge({ grunt.config.merge({
nylaslint: { nylaslint: {
src: grunt.config('source:coffeescript').concat(grunt.config('source:es6')), src: grunt.config('source:coffeescript').concat(grunt.config('source:es6')),
}, },
}); });
grunt.registerMultiTask('nylaslint', 'Check requires for file extensions compiled away', function nylaslint() { grunt.registerMultiTask(
const done = this.async(); 'nylaslint',
'Check requires for file extensions compiled away',
function nylaslint() {
const done = this.async();
// Enable once path errors are fixed. // Enable once path errors are fixed.
if (process.platform === 'win32') { if (process.platform === 'win32') {
done(); done();
return; return;
}
const extensionRegex = /require ['"].*\.(coffee|cjsx|jsx|es6|es)['"]/i;
for (const fileset of this.files) {
grunt.log.writeln(`Nylinting ${fileset.src.length} files.`);
const esExtensions = {
".es6": true,
".es": true,
".jsx": true,
};
const errors = [];
const esExport = {};
const esNoExport = {};
const esExportDefault = {};
// Temp TODO. Fix spec files
for (const f of fileset.src) {
if (!esExtensions[path.extname(f)]) { continue; }
if (!/-spec/.test(f)) { continue; }
const content = fs.readFileSync(f, {encoding: 'utf8'});
// https://regex101.com/r/rQ3eD0/1
// Matches only the first describe block
const describeRe = /[\n]describe\(['"](.*?)['"], ?\(\) ?=> ?/m;
if (describeRe.test(content)) {
errors.push(`${f}: Spec has to start with function`);
}
} }
// NOTE: Comment me in if you want to fix these files. const extensionRegex = /require ['"].*\.(coffee|cjsx|jsx|es6|es)['"]/i;
// _str = require('underscore.string')
// replacer = (match, describeName) ->
// fnName = _str.camelize(describeName, true)
// return "\ndescribe('#{describeName}', function #{fnName}() "
// newContent = content.replace(describeRe, replacer)
// fs.writeFileSync(f, newContent, encoding:'utf8')
// Build the list of ES6 files that export things and categorize for (const fileset of this.files) {
for (const f of fileset.src) { grunt.log.writeln(`Nylinting ${fileset.src.length} files.`);
if (!esExtensions[path.extname(f)]) { continue; }
const lookupPath = `${path.dirname(f)}/${path.basename(f, path.extname(f))}`;
const content = fs.readFileSync(f, {encoding: 'utf8'});
if (/module.exports\s?=\s?.+/gmi.test(content)) { const esExtensions = {
if (!f.endsWith('nylas-exports.es6')) { '.es6': true,
errors.push(`${f}: Don't use module.exports in ES6`); '.es': true,
'.jsx': true,
};
const errors = [];
const esExport = {};
const esNoExport = {};
const esExportDefault = {};
// Temp TODO. Fix spec files
for (const f of fileset.src) {
if (!esExtensions[path.extname(f)]) {
continue;
}
if (!/-spec/.test(f)) {
continue;
}
const content = fs.readFileSync(f, { encoding: 'utf8' });
// https://regex101.com/r/rQ3eD0/1
// Matches only the first describe block
const describeRe = /[\n]describe\(['"](.*?)['"], ?\(\) ?=> ?/m;
if (describeRe.test(content)) {
errors.push(`${f}: Spec has to start with function`);
} }
} }
if (/^export/gmi.test(content)) { // NOTE: Comment me in if you want to fix these files.
if (/^export default/gmi.test(content)) { // _str = require('underscore.string')
esExportDefault[lookupPath] = true; // replacer = (match, describeName) ->
} else { // fnName = _str.camelize(describeName, true)
esExport[lookupPath] = true; // return "\ndescribe('#{describeName}', function #{fnName}() "
// newContent = content.replace(describeRe, replacer)
// fs.writeFileSync(f, newContent, encoding:'utf8')
// Build the list of ES6 files that export things and categorize
for (const f of fileset.src) {
if (!esExtensions[path.extname(f)]) {
continue;
} }
} else { const lookupPath = `${path.dirname(f)}/${path.basename(f, path.extname(f))}`;
esNoExport[lookupPath] = true; const content = fs.readFileSync(f, { encoding: 'utf8' });
}
}
// Now look again through all ES6 files, this time to check imports if (/module.exports\s?=\s?.+/gim.test(content)) {
// instead of exports. if (!f.endsWith('nylas-exports.es6')) {
for (const f of fileset.src) { errors.push(`${f}: Don't use module.exports in ES6`);
let result = null;
if (!esExtensions[path.extname(f)]) {
continue;
}
const content = fs.readFileSync(f, {encoding: 'utf8'});
const importRe = /import \{.*\} from ['"](.*?)['"]/gmi;
while (result = importRe.exec(content)) {
for (const requirePath of result.slice(1)) {
const lookupPath = normalizeRequirePath(requirePath, f);
if (esExportDefault[lookupPath] || esNoExport[lookupPath]) {
errors.push(`${f}: Don't destructure default export ${requirePath}`);
} }
} }
}
}
// Now look through all coffeescript files if (/^export/gim.test(content)) {
// If they require things from ES6 files, ensure they're using the if (/^export default/gim.test(content)) {
// proper syntax. esExportDefault[lookupPath] = true;
for (const f of fileset.src) {
let result = null;
if (esExtensions[path.extname(f)]) {
continue;
}
const content = fs.readFileSync(f, {encoding: 'utf8'});
if (extensionRegex.test(content)) {
errors.push(`${f}: Remove extensions when requiring files`);
}
const requireRe = /require[ (]['"]([\w_./-]*?)['"]/gmi;
while (result = requireRe.exec(content)) {
for (const requirePath of result.slice(1)) {
const lookupPath = normalizeRequirePath(requirePath, f);
const baseRequirePath = path.basename(requirePath);
const plainRequireRe = new RegExp(`require[ (]['"].*${baseRequirePath}['"]\\)?$`, "gm");
const defaultRequireRe = new RegExp(`require\\(['"].*${baseRequirePath}['"]\\)\\.default`, "gm");
if (esExport[lookupPath]) {
if (!plainRequireRe.test(content)) {
errors.push(`${f}: No \`default\` exported ${requirePath}`);
}
} else if (esNoExport[lookupPath]) {
errors.push(`${f}: Nothing exported from ${requirePath}`);
} else if (esExportDefault[lookupPath]) {
if (!defaultRequireRe.test(content)) {
errors.push(`${f}: Add \`default\` to require ${requirePath}`);
}
} else { } else {
// must be a coffeescript or core file esExport[lookupPath] = true;
if (defaultRequireRe.test(content)) { }
errors.push(`${f}: Don't ask for \`default\` from ${requirePath}`); } else {
esNoExport[lookupPath] = true;
}
}
// Now look again through all ES6 files, this time to check imports
// instead of exports.
for (const f of fileset.src) {
let result = null;
if (!esExtensions[path.extname(f)]) {
continue;
}
const content = fs.readFileSync(f, { encoding: 'utf8' });
const importRe = /import \{.*\} from ['"](.*?)['"]/gim;
while ((result = importRe.exec(content))) {
for (const requirePath of result.slice(1)) {
const lookupPath = normalizeRequirePath(requirePath, f);
if (esExportDefault[lookupPath] || esNoExport[lookupPath]) {
errors.push(`${f}: Don't destructure default export ${requirePath}`);
} }
} }
} }
} }
}
if (errors.length > 0) { // Now look through all coffeescript files
for (const err of errors) { grunt.log.error(err); } // If they require things from ES6 files, ensure they're using the
const error = ` // proper syntax.
for (const f of fileset.src) {
let result = null;
if (esExtensions[path.extname(f)]) {
continue;
}
const content = fs.readFileSync(f, { encoding: 'utf8' });
if (extensionRegex.test(content)) {
errors.push(`${f}: Remove extensions when requiring files`);
}
const requireRe = /require[ (]['"]([\w_./-]*?)['"]/gim;
while ((result = requireRe.exec(content))) {
for (const requirePath of result.slice(1)) {
const lookupPath = normalizeRequirePath(requirePath, f);
const baseRequirePath = path.basename(requirePath);
const plainRequireRe = new RegExp(
`require[ (]['"].*${baseRequirePath}['"]\\)?$`,
'gm'
);
const defaultRequireRe = new RegExp(
`require\\(['"].*${baseRequirePath}['"]\\)\\.default`,
'gm'
);
if (esExport[lookupPath]) {
if (!plainRequireRe.test(content)) {
errors.push(`${f}: No \`default\` exported ${requirePath}`);
}
} else if (esNoExport[lookupPath]) {
errors.push(`${f}: Nothing exported from ${requirePath}`);
} else if (esExportDefault[lookupPath]) {
if (!defaultRequireRe.test(content)) {
errors.push(`${f}: Add \`default\` to require ${requirePath}`);
}
} else {
// must be a coffeescript or core file
if (defaultRequireRe.test(content)) {
errors.push(`${f}: Don't ask for \`default\` from ${requirePath}`);
}
}
}
}
}
if (errors.length > 0) {
for (const err of errors) {
grunt.log.error(err);
}
const error = `
Please fix the #{errors.length} linter errors above. These are the issues we're looking for: Please fix the #{errors.length} linter errors above. These are the issues we're looking for:
ISSUES WITH COFFEESCRIPT FILES: ISSUES WITH COFFEESCRIPT FILES:
@ -180,10 +196,11 @@ module.exports = (grunt) => {
6. Spec has to start with function 6. Spec has to start with function
Top-level "describe" blocks can no longer use the "() => {}" function syntax. This will incorrectly bind "this" to the "window" object instead of the jasmine object. The top-level "describe" block must use the "function describeName() {}" syntax Top-level "describe" blocks can no longer use the "() => {}" function syntax. This will incorrectly bind "this" to the "window" object instead of the jasmine object. The top-level "describe" block must use the "function describeName() {}" syntax
`; `;
done(new Error(error)); done(new Error(error));
}
} }
}
done(null); done(null);
}); }
} );
};

View file

@ -1,4 +1,4 @@
/* eslint global-require: 0 *//* eslint prefer-template: 0 */ /* eslint global-require: 0 */ /* eslint prefer-template: 0 */
/* eslint quote-props: 0 */ /* eslint quote-props: 0 */
const packager = require('electron-packager'); const packager = require('electron-packager');
const path = require('path'); const path = require('path');
@ -8,13 +8,13 @@ const fs = require('fs-plus');
const coffeereact = require('coffee-react'); const coffeereact = require('coffee-react');
const glob = require('glob'); const glob = require('glob');
const babel = require('babel-core'); const babel = require('babel-core');
const {execSync} = require('child_process'); const { execSync } = require('child_process');
const symlinkedPackages = [] const symlinkedPackages = [];
module.exports = (grunt) => { module.exports = grunt => {
const packageJSON = grunt.config('appJSON'); const packageJSON = grunt.config('appJSON');
const babelPath = path.join(grunt.config('rootDir'), '.babelrc') const babelPath = path.join(grunt.config('rootDir'), '.babelrc');
const babelOptions = JSON.parse(fs.readFileSync(babelPath)) const babelOptions = JSON.parse(fs.readFileSync(babelPath));
function runCopyPlatformSpecificResources(buildPath, electronVersion, platform, arch, callback) { function runCopyPlatformSpecificResources(buildPath, electronVersion, platform, arch, callback) {
// these files (like nylas-mailto-default.reg) go alongside the ASAR, // these files (like nylas-mailto-default.reg) go alongside the ASAR,
@ -41,33 +41,28 @@ module.exports = (grunt) => {
* for the symlink copy function to use after the packaging is complete. * for the symlink copy function to use after the packaging is complete.
*/ */
function resolveRealSymlinkPaths(appDir) { function resolveRealSymlinkPaths(appDir) {
console.log("---> Resolving symlinks"); console.log('---> Resolving symlinks');
const dirs = [ const dirs = ['internal_packages', 'src', 'spec', 'node_modules'];
'internal_packages',
'src',
'spec',
'node_modules',
];
dirs.forEach((dir) => { dirs.forEach(dir => {
const absoluteDir = path.join(appDir, dir); const absoluteDir = path.join(appDir, dir);
fs.readdirSync(absoluteDir).forEach((packageName) => { fs.readdirSync(absoluteDir).forEach(packageName => {
const relativePackageDir = path.join(dir, packageName) const relativePackageDir = path.join(dir, packageName);
const absolutePackageDir = path.join(absoluteDir, packageName) const absolutePackageDir = path.join(absoluteDir, packageName);
const realPackagePath = fs.realpathSync(absolutePackageDir).replace('/private/', '/') const realPackagePath = fs.realpathSync(absolutePackageDir).replace('/private/', '/');
if (realPackagePath !== absolutePackageDir) { if (realPackagePath !== absolutePackageDir) {
console.log(` ---> Resolving '${relativePackageDir}' to '${realPackagePath}'`) console.log(` ---> Resolving '${relativePackageDir}' to '${realPackagePath}'`);
symlinkedPackages.push({realPackagePath, relativePackageDir}) symlinkedPackages.push({ realPackagePath, relativePackageDir });
} }
}); });
}); });
} }
function runCopySymlinkedPackages(buildPath, electronVersion, platform, arch, callback) { function runCopySymlinkedPackages(buildPath, electronVersion, platform, arch, callback) {
console.log("---> Moving symlinked node modules / internal packages into build folder.") console.log('---> Moving symlinked node modules / internal packages into build folder.');
symlinkedPackages.forEach(({realPackagePath, relativePackageDir}) => { symlinkedPackages.forEach(({ realPackagePath, relativePackageDir }) => {
const packagePath = path.join(buildPath, relativePackageDir) const packagePath = path.join(buildPath, relativePackageDir);
console.log(` ---> Copying ${realPackagePath} to ${packagePath}`); console.log(` ---> Copying ${realPackagePath} to ${packagePath}`);
fs.removeSync(packagePath); fs.removeSync(packagePath);
fs.copySync(realPackagePath, packagePath); fs.copySync(realPackagePath, packagePath);
@ -77,13 +72,13 @@ module.exports = (grunt) => {
} }
function runTranspilers(buildPath, electronVersion, platform, arch, callback) { function runTranspilers(buildPath, electronVersion, platform, arch, callback) {
console.log("---> Running babel and coffeescript transpilers") console.log('---> Running babel and coffeescript transpilers');
grunt.config('source:coffeescript').forEach(pattern => { grunt.config('source:coffeescript').forEach(pattern => {
glob.sync(pattern, {cwd: buildPath}).forEach((relPath) => { glob.sync(pattern, { cwd: buildPath }).forEach(relPath => {
const coffeepath = path.join(buildPath, relPath) const coffeepath = path.join(buildPath, relPath);
if (/(node_modules|\.js$)/.test(coffeepath)) return if (/(node_modules|\.js$)/.test(coffeepath)) return;
console.log(` ---> Compiling ${coffeepath.slice(coffeepath.indexOf("/app") + 4)}`) console.log(` ---> Compiling ${coffeepath.slice(coffeepath.indexOf('/app') + 4)}`);
const outPath = coffeepath.replace(path.extname(coffeepath), '.js'); const outPath = coffeepath.replace(path.extname(coffeepath), '.js');
const res = coffeereact.compile(grunt.file.read(coffeepath), { const res = coffeereact.compile(grunt.file.read(coffeepath), {
bare: false, bare: false,
@ -95,25 +90,34 @@ module.exports = (grunt) => {
generatedFile: path.basename(outPath), generatedFile: path.basename(outPath),
sourceFiles: [path.relative(buildPath, coffeepath)], sourceFiles: [path.relative(buildPath, coffeepath)],
}); });
grunt.file.write(outPath, `${res.js}\n//# sourceMappingURL=${path.basename(outPath)}.map\n`); grunt.file.write(
outPath,
`${res.js}\n//# sourceMappingURL=${path.basename(outPath)}.map\n`
);
grunt.file.write(`${outPath}.map`, res.v3SourceMap); grunt.file.write(`${outPath}.map`, res.v3SourceMap);
fs.unlinkSync(coffeepath); fs.unlinkSync(coffeepath);
}); });
}); });
grunt.config('source:es6').forEach(pattern => { grunt.config('source:es6').forEach(pattern => {
glob.sync(pattern, {cwd: buildPath}).forEach((relPath) => { glob.sync(pattern, { cwd: buildPath }).forEach(relPath => {
const es6Path = path.join(buildPath, relPath) const es6Path = path.join(buildPath, relPath);
if (/(node_modules|\.js$)/.test(es6Path)) return if (/(node_modules|\.js$)/.test(es6Path)) return;
const outPath = es6Path.replace(path.extname(es6Path), '.js'); const outPath = es6Path.replace(path.extname(es6Path), '.js');
console.log(` ---> Compiling ${es6Path.slice(es6Path.indexOf("/app") + 4)}`) console.log(` ---> Compiling ${es6Path.slice(es6Path.indexOf('/app') + 4)}`);
const res = babel.transformFileSync(es6Path, Object.assign(babelOptions, { const res = babel.transformFileSync(
sourceMaps: true, es6Path,
sourceRoot: '/', Object.assign(babelOptions, {
sourceMapTarget: path.relative(buildPath, outPath), sourceMaps: true,
sourceFileName: path.relative(buildPath, es6Path), sourceRoot: '/',
})); sourceMapTarget: path.relative(buildPath, outPath),
grunt.file.write(outPath, `${res.code}\n//# sourceMappingURL=${path.basename(outPath)}.map\n`); sourceFileName: path.relative(buildPath, es6Path),
})
);
grunt.file.write(
outPath,
`${res.code}\n//# sourceMappingURL=${path.basename(outPath)}.map\n`
);
grunt.file.write(`${outPath}.map`, JSON.stringify(res.map)); grunt.file.write(`${outPath}.map`, JSON.stringify(res.map));
fs.unlinkSync(es6Path); fs.unlinkSync(es6Path);
}); });
@ -129,21 +133,30 @@ module.exports = (grunt) => {
packager: { packager: {
appVersion: packageJSON.version, appVersion: packageJSON.version,
platform: platform, platform: platform,
protocols: [{ protocols: [
name: "Mailspring Protocol", {
schemes: ["mailspring"], name: 'Mailspring Protocol',
}, { schemes: ['mailspring'],
name: "Mailto Protocol", },
schemes: ["mailto"], {
}], name: 'Mailto Protocol',
schemes: ['mailto'],
},
],
dir: grunt.config('appDir'), dir: grunt.config('appDir'),
appCategoryType: "public.app-category.business", appCategoryType: 'public.app-category.business',
tmpdir: tmpdir, tmpdir: tmpdir,
arch: { arch: {
'win32': 'ia32', win32: 'ia32',
}[platform], }[platform],
icon: { icon: {
darwin: path.resolve(grunt.config('appDir'), 'build', 'resources', 'mac', 'mailspring.icns'), darwin: path.resolve(
grunt.config('appDir'),
'build',
'resources',
'mac',
'mailspring.icns'
),
win32: path.resolve(grunt.config('appDir'), 'build', 'resources', 'win', 'mailspring.ico'), win32: path.resolve(grunt.config('appDir'), 'build', 'resources', 'win', 'mailspring.ico'),
linux: undefined, linux: undefined,
}[platform], }[platform],
@ -155,19 +168,23 @@ module.exports = (grunt) => {
appCopyright: `Copyright (C) 2014-${new Date().getFullYear()} Foundry 376, LLC. All rights reserved.`, appCopyright: `Copyright (C) 2014-${new Date().getFullYear()} Foundry 376, LLC. All rights reserved.`,
derefSymlinks: false, derefSymlinks: false,
asar: { asar: {
'unpack': "{" + [ unpack:
'mailsync', '{' +
'mailsync.exe', [
'*.dll', 'mailsync',
'*.node', 'mailsync.exe',
'**/vendor/**', '*.dll',
'examples/**', '*.node',
'**/src/tasks/**', '**/vendor/**',
'**/node_modules/spellchecker/**', 'examples/**',
'**/node_modules/windows-shortcuts/**', '**/src/tasks/**',
].join(',') + "}", '**/node_modules/spellchecker/**',
'**/node_modules/windows-shortcuts/**',
].join(',') +
'}',
}, },
ignore: [ // These are all relative to client-app ignore: [
// These are all relative to client-app
// top level dirs we never want // top level dirs we never want
/^\/build.*/, /^\/build.*/,
/^\/dist.*/, /^\/dist.*/,
@ -235,7 +252,7 @@ module.exports = (grunt) => {
// Electron.app/Contents/Info.plist. A majority of the defaults are // Electron.app/Contents/Info.plist. A majority of the defaults are
// left in the Electron Info.plist file // left in the Electron Info.plist file
extendInfo: path.resolve(grunt.config('appDir'), 'build', 'resources', 'mac', 'extra.plist'), extendInfo: path.resolve(grunt.config('appDir'), 'build', 'resources', 'mac', 'extra.plist'),
appBundleId: "com.mailspring.mailspring", appBundleId: 'com.mailspring.mailspring',
afterCopy: [ afterCopy: [
runCopyPlatformSpecificResources, runCopyPlatformSpecificResources,
runWriteCommitHashIntoPackage, runWriteCommitHashIntoPackage,
@ -243,7 +260,7 @@ module.exports = (grunt) => {
runTranspilers, runTranspilers,
], ],
}, },
}) });
grunt.registerTask('package', 'Package Mailspring', function pack() { grunt.registerTask('package', 'Package Mailspring', function pack() {
const done = this.async(); const done = this.async();
@ -253,14 +270,14 @@ module.exports = (grunt) => {
console.log(util.inspect(grunt.config.get('packager'), true, 7, true)); console.log(util.inspect(grunt.config.get('packager'), true, 7, true));
const ongoing = setInterval(() => { const ongoing = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000.0) const elapsed = Math.round((Date.now() - start) / 1000.0);
console.log(`---> Packaging for ${elapsed}s`); console.log(`---> Packaging for ${elapsed}s`);
}, 1000) }, 1000);
resolveRealSymlinkPaths(grunt.config('appDir')) resolveRealSymlinkPaths(grunt.config('appDir'));
packager(grunt.config.get('packager'), (err, appPaths) => { packager(grunt.config.get('packager'), (err, appPaths) => {
clearInterval(ongoing) clearInterval(ongoing);
if (err) { if (err) {
grunt.fail.fatal(err); grunt.fail.fatal(err);
return done(err); return done(err);

View file

@ -23,58 +23,86 @@ const fs = require('fs-plus');
// codesign -dvvv /path/to/N1.app // codesign -dvvv /path/to/N1.app
// //
// Which should return "accepted" // Which should return "accepted"
module.exports = (grunt) => { module.exports = grunt => {
let getCertData; let getCertData;
const {spawnP} = grunt.config('taskHelpers') const { spawnP } = grunt.config('taskHelpers');
const tmpKeychain = "n1-build.keychain"; const tmpKeychain = 'n1-build.keychain';
const unlockKeychain = (keychain, keychainPass) => { const unlockKeychain = (keychain, keychainPass) => {
const args = ['unlock-keychain', '-p', keychainPass, keychain]; const args = ['unlock-keychain', '-p', keychainPass, keychain];
return spawnP({cmd: "security", args}); return spawnP({ cmd: 'security', args });
}; };
const cleanupKeychain = () => { const cleanupKeychain = () => {
if (fs.existsSync(path.join(process.env.HOME, "Library", "Keychains", tmpKeychain))) { if (fs.existsSync(path.join(process.env.HOME, 'Library', 'Keychains', tmpKeychain))) {
return spawnP({cmd: "security", args: ["delete-keychain", tmpKeychain]}); return spawnP({ cmd: 'security', args: ['delete-keychain', tmpKeychain] });
} }
return Promise.resolve() return Promise.resolve();
}; };
const buildMacKeychain = () => { const buildMacKeychain = () => {
const crypto = require('crypto'); const crypto = require('crypto');
const tmpPass = crypto.randomBytes(32).toString('hex'); const tmpPass = crypto.randomBytes(32).toString('hex');
const {appleCert, nylasCert, nylasPrivateKey, keyPass} = getCertData(); const { appleCert, nylasCert, nylasPrivateKey, keyPass } = getCertData();
const codesignBin = path.join("/", "usr", "bin", "codesign"); const codesignBin = path.join('/', 'usr', 'bin', 'codesign');
// Create a custom, temporary keychain // Create a custom, temporary keychain
return cleanupKeychain() return (
.then(() => spawnP({cmd: "security", args: ["create-keychain", '-p', tmpPass, tmpKeychain]})) cleanupKeychain()
.then(() =>
// Due to a bug in OSX, you must list-keychain with -s in order for it spawnP({ cmd: 'security', args: ['create-keychain', '-p', tmpPass, tmpKeychain] })
// to actually add it to the list of keychains. See http://stackoverflow.com/questions/20391911/os-x-keychain-not-visible-to-keychain-access-app-in-mavericks )
.then(() => spawnP({cmd: "security", args: ["list-keychains", "-s", tmpKeychain]})) // Due to a bug in OSX, you must list-keychain with -s in order for it
// to actually add it to the list of keychains. See http://stackoverflow.com/questions/20391911/os-x-keychain-not-visible-to-keychain-access-app-in-mavericks
// Make the custom keychain default, so xcodebuild will use it for signing .then(() => spawnP({ cmd: 'security', args: ['list-keychains', '-s', tmpKeychain] }))
.then(() => spawnP({cmd: "security", args: ["default-keychain", "-s", tmpKeychain]})) // Make the custom keychain default, so xcodebuild will use it for signing
.then(() => spawnP({ cmd: 'security', args: ['default-keychain', '-s', tmpKeychain] }))
// Unlock the keychain // Unlock the keychain
.then(() => unlockKeychain(tmpKeychain, tmpPass)) .then(() => unlockKeychain(tmpKeychain, tmpPass))
// Set keychain timeout to 1 hour for long builds
// Set keychain timeout to 1 hour for long builds .then(() =>
.then(() => spawnP({cmd: "security", args: ["set-keychain-settings", "-t", "3600", "-l", tmpKeychain]})) spawnP({
cmd: 'security',
// Add certificates to keychain and allow codesign to access them args: ['set-keychain-settings', '-t', '3600', '-l', tmpKeychain],
.then(() => spawnP({cmd: "security", args: ["import", appleCert, "-k", tmpKeychain, "-T", codesignBin]})) })
)
.then(() => spawnP({cmd: "security", args: ["import", nylasCert, "-k", tmpKeychain, "-T", codesignBin]})) // Add certificates to keychain and allow codesign to access them
.then(() =>
// Load the password for the private key from environment variables spawnP({
.then(() => spawnP({cmd: "security", args: ["import", nylasPrivateKey, "-k", tmpKeychain, "-P", keyPass, "-T", codesignBin]})) cmd: 'security',
args: ['import', appleCert, '-k', tmpKeychain, '-T', codesignBin],
// mark that the codesign utility should be allowed to access the keychain without })
// prompting for access. (Needed for Mac OS Sierra and above) )
// https://stackoverflow.com/questions/39868578/security-codesign-in-sierra-keychain-ignores-access-control-settings-and-ui-p .then(() =>
.then(() => spawnP({cmd: "security", args: ["set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-k", tmpPass, tmpKeychain]})) spawnP({
cmd: 'security',
args: ['import', nylasCert, '-k', tmpKeychain, '-T', codesignBin],
})
)
// Load the password for the private key from environment variables
.then(() =>
spawnP({
cmd: 'security',
args: ['import', nylasPrivateKey, '-k', tmpKeychain, '-P', keyPass, '-T', codesignBin],
})
)
// mark that the codesign utility should be allowed to access the keychain without
// prompting for access. (Needed for Mac OS Sierra and above)
// https://stackoverflow.com/questions/39868578/security-codesign-in-sierra-keychain-ignores-access-control-settings-and-ui-p
.then(() =>
spawnP({
cmd: 'security',
args: [
'set-key-partition-list',
'-S',
'apple-tool:,apple:,codesign:',
'-k',
tmpPass,
tmpKeychain,
],
})
)
);
}; };
getCertData = () => { getCertData = () => {
@ -86,7 +114,7 @@ module.exports = (grunt) => {
const keyPass = process.env.APPLE_CODESIGN_KEY_PASSWORD; const keyPass = process.env.APPLE_CODESIGN_KEY_PASSWORD;
if (!keyPass) { if (!keyPass) {
throw new Error("APPLE_CODESIGN_KEY_PASSWORD must be set"); throw new Error('APPLE_CODESIGN_KEY_PASSWORD must be set');
} }
if (!fs.existsSync(appleCert)) { if (!fs.existsSync(appleCert)) {
throw new Error(`${appleCert} doesn't exist`); throw new Error(`${appleCert} doesn't exist`);
@ -98,21 +126,27 @@ module.exports = (grunt) => {
throw new Error(`${nylasPrivateKey} doesn't exist`); throw new Error(`${nylasPrivateKey} doesn't exist`);
} }
return {appleCert, nylasCert, nylasPrivateKey, keyPass}; return { appleCert, nylasCert, nylasPrivateKey, keyPass };
}; };
const shouldRun = () => { const shouldRun = () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
grunt.log.writeln(`Skipping keychain setup since ${process.platform} is not darwin`); grunt.log.writeln(`Skipping keychain setup since ${process.platform} is not darwin`);
return false return false;
} }
return !!process.env.SIGN_BUILD return !!process.env.SIGN_BUILD;
} };
grunt.registerTask('setup-mac-keychain', 'Setup Mac Keychain to sign the app', function setupMacKeychain() { grunt.registerTask(
const done = this.async(); 'setup-mac-keychain',
if (!shouldRun()) return done(); 'Setup Mac Keychain to sign the app',
function setupMacKeychain() {
const done = this.async();
if (!shouldRun()) return done();
return buildMacKeychain().then(done).catch(grunt.fail.fatal); return buildMacKeychain()
}); .then(done)
} .catch(grunt.fail.fatal);
}
);
};

View file

@ -1,6 +1,6 @@
const childProcess = require('child_process'); const childProcess = require('child_process');
module.exports = (grunt) => { module.exports = grunt => {
function spawn(options, callback) { function spawn(options, callback) {
const stdout = []; const stdout = [];
const stderr = []; const stderr = [];
@ -8,25 +8,31 @@ module.exports = (grunt) => {
const proc = childProcess.spawn(options.cmd, options.args, options.opts); const proc = childProcess.spawn(options.cmd, options.args, options.opts);
proc.stdout.on('data', data => stdout.push(data.toString())); proc.stdout.on('data', data => stdout.push(data.toString()));
proc.stderr.on('data', data => stderr.push(data.toString())); proc.stderr.on('data', data => stderr.push(data.toString()));
proc.on('error', (processError) => { proc.on('error', processError => {
return error != null ? error : (error = processError) return error != null ? error : (error = processError);
}); });
proc.on('close', (exitCode, signal) => { proc.on('close', (exitCode, signal) => {
if (exitCode !== 0) { if (typeof error === 'undefined' || error === null) { error = new Error(signal); } } if (exitCode !== 0) {
const results = {stderr: stderr.join(''), stdout: stdout.join(''), code: exitCode}; if (typeof error === 'undefined' || error === null) {
if (exitCode !== 0) { grunt.log.error(results.stderr); } error = new Error(signal);
}
}
const results = { stderr: stderr.join(''), stdout: stdout.join(''), code: exitCode };
if (exitCode !== 0) {
grunt.log.error(results.stderr);
}
return callback(error, results, exitCode); return callback(error, results, exitCode);
}); });
} }
function spawnP(options) { function spawnP(options) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
spawn(options, (error) => { spawn(options, error => {
if (error) return reject(error); if (error) return reject(error);
return resolve() return resolve();
}) });
}) });
} }
return {spawn, spawnP}; return { spawn, spawnP };
} };

View file

@ -1,5 +1,4 @@
React = require 'react' {Actions, React, PropTypes} = require 'nylas-exports'
{Actions} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit' {RetinaImg} = require 'nylas-component-kit'
AccountCommands = require '../account-commands' AccountCommands = require '../account-commands'
@ -8,8 +7,8 @@ class AccountSwitcher extends React.Component
@displayName: 'AccountSwitcher' @displayName: 'AccountSwitcher'
@propTypes: @propTypes:
accounts: React.PropTypes.array.isRequired accounts: PropTypes.array.isRequired
sidebarAccountIds: React.PropTypes.array.isRequired sidebarAccountIds: PropTypes.array.isRequired
_makeMenuTemplate: => _makeMenuTemplate: =>

View file

@ -1,29 +1,29 @@
import {Folder, Actions} from "nylas-exports" import { Folder, Actions } from 'nylas-exports';
import SidebarItem from "../lib/sidebar-item" import SidebarItem from '../lib/sidebar-item';
describe("sidebar-item", function sidebarItemSpec() { describe('sidebar-item', function sidebarItemSpec() {
it("preserves nested labels on rename", () => { it('preserves nested labels on rename', () => {
spyOn(Actions, "queueTask") spyOn(Actions, 'queueTask');
const categories = [new Folder({path: 'a.b/c', accountId: window.TEST_ACCOUNT_ID})] const categories = [new Folder({ path: 'a.b/c', accountId: window.TEST_ACCOUNT_ID })];
NylasEnv.savedState.sidebarKeysCollapsed = {} NylasEnv.savedState.sidebarKeysCollapsed = {};
const item = SidebarItem.forCategories(categories) const item = SidebarItem.forCategories(categories);
item.onEdited(item, 'd') item.onEdited(item, 'd');
const task = Actions.queueTask.calls[0].args[0] const task = Actions.queueTask.calls[0].args[0];
const {existingPath, path} = task; const { existingPath, path } = task;
expect(existingPath).toBe("a.b/c") expect(existingPath).toBe('a.b/c');
expect(path).toBe("a.b/d") expect(path).toBe('a.b/d');
}) });
it("preserves labels on rename", () => { it('preserves labels on rename', () => {
spyOn(Actions, "queueTask") spyOn(Actions, 'queueTask');
const categories = [new Folder({path: 'a', accountId: window.TEST_ACCOUNT_ID})] const categories = [new Folder({ path: 'a', accountId: window.TEST_ACCOUNT_ID })];
NylasEnv.savedState.sidebarKeysCollapsed = {} NylasEnv.savedState.sidebarKeysCollapsed = {};
const item = SidebarItem.forCategories(categories) const item = SidebarItem.forCategories(categories);
item.onEdited(item, 'b') item.onEdited(item, 'b');
const task = Actions.queueTask.calls[0].args[0] const task = Actions.queueTask.calls[0].args[0];
const {existingPath, path} = task; const { existingPath, path } = task;
expect(existingPath).toBe("a") expect(existingPath).toBe('a');
expect(path).toBe("b") expect(path).toBe('b');
}) });
}) });

View file

@ -1,9 +1,8 @@
import {Rx, Message, DatabaseStore} from 'nylas-exports'; import { Rx, Message, DatabaseStore } from 'nylas-exports';
export default class ActivityDataSource { export default class ActivityDataSource {
buildObservable({openTrackingId, linkTrackingId, messageLimit}) { buildObservable({ openTrackingId, linkTrackingId, messageLimit }) {
const query = DatabaseStore const query = DatabaseStore.findAll(Message)
.findAll(Message)
.order(Message.attributes.date.descending()) .order(Message.attributes.date.descending())
.where(Message.attributes.pluginMetadata.contains(openTrackingId, linkTrackingId)) .where(Message.attributes.pluginMetadata.contains(openTrackingId, linkTrackingId))
.limit(messageLimit); .limit(messageLimit);

View file

@ -1,8 +1,6 @@
import Reflux from 'reflux'; import Reflux from 'reflux';
const ActivityListActions = Reflux.createActions([ const ActivityListActions = Reflux.createActions(['resetSeen']);
"resetSeen",
]);
for (const key of Object.keys(ActivityListActions)) { for (const key of Object.keys(ActivityListActions)) {
ActivityListActions[key].sync = true; ActivityListActions[key].sync = true;

View file

@ -1,11 +1,10 @@
import React from 'react'; import React from 'react';
import {Actions, ReactDOM} from 'nylas-exports'; import { Actions, ReactDOM } from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit'; import { RetinaImg } from 'nylas-component-kit';
import ActivityList from './activity-list'; import ActivityList from './activity-list';
import ActivityListStore from './activity-list-store'; import ActivityListStore from './activity-list-store';
class ActivityListButton extends React.Component { class ActivityListButton extends React.Component {
static displayName = 'ActivityListButton'; static displayName = 'ActivityListButton';
@ -24,39 +23,29 @@ class ActivityListButton extends React.Component {
onClick = () => { onClick = () => {
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
Actions.openPopover( Actions.openPopover(<ActivityList />, { originRect: buttonRect, direction: 'down' });
<ActivityList />, };
{originRect: buttonRect, direction: 'down'}
);
}
_onDataChanged = () => { _onDataChanged = () => {
this.setState(this._getStateFromStores()); this.setState(this._getStateFromStores());
} };
_getStateFromStores() { _getStateFromStores() {
return { return {
unreadCount: ActivityListStore.unreadCount(), unreadCount: ActivityListStore.unreadCount(),
} };
} }
render() { render() {
let unreadCountClass = "unread-count"; let unreadCountClass = 'unread-count';
let iconClass = "activity-toolbar-icon"; let iconClass = 'activity-toolbar-icon';
if (this.state.unreadCount) { if (this.state.unreadCount) {
unreadCountClass += " active"; unreadCountClass += ' active';
iconClass += " unread"; iconClass += ' unread';
} }
return ( return (
<div <div tabIndex={-1} className="toolbar-activity" title="View activity" onClick={this.onClick}>
tabIndex={-1} <div className={unreadCountClass}>{this.state.unreadCount}</div>
className="toolbar-activity"
title="View activity"
onClick={this.onClick}
>
<div className={unreadCountClass}>
{this.state.unreadCount}
</div>
<RetinaImg <RetinaImg
name="icon-toolbar-activity.png" name="icon-toolbar-activity.png"
className={iconClass} className={iconClass}

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import {RetinaImg} from 'nylas-component-kit'; import { RetinaImg } from 'nylas-component-kit';
const ActivityListEmptyState = function ActivityListEmptyState() { const ActivityListEmptyState = function ActivityListEmptyState() {
return ( return (
@ -10,12 +10,16 @@ const ActivityListEmptyState = function ActivityListEmptyState() {
mode={RetinaImg.Mode.ContentIsMask} mode={RetinaImg.Mode.ContentIsMask}
/> />
<div className="text"> <div className="text">
Enable read receipts <RetinaImg name="icon-activity-mailopen.png" mode={RetinaImg.Mode.ContentDark} /> or Enable read receipts{' '}
link tracking <RetinaImg name="icon-activity-linkopen.png" mode={RetinaImg.Mode.ContentDark} /> to <RetinaImg name="icon-activity-mailopen.png" mode={RetinaImg.Mode.ContentDark} /> or link
see notifications here. tracking <RetinaImg
name="icon-activity-linkopen.png"
mode={RetinaImg.Mode.ContentDark}
/>{' '}
to see notifications here.
</div> </div>
</div> </div>
); );
} };
export default ActivityListEmptyState; export default ActivityListEmptyState;

View file

@ -1,19 +1,16 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {DisclosureTriangle, import { DisclosureTriangle, Flexbox, RetinaImg } from 'nylas-component-kit';
Flexbox, import { DateUtils } from 'nylas-exports';
RetinaImg} from 'nylas-component-kit';
import {DateUtils} from 'nylas-exports';
import ActivityListStore from './activity-list-store'; import ActivityListStore from './activity-list-store';
import {pluginFor} from './plugin-helpers'; import { pluginFor } from './plugin-helpers';
class ActivityListItemContainer extends React.Component { class ActivityListItemContainer extends React.Component {
static displayName = 'ActivityListItemContainer'; static displayName = 'ActivityListItemContainer';
static propTypes = { static propTypes = {
group: React.PropTypes.array, group: PropTypes.array,
}; };
constructor(props) { constructor(props) {
@ -27,15 +24,15 @@ class ActivityListItemContainer extends React.Component {
ActivityListStore.focusThread(threadId); ActivityListStore.focusThread(threadId);
} }
_onCollapseToggled = (event) => { _onCollapseToggled = event => {
event.stopPropagation(); event.stopPropagation();
this.setState({collapsed: !this.state.collapsed}); this.setState({ collapsed: !this.state.collapsed });
} };
_getText() { _getText() {
const text = { const text = {
recipient: "Someone", recipient: 'Someone',
title: "(No Subject)", title: '(No Subject)',
date: new Date(0), date: new Date(0),
}; };
const lastAction = this.props.group[0]; const lastAction = this.props.group[0];
@ -64,18 +61,13 @@ class ActivityListItemContainer extends React.Component {
const date = new Date(0); const date = new Date(0);
date.setUTCSeconds(action.timestamp); date.setUTCSeconds(action.timestamp);
actions.push( actions.push(
<div <div key={`${action.messageId}-${action.timestamp}`} className="activity-list-toggle-item">
key={`${action.messageId}-${action.timestamp}`}
className="activity-list-toggle-item"
>
<Flexbox direction="row"> <Flexbox direction="row">
<div className="action-message"> <div className="action-message">
{action.recipient ? action.recipient.displayName() : "Someone"} {action.recipient ? action.recipient.displayName() : 'Someone'}
</div> </div>
<div className="spacer" /> <div className="spacer" />
<div className="timestamp"> <div className="timestamp">{DateUtils.shortTimeString(date)}</div>
{DateUtils.shortTimeString(date)}
</div>
</Flexbox> </Flexbox>
</div> </div>
); );
@ -83,7 +75,7 @@ class ActivityListItemContainer extends React.Component {
return ( return (
<div <div
key={`activity-toggle-container`} key={`activity-toggle-container`}
className={`activity-toggle-container ${this.state.collapsed ? "hidden" : ""}`} className={`activity-toggle-container ${this.state.collapsed ? 'hidden' : ''}`}
> >
{actions} {actions}
</div> </div>
@ -92,10 +84,10 @@ class ActivityListItemContainer extends React.Component {
render() { render() {
const lastAction = this.props.group[0]; const lastAction = this.props.group[0];
let className = "activity-list-item"; let className = 'activity-list-item';
if (!ActivityListStore.hasBeenViewed(lastAction)) className += " unread"; if (!ActivityListStore.hasBeenViewed(lastAction)) className += ' unread';
const text = this._getText(); const text = this._getText();
let disclosureTriangle = (<div style={{width: "7px"}} />); let disclosureTriangle = <div style={{ width: '7px' }} />;
if (this.props.group.length > 1) { if (this.props.group.length > 1) {
disclosureTriangle = ( disclosureTriangle = (
<DisclosureTriangle <DisclosureTriangle
@ -106,11 +98,13 @@ class ActivityListItemContainer extends React.Component {
); );
} }
return ( return (
<div onClick={() => { this._onClick(lastAction.threadId) }}> <div
onClick={() => {
this._onClick(lastAction.threadId);
}}
>
<Flexbox direction="column" className={className}> <Flexbox direction="column" className={className}>
<Flexbox <Flexbox direction="row">
direction="row"
>
<div className="activity-icon-container"> <div className="activity-icon-container">
<RetinaImg <RetinaImg
className="activity-icon" className="activity-icon"
@ -123,19 +117,14 @@ class ActivityListItemContainer extends React.Component {
{text.recipient} {pluginFor(lastAction.pluginId).predicate}: {text.recipient} {pluginFor(lastAction.pluginId).predicate}:
</div> </div>
<div className="spacer" /> <div className="spacer" />
<div className="timestamp"> <div className="timestamp">{DateUtils.shortTimeString(text.date)}</div>
{DateUtils.shortTimeString(text.date)}
</div>
</Flexbox> </Flexbox>
<div className="title"> <div className="title">{text.title}</div>
{text.title}
</div>
</Flexbox> </Flexbox>
{this.renderActivityContainer()} {this.renderActivityContainer()}
</div> </div>
); );
} }
} }
export default ActivityListItemContainer; export default ActivityListItemContainer;

View file

@ -8,8 +8,7 @@ import {
} from 'nylas-exports'; } from 'nylas-exports';
import ActivityListActions from './activity-list-actions'; import ActivityListActions from './activity-list-actions';
import ActivityDataSource from './activity-data-source'; import ActivityDataSource from './activity-data-source';
import {pluginFor} from './plugin-helpers'; import { pluginFor } from './plugin-helpers';
class ActivityListStore extends NylasStore { class ActivityListStore extends NylasStore {
activate() { activate() {
@ -38,7 +37,7 @@ class ActivityListStore extends NylasStore {
} else if (!this._unreadCount) { } else if (!this._unreadCount) {
return null; return null;
} }
return "999+"; return '999+';
} }
hasBeenViewed(action) { hasBeenViewed(action) {
@ -47,16 +46,18 @@ class ActivityListStore extends NylasStore {
} }
focusThread(threadId) { focusThread(threadId) {
NylasEnv.displayWindow() NylasEnv.displayWindow();
Actions.closePopover() Actions.closePopover();
DatabaseStore.find(Thread, threadId).then((thread) => { DatabaseStore.find(Thread, threadId).then(thread => {
if (!thread) { if (!thread) {
NylasEnv.reportError(new Error(`ActivityListStore::focusThread: Can't find thread`, {threadId})) NylasEnv.reportError(
NylasEnv.showErrorDialog(`Can't find the selected thread in your mailbox`) new Error(`ActivityListStore::focusThread: Can't find thread`, { threadId })
);
NylasEnv.showErrorDialog(`Can't find the selected thread in your mailbox`);
return; return;
} }
Actions.ensureCategoryIsFocused('sent', thread.accountId); Actions.ensureCategoryIsFocused('sent', thread.accountId);
Actions.setFocus({collection: 'thread', item: thread}); Actions.setFocus({ collection: 'thread', item: thread });
}); });
} }
@ -85,14 +86,16 @@ class ActivityListStore extends NylasStore {
_getActivity() { _getActivity() {
const dataSource = this._dataSource(); const dataSource = this._dataSource();
this._subscription = dataSource.buildObservable({ this._subscription = dataSource
openTrackingId: NylasEnv.packages.pluginIdFor('open-tracking'), .buildObservable({
linkTrackingId: NylasEnv.packages.pluginIdFor('link-tracking'), openTrackingId: NylasEnv.packages.pluginIdFor('open-tracking'),
messageLimit: 500, linkTrackingId: NylasEnv.packages.pluginIdFor('link-tracking'),
}).subscribe((messages) => { messageLimit: 500,
this._messages = messages; })
this._updateActivity(); .subscribe(messages => {
}); this._messages = messages;
this._updateActivity();
});
} }
_updateActivity() { _updateActivity() {
@ -107,10 +110,12 @@ class ActivityListStore extends NylasStore {
const sidebarAccountIds = FocusedPerspectiveStore.sidebarAccountIds(); const sidebarAccountIds = FocusedPerspectiveStore.sidebarAccountIds();
for (const message of messages) { for (const message of messages) {
if (sidebarAccountIds.length > 1 || message.accountId === sidebarAccountIds[0]) { if (sidebarAccountIds.length > 1 || message.accountId === sidebarAccountIds[0]) {
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking') const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking');
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking') const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking');
if (message.metadataForPluginId(openTrackingId) || if (
message.metadataForPluginId(linkTrackingId)) { message.metadataForPluginId(openTrackingId) ||
message.metadataForPluginId(linkTrackingId)
) {
actions = actions.concat(this._openActionsForMessage(message)); actions = actions.concat(this._openActionsForMessage(message));
actions = actions.concat(this._linkActionsForMessage(message)); actions = actions.concat(this._linkActionsForMessage(message));
} }
@ -119,7 +124,7 @@ class ActivityListStore extends NylasStore {
if (!this._lastNotified) this._lastNotified = {}; if (!this._lastNotified) this._lastNotified = {};
for (const notification of this._notifications) { for (const notification of this._notifications) {
const lastNotified = this._lastNotified[notification.threadId]; const lastNotified = this._lastNotified[notification.threadId];
const {notificationInterval} = pluginFor(notification.pluginId); const { notificationInterval } = pluginFor(notification.pluginId);
if (!lastNotified || lastNotified < Date.now() - notificationInterval) { if (!lastNotified || lastNotified < Date.now() - notificationInterval) {
NativeNotifications.displayNotification(notification.data); NativeNotifications.displayNotification(notification.data);
this._lastNotified[notification.threadId] = Date.now(); this._lastNotified[notification.threadId] = Date.now();
@ -137,7 +142,7 @@ class ActivityListStore extends NylasStore {
} }
_openActionsForMessage(message) { _openActionsForMessage(message) {
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking') const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking');
const openMetadata = message.metadataForPluginId(openTrackingId); const openMetadata = message.metadataForPluginId(openTrackingId);
const recipients = message.to.concat(message.cc, message.bcc); const recipients = message.to.concat(message.cc, message.bcc);
const actions = []; const actions = [];
@ -150,10 +155,12 @@ class ActivityListStore extends NylasStore {
pluginId: openTrackingId, pluginId: openTrackingId,
threadId: message.threadId, threadId: message.threadId,
data: { data: {
title: "New open", title: 'New open',
subtitle: `${recipient ? recipient.displayName() : "Someone"} just opened ${message.subject}`, subtitle: `${recipient
? recipient.displayName()
: 'Someone'} just opened ${message.subject}`,
canReply: false, canReply: false,
tag: "message-open", tag: 'message-open',
onActivate: () => { onActivate: () => {
this.focusThread(message.threadId); this.focusThread(message.threadId);
}, },
@ -176,8 +183,8 @@ class ActivityListStore extends NylasStore {
} }
_linkActionsForMessage(message) { _linkActionsForMessage(message) {
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking') const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking');
const linkMetadata = message.metadataForPluginId(linkTrackingId) const linkMetadata = message.metadataForPluginId(linkTrackingId);
const recipients = message.to.concat(message.cc, message.bcc); const recipients = message.to.concat(message.cc, message.bcc);
const actions = []; const actions = [];
if (linkMetadata && linkMetadata.links) { if (linkMetadata && linkMetadata.links) {
@ -189,10 +196,12 @@ class ActivityListStore extends NylasStore {
pluginId: linkTrackingId, pluginId: linkTrackingId,
threadId: message.threadId, threadId: message.threadId,
data: { data: {
title: "New click", title: 'New click',
subtitle: `${recipient ? recipient.displayName() : "Someone"} just clicked ${link.url}.`, subtitle: `${recipient
? recipient.displayName()
: 'Someone'} just clicked ${link.url}.`,
canReply: false, canReply: false,
tag: "link-open", tag: 'link-open',
onActivate: () => { onActivate: () => {
this.focusThread(message.threadId); this.focusThread(message.threadId);
}, },

View file

@ -1,15 +1,13 @@
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import {Flexbox, import { Flexbox, ScrollRegion } from 'nylas-component-kit';
ScrollRegion} from 'nylas-component-kit';
import ActivityListStore from './activity-list-store'; import ActivityListStore from './activity-list-store';
import ActivityListActions from './activity-list-actions'; import ActivityListActions from './activity-list-actions';
import ActivityListItemContainer from './activity-list-item-container'; import ActivityListItemContainer from './activity-list-item-container';
import ActivityListEmptyState from './activity-list-empty-state'; import ActivityListEmptyState from './activity-list-empty-state';
class ActivityList extends React.Component { class ActivityList extends React.Component {
static displayName = 'ActivityList'; static displayName = 'ActivityList';
constructor() { constructor() {
@ -28,7 +26,7 @@ class ActivityList extends React.Component {
_onDataChanged = () => { _onDataChanged = () => {
this.setState(this._getStateFromStores()); this.setState(this._getStateFromStores());
} };
_getStateFromStores() { _getStateFromStores() {
const actions = ActivityListStore.actions(); const actions = ActivityListStore.actions();
@ -36,7 +34,7 @@ class ActivityList extends React.Component {
actions: actions, actions: actions,
empty: actions instanceof Array && actions.length === 0, empty: actions instanceof Array && actions.length === 0,
collapsedToggles: this.state ? this.state.collapsedToggles : {}, collapsedToggles: this.state ? this.state.collapsedToggles : {},
} };
} }
_groupActions(actions) { _groupActions(actions) {
@ -44,8 +42,10 @@ class ActivityList extends React.Component {
for (const action of actions) { for (const action of actions) {
if (groupedActions.length > 0) { if (groupedActions.length > 0) {
const currentGroup = groupedActions[groupedActions.length - 1]; const currentGroup = groupedActions[groupedActions.length - 1];
if (action.messageId === currentGroup[0].messageId && if (
action.pluginId === currentGroup[0].pluginId) { action.messageId === currentGroup[0].messageId &&
action.pluginId === currentGroup[0].pluginId
) {
groupedActions[groupedActions.length - 1].push(action); groupedActions[groupedActions.length - 1].push(action);
} else { } else {
groupedActions.push([action]); groupedActions.push([action]);
@ -59,13 +59,11 @@ class ActivityList extends React.Component {
renderActions() { renderActions() {
if (this.state.empty) { if (this.state.empty) {
return ( return <ActivityListEmptyState />;
<ActivityListEmptyState />
)
} }
const groupedActions = this._groupActions(this.state.actions); const groupedActions = this._groupActions(this.state.actions);
return groupedActions.map((group) => { return groupedActions.map(group => {
return ( return (
<ActivityListItemContainer <ActivityListItemContainer
key={`${group[0].messageId}-${group[0].timestamp}`} key={`${group[0].messageId}-${group[0].timestamp}`}
@ -79,19 +77,12 @@ class ActivityList extends React.Component {
if (!this.state.actions) return null; if (!this.state.actions) return null;
const classes = classnames({ const classes = classnames({
"activity-list-container": true, 'activity-list-container': true,
"empty": this.state.empty, empty: this.state.empty,
}) });
return ( return (
<Flexbox <Flexbox direction="column" height="none" className={classes} tabIndex="-1">
direction="column" <ScrollRegion style={{ height: '100%' }}>{this.renderActions()}</ScrollRegion>
height="none"
className={classes}
tabIndex="-1"
>
<ScrollRegion style={{height: "100%"}}>
{this.renderActions()}
</ScrollRegion>
</Flexbox> </Flexbox>
); );
} }

View file

@ -1,10 +1,10 @@
import {ComponentRegistry, WorkspaceStore} from 'nylas-exports'; import { ComponentRegistry, WorkspaceStore } from 'nylas-exports';
import {HasTutorialTip} from 'nylas-component-kit'; import { HasTutorialTip } from 'nylas-component-kit';
import ActivityListButton from './activity-list-button'; import ActivityListButton from './activity-list-button';
import ActivityListStore from './activity-list-store'; import ActivityListStore from './activity-list-store';
const ActivityListButtonWithTutorialTip = HasTutorialTip(ActivityListButton, { const ActivityListButtonWithTutorialTip = HasTutorialTip(ActivityListButton, {
title: "Open and link tracking", title: 'Open and link tracking',
instructions: "If you've enabled link tracking or read receipts, those events will appear here!", instructions: "If you've enabled link tracking or read receipts, those events will appear here!",
}); });
@ -15,7 +15,6 @@ export function activate() {
ActivityListStore.activate(); ActivityListStore.activate();
} }
export function deactivate() { export function deactivate() {
ComponentRegistry.unregister(ActivityListButtonWithTutorialTip); ComponentRegistry.unregister(ActivityListButtonWithTutorialTip);
} }

View file

@ -1,22 +1,21 @@
export function pluginFor(id) { export function pluginFor(id) {
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking') const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking');
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking') const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking');
if (id === openTrackingId) { if (id === openTrackingId) {
return { return {
name: "open", name: 'open',
predicate: "opened", predicate: 'opened',
iconName: "icon-activity-mailopen.png", iconName: 'icon-activity-mailopen.png',
notificationInterval: 600000, // 10 minutes in ms notificationInterval: 600000, // 10 minutes in ms
} };
} }
if (id === linkTrackingId) { if (id === linkTrackingId) {
return { return {
name: "link", name: 'link',
predicate: "clicked", predicate: 'clicked',
iconName: "icon-activity-linkopen.png", iconName: 'icon-activity-linkopen.png',
notificationInterval: 10000, // 10 seconds in ms notificationInterval: 10000, // 10 seconds in ms
} };
} }
return undefined return undefined;
} }

View file

@ -5,14 +5,14 @@ export default class TestDataSource {
manuallyTrigger = (messages = []) => { manuallyTrigger = (messages = []) => {
this.onNext(messages); this.onNext(messages);
} };
subscribe(onNext) { subscribe(onNext) {
this.onNext = onNext; this.onNext = onNext;
this.manuallyTrigger(); this.manuallyTrigger();
const dispose = () => { const dispose = () => {
this._unsub(); this._unsub();
} };
return {dispose}; return { dispose };
} }
} }

View file

@ -12,79 +12,97 @@ import ActivityList from '../lib/activity-list';
import ActivityListStore from '../lib/activity-list-store'; import ActivityListStore from '../lib/activity-list-store';
import TestDataSource from '../lib/test-data-source'; import TestDataSource from '../lib/test-data-source';
const OPEN_TRACKING_ID = 'open-tracking-id' const OPEN_TRACKING_ID = 'open-tracking-id';
const LINK_TRACKING_ID = 'link-tracking-id' const LINK_TRACKING_ID = 'link-tracking-id';
const messages = [ const messages = [
new Message({ new Message({
id: 'a', id: 'a',
accountId: "0000000000000000000000000", accountId: '0000000000000000000000000',
bcc: [], bcc: [],
cc: [], cc: [],
snippet: "Testing.", snippet: 'Testing.',
subject: "Open me!", subject: 'Open me!',
threadId: "0000000000000000000000000", threadId: '0000000000000000000000000',
to: [new Contact({ to: [
name: "Jackie Luo", new Contact({
email: "jackie@nylas.com", name: 'Jackie Luo',
})], email: 'jackie@nylas.com',
}),
],
}), }),
new Message({ new Message({
id: 'b', id: 'b',
accountId: "0000000000000000000000000", accountId: '0000000000000000000000000',
bcc: [new Contact({ bcc: [
name: "Ben Gotow", new Contact({
email: "ben@nylas.com", name: 'Ben Gotow',
})], email: 'ben@nylas.com',
}),
],
cc: [], cc: [],
snippet: "Hey! I am in town for the week...", snippet: 'Hey! I am in town for the week...',
subject: "Coffee?", subject: 'Coffee?',
threadId: "0000000000000000000000000", threadId: '0000000000000000000000000',
to: [new Contact({ to: [
name: "Jackie Luo", new Contact({
email: "jackie@nylas.com", name: 'Jackie Luo',
})], email: 'jackie@nylas.com',
}),
],
}), }),
new Message({ new Message({
id: 'c', id: 'c',
accountId: "0000000000000000000000000", accountId: '0000000000000000000000000',
bcc: [], bcc: [],
cc: [new Contact({ cc: [
name: "Evan Morikawa", new Contact({
email: "evan@nylas.com", name: 'Evan Morikawa',
})], email: 'evan@nylas.com',
}),
],
snippet: "Here's the latest deals!", snippet: "Here's the latest deals!",
subject: "Newsletter", subject: 'Newsletter',
threadId: "0000000000000000000000000", threadId: '0000000000000000000000000',
to: [new Contact({ to: [
name: "Juan Tejada", new Contact({
email: "juan@nylas.com", name: 'Juan Tejada',
})], email: 'juan@nylas.com',
}),
],
}), }),
]; ];
let pluginValue = { let pluginValue = {
open_count: 1, open_count: 1,
open_data: [{ open_data: [
timestamp: 1461361759.351055, {
}], timestamp: 1461361759.351055,
},
],
}; };
messages[0].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue); messages[0].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue);
pluginValue = { pluginValue = {
links: [{ links: [
click_count: 1, {
click_data: [{ click_count: 1,
timestamp: 1461349232.495837, click_data: [
}], {
}], timestamp: 1461349232.495837,
},
],
},
],
tracked: true, tracked: true,
}; };
messages[0].directlyAttachMetadata(LINK_TRACKING_ID, pluginValue); messages[0].directlyAttachMetadata(LINK_TRACKING_ID, pluginValue);
pluginValue = { pluginValue = {
open_count: 1, open_count: 1,
open_data: [{ open_data: [
timestamp: 1461361763.283720, {
}], timestamp: 1461361763.28372,
},
],
}; };
messages[1].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue); messages[1].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue);
pluginValue = { pluginValue = {
@ -98,51 +116,55 @@ pluginValue = {
}; };
messages[2].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue); messages[2].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue);
pluginValue = { pluginValue = {
links: [{ links: [
click_count: 0, {
click_data: [], click_count: 0,
}], click_data: [],
},
],
tracked: true, tracked: true,
}; };
messages[2].directlyAttachMetadata(LINK_TRACKING_ID, pluginValue); messages[2].directlyAttachMetadata(LINK_TRACKING_ID, pluginValue);
describe('ActivityList', function activityList() { describe('ActivityList', function activityList() {
beforeEach(() => { beforeEach(() => {
this.testSource = new TestDataSource(); this.testSource = new TestDataSource();
spyOn(NylasEnv.packages, 'pluginIdFor').andCallFake((pluginName) => { spyOn(NylasEnv.packages, 'pluginIdFor').andCallFake(pluginName => {
if (pluginName === 'open-tracking') { if (pluginName === 'open-tracking') {
return OPEN_TRACKING_ID return OPEN_TRACKING_ID;
} }
if (pluginName === 'link-tracking') { if (pluginName === 'link-tracking') {
return LINK_TRACKING_ID return LINK_TRACKING_ID;
} }
return null return null;
}) });
spyOn(ActivityListStore, "_dataSource").andReturn(this.testSource); spyOn(ActivityListStore, '_dataSource').andReturn(this.testSource);
spyOn(FocusedPerspectiveStore, "sidebarAccountIds").andReturn(["0000000000000000000000000"]); spyOn(FocusedPerspectiveStore, 'sidebarAccountIds').andReturn(['0000000000000000000000000']);
spyOn(DatabaseStore, "run").andCallFake((query) => { spyOn(DatabaseStore, 'run').andCallFake(query => {
if (query._klass === Thread) { if (query._klass === Thread) {
const thread = new Thread({ const thread = new Thread({
id: "0000000000000000000000000", id: '0000000000000000000000000',
accountId: TEST_ACCOUNT_ID, accountId: TEST_ACCOUNT_ID,
}); });
return Promise.resolve(thread); return Promise.resolve(thread);
} }
return null; return null;
}); });
spyOn(ActivityListStore, "focusThread").andCallThrough(); spyOn(ActivityListStore, 'focusThread').andCallThrough();
spyOn(NylasEnv, "displayWindow"); spyOn(NylasEnv, 'displayWindow');
spyOn(Actions, "closePopover"); spyOn(Actions, 'closePopover');
spyOn(Actions, "setFocus"); spyOn(Actions, 'setFocus');
spyOn(Actions, "ensureCategoryIsFocused"); spyOn(Actions, 'ensureCategoryIsFocused');
ActivityListStore.activate(); ActivityListStore.activate();
this.component = ReactTestUtils.renderIntoDocument(<ActivityList />); this.component = ReactTestUtils.renderIntoDocument(<ActivityList />);
}); });
describe('when no actions are found', () => { describe('when no actions are found', () => {
it('should show empty state', () => { it('should show empty state', () => {
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item"); const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(
this.component,
'activity-list-item'
);
expect(items.length).toBe(0); expect(items.length).toBe(0);
}); });
}); });
@ -151,37 +173,61 @@ describe('ActivityList', function activityList() {
it('should show activity list items', () => { it('should show activity list items', () => {
this.testSource.manuallyTrigger(messages); this.testSource.manuallyTrigger(messages);
waitsFor(() => { waitsFor(() => {
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item"); const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(
this.component,
'activity-list-item'
);
return items.length > 0; return items.length > 0;
}); });
runs(() => { runs(() => {
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item").length).toBe(3); expect(
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'activity-list-item')
.length
).toBe(3);
}); });
}); });
it('should show the correct items', () => { it('should show the correct items', () => {
this.testSource.manuallyTrigger(messages); this.testSource.manuallyTrigger(messages);
waitsFor(() => { waitsFor(() => {
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item"); const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(
this.component,
'activity-list-item'
);
return items.length > 0; return items.length > 0;
}); });
runs(() => { runs(() => {
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[0].textContent).toBe("Someone opened:Apr 22 2016Coffee?"); expect(
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[1].textContent).toBe("Jackie Luo opened:Apr 22 2016Open me!"); ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'activity-list-item')[0]
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[2].textContent).toBe("Jackie Luo clicked:Apr 22 2016(No Subject)"); .textContent
).toBe('Someone opened:Apr 22 2016Coffee?');
expect(
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'activity-list-item')[1]
.textContent
).toBe('Jackie Luo opened:Apr 22 2016Open me!');
expect(
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'activity-list-item')[2]
.textContent
).toBe('Jackie Luo clicked:Apr 22 2016(No Subject)');
}); });
}); });
xit('should focus the thread', () => { xit('should focus the thread', () => {
runs(() => { runs(() => {
return this.testSource.manuallyTrigger(messages); return this.testSource.manuallyTrigger(messages);
}) });
waitsFor(() => { waitsFor(() => {
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item"); const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(
this.component,
'activity-list-item'
);
return items.length > 0; return items.length > 0;
}); });
runs(() => { runs(() => {
const item = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[0]; const item = ReactTestUtils.scryRenderedDOMComponentsWithClass(
this.component,
'activity-list-item'
)[0];
ReactTestUtils.Simulate.click(item); ReactTestUtils.Simulate.click(item);
}); });
waitsFor(() => { waitsFor(() => {

View file

@ -3,7 +3,7 @@ const assert = require('assert');
const crypto = require('crypto'); const crypto = require('crypto');
const validate = require('@segment/loosely-validate-event'); const validate = require('@segment/loosely-validate-event');
const debug = require('debug')('analytics-node'); const debug = require('debug')('analytics-node');
const version = `3.0.0` const version = `3.0.0`;
// BG: Dependencies of analytics-node I lifted in // BG: Dependencies of analytics-node I lifted in
@ -11,13 +11,13 @@ const version = `3.0.0`
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function uid(length, fn) { function uid(length, fn) {
const str = (bytes) => { const str = bytes => {
const res = []; const res = [];
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
res.push(chars[bytes[i] % chars.length]); res.push(chars[bytes[i] % chars.length]);
} }
return res.join(''); return res.join('');
} };
if (typeof length === 'function') { if (typeof length === 'function') {
fn = length; fn = length;
@ -45,9 +45,8 @@ function removeSlash(str) {
return String(str).replace(/\/+$/, ''); return String(str).replace(/\/+$/, '');
} }
const setImmediate = global.setImmediate || process.nextTick.bind(process);
const setImmediate = global.setImmediate || process.nextTick.bind(process) const noop = () => {};
const noop = () => {}
export default class Analytics { export default class Analytics {
/** /**
@ -62,16 +61,16 @@ export default class Analytics {
*/ */
constructor(writeKey, options) { constructor(writeKey, options) {
options = options || {} options = options || {};
assert(writeKey, 'You must pass your Segment project\'s write key.') assert(writeKey, "You must pass your Segment project's write key.");
this.queue = [] this.queue = [];
this.writeKey = writeKey this.writeKey = writeKey;
this.host = removeSlash(options.host || 'https://api.segment.io') this.host = removeSlash(options.host || 'https://api.segment.io');
this.flushAt = Math.max(options.flushAt, 1) || 20 this.flushAt = Math.max(options.flushAt, 1) || 20;
this.flushInterval = options.flushInterval || 10000 this.flushInterval = options.flushInterval || 10000;
this.flushed = false this.flushed = false;
} }
/** /**
@ -83,9 +82,9 @@ export default class Analytics {
*/ */
identify(message, callback) { identify(message, callback) {
validate(message, 'identify') validate(message, 'identify');
this.enqueue('identify', message, callback) this.enqueue('identify', message, callback);
return this return this;
} }
/** /**
@ -97,9 +96,9 @@ export default class Analytics {
*/ */
group(message, callback) { group(message, callback) {
validate(message, 'group') validate(message, 'group');
this.enqueue('group', message, callback) this.enqueue('group', message, callback);
return this return this;
} }
/** /**
@ -111,9 +110,9 @@ export default class Analytics {
*/ */
track(message, callback) { track(message, callback) {
validate(message, 'track') validate(message, 'track');
this.enqueue('track', message, callback) this.enqueue('track', message, callback);
return this return this;
} }
/** /**
@ -125,9 +124,9 @@ export default class Analytics {
*/ */
page(message, callback) { page(message, callback) {
validate(message, 'page') validate(message, 'page');
this.enqueue('page', message, callback) this.enqueue('page', message, callback);
return this return this;
} }
/** /**
@ -139,9 +138,9 @@ export default class Analytics {
*/ */
screen(message, callback) { screen(message, callback) {
validate(message, 'screen') validate(message, 'screen');
this.enqueue('screen', message, callback) this.enqueue('screen', message, callback);
return this return this;
} }
/** /**
@ -153,9 +152,9 @@ export default class Analytics {
*/ */
alias(message, callback) { alias(message, callback) {
validate(message, 'alias') validate(message, 'alias');
this.enqueue('alias', message, callback) this.enqueue('alias', message, callback);
return this return this;
} }
/** /**
@ -169,45 +168,51 @@ export default class Analytics {
*/ */
enqueue(type, message, callback) { enqueue(type, message, callback) {
callback = callback || noop callback = callback || noop;
message = Object.assign({}, message) message = Object.assign({}, message);
message.type = type message.type = type;
message.context = Object.assign({ message.context = Object.assign(
library: { {
name: 'analytics-node', library: {
version, name: 'analytics-node',
version,
},
}, },
}, message.context) message.context
);
message._metadata = Object.assign({ message._metadata = Object.assign(
nodeVersion: process.versions.node, {
}, message._metadata) nodeVersion: process.versions.node,
},
message._metadata
);
if (!message.timestamp) { if (!message.timestamp) {
message.timestamp = new Date() message.timestamp = new Date();
} }
if (!message.messageId) { if (!message.messageId) {
message.messageId = `node-${uid(32)}` message.messageId = `node-${uid(32)}`;
} }
debug('%s: %o', type, message) debug('%s: %o', type, message);
this.queue.push({ message, callback }) this.queue.push({ message, callback });
if (!this.flushed) { if (!this.flushed) {
this.flushed = true this.flushed = true;
this.flush() this.flush();
return return;
} }
if (this.queue.length >= this.flushAt) { if (this.queue.length >= this.flushAt) {
this.flush() this.flush();
} }
if (this.flushInterval && !this.timer) { if (this.flushInterval && !this.timer) {
this.timer = setTimeout(this.flush.bind(this), this.flushInterval) this.timer = setTimeout(this.flush.bind(this), this.flushInterval);
} }
} }
@ -219,29 +224,29 @@ export default class Analytics {
*/ */
async flush(callback) { async flush(callback) {
callback = callback || noop callback = callback || noop;
if (this.timer) { if (this.timer) {
clearTimeout(this.timer) clearTimeout(this.timer);
this.timer = null this.timer = null;
} }
if (!this.queue.length) { if (!this.queue.length) {
setImmediate(callback) setImmediate(callback);
return; return;
} }
const items = this.queue.splice(0, this.flushAt) const items = this.queue.splice(0, this.flushAt);
const callbacks = items.map(item => item.callback) const callbacks = items.map(item => item.callback);
const messages = items.map(item => item.message) const messages = items.map(item => item.message);
const data = { const data = {
batch: messages, batch: messages,
timestamp: new Date(), timestamp: new Date(),
sentAt: new Date(), sentAt: new Date(),
} };
debug('flush: %o', data) debug('flush: %o', data);
const options = { const options = {
body: JSON.stringify(data), body: JSON.stringify(data),
@ -250,11 +255,11 @@ export default class Analytics {
method: 'POST', method: 'POST',
}; };
options.headers.set('Accept', 'application/json'); options.headers.set('Accept', 'application/json');
options.headers.set('Authorization', `Basic ${btoa(`${this.writeKey}:`)}`) options.headers.set('Authorization', `Basic ${btoa(`${this.writeKey}:`)}`);
options.headers.set('Content-Type', 'application/json'); options.headers.set('Content-Type', 'application/json');
const runCallbacks = (err) => { const runCallbacks = err => {
callbacks.forEach((cb) => cb(err)) callbacks.forEach(cb => cb(err));
callback(err, data); callback(err, data);
debug('flushed: %o', data); debug('flushed: %o', data);
}; };

View file

@ -1,14 +1,14 @@
import _ from 'underscore' import _ from 'underscore';
import NylasStore from 'nylas-store' import NylasStore from 'nylas-store';
import { import {
IdentityStore, IdentityStore,
Actions, Actions,
AccountStore, AccountStore,
FocusedPerspectiveStore, FocusedPerspectiveStore,
NylasAPIRequest, NylasAPIRequest,
} from 'nylas-exports' } from 'nylas-exports';
import AnalyticsSink from '../analytics-electron' import AnalyticsSink from '../analytics-electron';
/** /**
* We wait 15 seconds to give the alias time to register before we send * We wait 15 seconds to give the alias time to register before we send
@ -17,16 +17,15 @@ import AnalyticsSink from '../analytics-electron'
const DEBOUNCE_TIME = 5 * 1000; const DEBOUNCE_TIME = 5 * 1000;
class AnalyticsStore extends NylasStore { class AnalyticsStore extends NylasStore {
activate() { activate() {
// Allow requests to be grouped together if they're fired back-to-back, // Allow requests to be grouped together if they're fired back-to-back,
// but generally report each event as it happens. This segment library // but generally report each event as it happens. This segment library
// is intended for a server where the user doesn't quit... // is intended for a server where the user doesn't quit...
this.analytics = new AnalyticsSink("mailspring", { this.analytics = new AnalyticsSink('mailspring', {
host: `${NylasAPIRequest.rootURLForServer('identity')}/api/s`, host: `${NylasAPIRequest.rootURLForServer('identity')}/api/s`,
flushInterval: 500, flushInterval: 500,
flushAt: 5, flushAt: 5,
}) });
this.launchTime = Date.now(); this.launchTime = Date.now();
const identifySoon = _.debounce(this.identify, DEBOUNCE_TIME); const identifySoon = _.debounce(this.identify, DEBOUNCE_TIME);
@ -39,7 +38,7 @@ class AnalyticsStore extends NylasStore {
this.listenTo(IdentityStore, identifySoon); this.listenTo(IdentityStore, identifySoon);
this.listenTo(Actions.recordUserEvent, (eventName, eventArgs) => { this.listenTo(Actions.recordUserEvent, (eventName, eventArgs) => {
this.track(eventName, eventArgs); this.track(eventName, eventArgs);
}) });
} }
// Properties applied to all events (only). // Properties applied to all events (only).
@ -53,7 +52,9 @@ class AnalyticsStore extends NylasStore {
currentProvider = 'Unified'; currentProvider = 'Unified';
} else { } else {
// Warning: when you auth a new account there's a single moment where the account cannot be found // Warning: when you auth a new account there's a single moment where the account cannot be found
const account = perspective ? AccountStore.accountForId(perspective.accountIds[0]) : AccountStore.accounts()[0]; const account = perspective
? AccountStore.accountForId(perspective.accountIds[0])
: AccountStore.accounts()[0];
currentProvider = account && account.displayProvider(); currentProvider = account && account.displayProvider();
} }
@ -67,10 +68,10 @@ class AnalyticsStore extends NylasStore {
const theme = NylasEnv.themes ? NylasEnv.themes.getActiveTheme() : null; const theme = NylasEnv.themes ? NylasEnv.themes.getActiveTheme() : null;
return { return {
version: NylasEnv.getVersion().split("-")[0], version: NylasEnv.getVersion().split('-')[0],
platform: process.platform, platform: process.platform,
activeTheme: theme ? theme.name : null, activeTheme: theme ? theme.name : null,
workspaceMode: NylasEnv.config.get("core.workspace.mode"), workspaceMode: NylasEnv.config.get('core.workspace.mode'),
}; };
} }
@ -79,13 +80,15 @@ class AnalyticsStore extends NylasStore {
firstDaySeen: this.firstDaySeen(), firstDaySeen: this.firstDaySeen(),
timeSinceLaunch: (Date.now() - this.launchTime) / 1000, timeSinceLaunch: (Date.now() - this.launchTime) / 1000,
accountCount: AccountStore.accounts().length, accountCount: AccountStore.accounts().length,
providers: AccountStore.accounts().map((a) => a.displayProvider()), providers: AccountStore.accounts().map(a => a.displayProvider()),
}); });
} }
personalTraits() { personalTraits() {
const identity = IdentityStore.identity(); const identity = IdentityStore.identity();
if (!(identity && identity.id)) { return {}; } if (!(identity && identity.id)) {
return {};
}
return { return {
email: identity.emailAddress, email: identity.emailAddress,
@ -97,26 +100,24 @@ class AnalyticsStore extends NylasStore {
track(eventName, eventArgs = {}) { track(eventName, eventArgs = {}) {
// if (NylasEnv.inDevMode()) { return } // if (NylasEnv.inDevMode()) { return }
const identity = IdentityStore.identity() const identity = IdentityStore.identity();
if (!(identity && identity.id)) { return; } if (!(identity && identity.id)) {
return;
}
this.analytics.track({ this.analytics.track({
event: eventName, event: eventName,
userId: identity.id, userId: identity.id,
properties: Object.assign({}, properties: Object.assign({}, eventArgs, this.eventState(), this.superTraits()),
eventArgs, });
this.eventState(),
this.superTraits(),
),
})
} }
firstDaySeen() { firstDaySeen() {
let firstDaySeen = NylasEnv.config.get("firstDaySeen"); let firstDaySeen = NylasEnv.config.get('firstDaySeen');
if (!firstDaySeen) { if (!firstDaySeen) {
const [y, m, d] = (new Date()).toISOString().split(/[-|T]/); const [y, m, d] = new Date().toISOString().split(/[-|T]/);
firstDaySeen = `${m}/${d}/${y}`; firstDaySeen = `${m}/${d}/${y}`;
NylasEnv.config.set("firstDaySeen", firstDaySeen); NylasEnv.config.set('firstDaySeen', firstDaySeen);
} }
return firstDaySeen; return firstDaySeen;
} }
@ -127,12 +128,14 @@ class AnalyticsStore extends NylasStore {
} }
const identity = IdentityStore.identity(); const identity = IdentityStore.identity();
if (!(identity && identity.id)) { return; } if (!(identity && identity.id)) {
return;
}
this.analytics.identify({ this.analytics.identify({
userId: identity.id, userId: identity.id,
traits: this.baseTraits(), traits: this.baseTraits(),
integrations: {All: true}, integrations: { All: true },
}); });
// Ensure we never send PI anywhere but Mixpanel // Ensure we never send PI anywhere but Mixpanel
@ -145,7 +148,7 @@ class AnalyticsStore extends NylasStore {
Mixpanel: true, Mixpanel: true,
}, },
}); });
} };
} }
export default new AnalyticsStore() export default new AnalyticsStore();

View file

@ -1,5 +1,5 @@
import AnalyticsStore from './analytics-store' import AnalyticsStore from './analytics-store';
export function activate() { export function activate() {
AnalyticsStore.activate() AnalyticsStore.activate();
} }

View file

@ -1,8 +1,8 @@
import {ComponentRegistry} from 'nylas-exports'; import { ComponentRegistry } from 'nylas-exports';
import MessageAttachments from './message-attachments' import MessageAttachments from './message-attachments';
export function activate() { export function activate() {
ComponentRegistry.register(MessageAttachments, {role: 'MessageAttachments'}) ComponentRegistry.register(MessageAttachments, { role: 'MessageAttachments' });
} }
export function deactivate() { export function deactivate() {

View file

@ -1,13 +1,12 @@
import React, {Component} from 'react' import React, { Component } from 'react';
import PropTypes from 'prop-types' import PropTypes from 'prop-types';
import {Actions, Utils, AttachmentStore} from 'nylas-exports' import { Actions, Utils, AttachmentStore } from 'nylas-exports';
import {AttachmentItem, ImageAttachmentItem} from 'nylas-component-kit' import { AttachmentItem, ImageAttachmentItem } from 'nylas-component-kit';
class MessageAttachments extends Component { class MessageAttachments extends Component {
static displayName = 'MessageAttachments' static displayName = 'MessageAttachments';
static containerRequired = false static containerRequired = false;
static propTypes = { static propTypes = {
files: PropTypes.array, files: PropTypes.array,
@ -15,42 +14,42 @@ class MessageAttachments extends Component {
headerMessageId: PropTypes.string, headerMessageId: PropTypes.string,
filePreviewPaths: PropTypes.object, filePreviewPaths: PropTypes.object,
canRemoveAttachments: PropTypes.bool, canRemoveAttachments: PropTypes.bool,
} };
static defaultProps = { static defaultProps = {
downloads: {}, downloads: {},
filePreviewPaths: {}, filePreviewPaths: {},
} };
onOpenAttachment = (file) => { onOpenAttachment = file => {
Actions.fetchAndOpenFile(file) Actions.fetchAndOpenFile(file);
} };
onRemoveAttachment = (file) => { onRemoveAttachment = file => {
const {headerMessageId} = this.props const { headerMessageId } = this.props;
Actions.removeAttachment({ Actions.removeAttachment({
headerMessageId: headerMessageId, headerMessageId: headerMessageId,
file: file, file: file,
}) });
} };
onDownloadAttachment = (file) => { onDownloadAttachment = file => {
Actions.fetchAndSaveFile(file) Actions.fetchAndSaveFile(file);
} };
onAbortDownload = (file) => { onAbortDownload = file => {
Actions.abortFetchFile(file) Actions.abortFetchFile(file);
} };
renderAttachment(AttachmentRenderer, file) { renderAttachment(AttachmentRenderer, file) {
const {canRemoveAttachments, downloads, filePreviewPaths, headerMessageId} = this.props const { canRemoveAttachments, downloads, filePreviewPaths, headerMessageId } = this.props;
const download = downloads[file.id] const download = downloads[file.id];
const filePath = AttachmentStore.pathForFile(file) const filePath = AttachmentStore.pathForFile(file);
const fileIconName = `file-${file.displayExtension()}.png` const fileIconName = `file-${file.displayExtension()}.png`;
const displayName = file.displayName() const displayName = file.displayName();
const displaySize = file.displayFileSize() const displaySize = file.displayFileSize();
const contentType = file.contentType const contentType = file.contentType;
const displayFilePreview = NylasEnv.config.get('core.attachments.displayFilePreview') const displayFilePreview = NylasEnv.config.get('core.attachments.displayFilePreview');
const filePreviewPath = displayFilePreview ? filePreviewPaths[file.id] : null; const filePreviewPath = displayFilePreview ? filePreviewPaths[file.id] : null;
return ( return (
@ -68,26 +67,24 @@ class MessageAttachments extends Component {
onOpenAttachment={() => this.onOpenAttachment(file)} onOpenAttachment={() => this.onOpenAttachment(file)}
onDownloadAttachment={() => this.onDownloadAttachment(file)} onDownloadAttachment={() => this.onDownloadAttachment(file)}
onAbortDownload={() => this.onAbortDownload(file)} onAbortDownload={() => this.onAbortDownload(file)}
onRemoveAttachment={canRemoveAttachments ? () => this.onRemoveAttachment(headerMessageId, file) : null} onRemoveAttachment={
canRemoveAttachments ? () => this.onRemoveAttachment(headerMessageId, file) : null
}
/> />
) );
} }
render() { render() {
const {files} = this.props; const { files } = this.props;
const nonImageFiles = files.filter((f) => !Utils.shouldDisplayAsImage(f)); const nonImageFiles = files.filter(f => !Utils.shouldDisplayAsImage(f));
const imageFiles = files.filter((f) => Utils.shouldDisplayAsImage(f)); const imageFiles = files.filter(f => Utils.shouldDisplayAsImage(f));
return ( return (
<div> <div>
{nonImageFiles.map((file) => {nonImageFiles.map(file => this.renderAttachment(AttachmentItem, file))}
this.renderAttachment(AttachmentItem, file) {imageFiles.map(file => this.renderAttachment(ImageAttachmentItem, file))}
)}
{imageFiles.map((file) =>
this.renderAttachment(ImageAttachmentItem, file)
)}
</div> </div>
) );
} }
} }
export default MessageAttachments export default MessageAttachments;

View file

@ -1,12 +1,7 @@
/* eslint jsx-a11y/tabindex-no-positive: 0 */ /* eslint jsx-a11y/tabindex-no-positive: 0 */
import React, {Component} from 'react' import React, { Component } from 'react';
import PropTypes from 'prop-types' import PropTypes from 'prop-types';
import { import { Menu, RetinaImg, LabelColorizer, BoldedSearchResult } from 'nylas-component-kit';
Menu,
RetinaImg,
LabelColorizer,
BoldedSearchResult,
} from 'nylas-component-kit'
import { import {
Utils, Utils,
Actions, Actions,
@ -14,92 +9,92 @@ import {
Label, Label,
SyncbackCategoryTask, SyncbackCategoryTask,
ChangeLabelsTask, ChangeLabelsTask,
} from 'nylas-exports' } from 'nylas-exports';
import {Categories} from 'nylas-observables' import { Categories } from 'nylas-observables';
export default class LabelPickerPopover extends Component { export default class LabelPickerPopover extends Component {
static propTypes = { static propTypes = {
threads: PropTypes.array.isRequired, threads: PropTypes.array.isRequired,
account: PropTypes.object.isRequired, account: PropTypes.object.isRequired,
}; };
constructor(props) { constructor(props) {
super(props) super(props);
this._labels = [] this._labels = [];
this.state = this._recalculateState(this.props, {searchValue: ''}) this.state = this._recalculateState(this.props, { searchValue: '' });
} }
componentDidMount() { componentDidMount() {
this._registerObservables() this._registerObservables();
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
this._registerObservables(nextProps) this._registerObservables(nextProps);
this.setState(this._recalculateState(nextProps)) this.setState(this._recalculateState(nextProps));
} }
componentWillUnmount() { componentWillUnmount() {
this._unregisterObservables() this._unregisterObservables();
} }
_registerObservables = (props = this.props) => { _registerObservables = (props = this.props) => {
this._unregisterObservables() this._unregisterObservables();
this.disposables = [ this.disposables = [
Categories.forAccount(props.account).sort().subscribe(this._onLabelsChanged), Categories.forAccount(props.account)
] .sort()
.subscribe(this._onLabelsChanged),
];
}; };
_unregisterObservables = () => { _unregisterObservables = () => {
if (this.disposables) { if (this.disposables) {
this.disposables.forEach(disp => disp.dispose()) this.disposables.forEach(disp => disp.dispose());
} }
}; };
_recalculateState = (props = this.props, {searchValue = (this.state.searchValue || "")} = {}) => { _recalculateState = (props = this.props, { searchValue = this.state.searchValue || '' } = {}) => {
const {threads} = props const { threads } = props;
if (threads.length === 0) { if (threads.length === 0) {
return {categoryData: [], searchValue} return { categoryData: [], searchValue };
} }
const categoryData = this._labels.filter(label => const categoryData = this._labels
Utils.wordSearchRegExp(searchValue).test(label.displayName) .filter(label => Utils.wordSearchRegExp(searchValue).test(label.displayName))
).map((label) => { .map(label => {
return { return {
id: label.id, id: label.id,
category: label, category: label,
displayName: label.displayName, displayName: label.displayName,
backgroundColor: LabelColorizer.backgroundColorDark(label), backgroundColor: LabelColorizer.backgroundColorDark(label),
usage: threads.filter(t => t.categories.find(c => c.id === label.id)).length, usage: threads.filter(t => t.categories.find(c => c.id === label.id)).length,
numThreads: threads.length, numThreads: threads.length,
}; };
}); });
if (searchValue.length > 0) { if (searchValue.length > 0) {
categoryData.push({ categoryData.push({
searchValue: searchValue, searchValue: searchValue,
newCategoryItem: true, newCategoryItem: true,
id: "category-create-new", id: 'category-create-new',
}); });
} }
return {categoryData, searchValue} return { categoryData, searchValue };
}; };
_onLabelsChanged = (categories) => { _onLabelsChanged = categories => {
this._labels = categories.filter(c => { this._labels = categories.filter(c => {
return (c instanceof Label) && (!c.role); return c instanceof Label && !c.role;
}); });
this.setState(this._recalculateState()) this.setState(this._recalculateState());
}; };
_onEscape = () => { _onEscape = () => {
Actions.closePopover() Actions.closePopover();
}; };
_onSelectLabel = (item) => { _onSelectLabel = item => {
const {account, threads} = this.props const { account, threads } = this.props;
if (threads.length === 0) return; if (threads.length === 0) return;
@ -107,52 +102,56 @@ export default class LabelPickerPopover extends Component {
const syncbackTask = new SyncbackCategoryTask({ const syncbackTask = new SyncbackCategoryTask({
path: this.state.searchValue, path: this.state.searchValue,
accountId: account.id, accountId: account.id,
}) });
TaskQueue.waitForPerformRemote(syncbackTask).then((finishedTask) => { TaskQueue.waitForPerformRemote(syncbackTask).then(finishedTask => {
if (!finishedTask.created) { if (!finishedTask.created) {
NylasEnv.showErrorDialog({title: "Error", message: `Could not create label.`}) NylasEnv.showErrorDialog({ title: 'Error', message: `Could not create label.` });
return; return;
} }
Actions.queueTask(new ChangeLabelsTask({ Actions.queueTask(
source: "Category Picker: New Category", new ChangeLabelsTask({
threads: threads, source: 'Category Picker: New Category',
labelsToRemove: [], threads: threads,
labelsToAdd: [finishedTask.created], labelsToRemove: [],
})); labelsToAdd: [finishedTask.created],
}) })
);
});
Actions.queueTask(syncbackTask); Actions.queueTask(syncbackTask);
} else if (item.usage === threads.length) { } else if (item.usage === threads.length) {
Actions.queueTask(new ChangeLabelsTask({ Actions.queueTask(
source: "Category Picker: Existing Category", new ChangeLabelsTask({
threads: threads, source: 'Category Picker: Existing Category',
labelsToRemove: [item.category], threads: threads,
labelsToAdd: [], labelsToRemove: [item.category],
})); labelsToAdd: [],
})
);
} else { } else {
Actions.queueTask(new ChangeLabelsTask({ Actions.queueTask(
source: "Category Picker: Existing Category", new ChangeLabelsTask({
threads: threads, source: 'Category Picker: Existing Category',
labelsToRemove: [], threads: threads,
labelsToAdd: [item.category], labelsToRemove: [],
})); labelsToAdd: [item.category],
})
);
} }
Actions.closePopover() Actions.closePopover();
}; };
_onSearchValueChange = (event) => { _onSearchValueChange = event => {
this.setState( this.setState(this._recalculateState(this.props, { searchValue: event.target.value }));
this._recalculateState(this.props, {searchValue: event.target.value})
)
}; };
_renderCheckbox = (item) => { _renderCheckbox = item => {
const styles = {} const styles = {};
let checkStatus; let checkStatus;
styles.backgroundColor = item.backgroundColor styles.backgroundColor = item.backgroundColor;
if (item.usage === 0) { if (item.usage === 0) {
checkStatus = <span /> checkStatus = <span />;
} else if (item.usage < item.numThreads) { } else if (item.usage < item.numThreads) {
checkStatus = ( checkStatus = (
<RetinaImg <RetinaImg
@ -161,7 +160,7 @@ export default class LabelPickerPopover extends Component {
mode={RetinaImg.Mode.ContentPreserve} mode={RetinaImg.Mode.ContentPreserve}
onClick={() => this._onSelectLabel(item)} onClick={() => this._onSelectLabel(item)}
/> />
) );
} else { } else {
checkStatus = ( checkStatus = (
<RetinaImg <RetinaImg
@ -170,7 +169,7 @@ export default class LabelPickerPopover extends Component {
mode={RetinaImg.Mode.ContentPreserve} mode={RetinaImg.Mode.ContentPreserve}
onClick={() => this._onSelectLabel(item)} onClick={() => this._onSelectLabel(item)}
/> />
) );
} }
return ( return (
@ -183,10 +182,10 @@ export default class LabelPickerPopover extends Component {
/> />
{checkStatus} {checkStatus}
</div> </div>
) );
}; };
_renderCreateNewItem = ({searchValue}) => { _renderCreateNewItem = ({ searchValue }) => {
return ( return (
<div className="category-item category-create-new"> <div className="category-item category-create-new">
<RetinaImg <RetinaImg
@ -198,24 +197,24 @@ export default class LabelPickerPopover extends Component {
<strong>&ldquo;{searchValue}&rdquo;</strong> (create new) <strong>&ldquo;{searchValue}&rdquo;</strong> (create new)
</div> </div>
</div> </div>
) );
}; };
_renderItem = (item) => { _renderItem = item => {
if (item.divider) { if (item.divider) {
return <Menu.Item key={item.id} divider={item.divider} /> return <Menu.Item key={item.id} divider={item.divider} />;
} else if (item.newCategoryItem) { } else if (item.newCategoryItem) {
return this._renderCreateNewItem(item) return this._renderCreateNewItem(item);
} }
return ( return (
<div className="category-item"> <div className="category-item">
{this._renderCheckbox(item)} {this._renderCheckbox(item)}
<div className="category-display-name"> <div className="category-display-name">
<BoldedSearchResult value={item.displayName} query={this.state.searchValue || ""} /> <BoldedSearchResult value={item.displayName} query={this.state.searchValue || ''} />
</div> </div>
</div> </div>
) );
}; };
render() { render() {
@ -229,7 +228,7 @@ export default class LabelPickerPopover extends Component {
value={this.state.searchValue} value={this.state.searchValue}
onChange={this._onSearchValueChange} onChange={this._onSearchValueChange}
/>, />,
] ];
return ( return (
<div className="category-picker-popover"> <div className="category-picker-popover">
@ -241,9 +240,9 @@ export default class LabelPickerPopover extends Component {
itemContent={this._renderItem} itemContent={this._renderItem}
onSelect={this._onSelectLabel} onSelect={this._onSelectLabel}
onEscape={this._onEscape} onEscape={this._onEscape}
defaultSelectedIndex={this.state.searchValue === "" ? -1 : 0} defaultSelectedIndex={this.state.searchValue === '' ? -1 : 0}
/> />
</div> </div>
) );
} }
} }

View file

@ -1,9 +1,8 @@
_ = require 'underscore' _ = require 'underscore'
React = require 'react'
ReactDOM = require 'react-dom'
{Actions, {Actions,
AccountStore, AccountStore,
React, ReactDOM, PropTypes,
WorkspaceStore} = require 'nylas-exports' WorkspaceStore} = require 'nylas-exports'
{RetinaImg, {RetinaImg,
@ -19,10 +18,10 @@ class LabelPicker extends React.Component
@containerRequired: false @containerRequired: false
@propTypes: @propTypes:
items: React.PropTypes.array items: PropTypes.array
@contextTypes: @contextTypes:
sheetDepth: React.PropTypes.number sheetDepth: PropTypes.number
constructor: (@props) -> constructor: (@props) ->
@_account = AccountStore.accountForItems(@props.items) @_account = AccountStore.accountForItems(@props.items)

View file

@ -1,12 +1,7 @@
/* eslint jsx-a11y/tabindex-no-positive: 0 */ /* eslint jsx-a11y/tabindex-no-positive: 0 */
import React, {Component} from 'react' import React, { Component } from 'react';
import PropTypes from 'prop-types' import PropTypes from 'prop-types';
import { import { Menu, RetinaImg, LabelColorizer, BoldedSearchResult } from 'nylas-component-kit';
Menu,
RetinaImg,
LabelColorizer,
BoldedSearchResult,
} from 'nylas-component-kit'
import { import {
Utils, Utils,
Actions, Actions,
@ -17,12 +12,10 @@ import {
ChangeFolderTask, ChangeFolderTask,
ChangeLabelsTask, ChangeLabelsTask,
FocusedPerspectiveStore, FocusedPerspectiveStore,
} from 'nylas-exports' } from 'nylas-exports';
import {Categories} from 'nylas-observables' import { Categories } from 'nylas-observables';
export default class MovePickerPopover extends Component { export default class MovePickerPopover extends Component {
static propTypes = { static propTypes = {
threads: PropTypes.array.isRequired, threads: PropTypes.array.isRequired,
account: PropTypes.object.isRequired, account: PropTypes.object.isRequired,
@ -32,7 +25,7 @@ export default class MovePickerPopover extends Component {
super(props); super(props);
this._standardFolders = []; this._standardFolders = [];
this._userCategories = []; this._userCategories = [];
this.state = this._recalculateState(this.props, {searchValue: ''}); this.state = this._recalculateState(this.props, { searchValue: '' });
} }
componentDidMount() { componentDidMount() {
@ -51,7 +44,9 @@ export default class MovePickerPopover extends Component {
_registerObservables = (props = this.props) => { _registerObservables = (props = this.props) => {
this._unregisterObservables(); this._unregisterObservables();
this.disposables = [ this.disposables = [
Categories.forAccount(props.account).sort().subscribe(this._onCategoriesChanged), Categories.forAccount(props.account)
.sort()
.subscribe(this._onCategoriesChanged),
]; ];
}; };
@ -61,16 +56,16 @@ export default class MovePickerPopover extends Component {
} }
}; };
_recalculateState = (props = this.props, {searchValue = (this.state.searchValue || "")} = {}) => { _recalculateState = (props = this.props, { searchValue = this.state.searchValue || '' } = {}) => {
const {threads, account} = props const { threads, account } = props;
if (threads.length === 0) { if (threads.length === 0) {
return {categoryData: [], searchValue} return { categoryData: [], searchValue };
} }
const currentCategories = FocusedPerspectiveStore.current().categories() || []; const currentCategories = FocusedPerspectiveStore.current().categories() || [];
const currentCategoryIds = currentCategories.map(c => c.id); const currentCategoryIds = currentCategories.map(c => c.id);
const viewingAllMail = !currentCategories.find(c => c.role === 'spam' || c.role === 'trash'); const viewingAllMail = !currentCategories.find(c => c.role === 'spam' || c.role === 'trash');
const hidden = account ? ["drafts", "sent", "snoozed"] : []; const hidden = account ? ['drafts', 'sent', 'snoozed'] : [];
if (viewingAllMail) { if (viewingAllMail) {
hidden.push('all'); hidden.push('all');
@ -78,18 +73,17 @@ export default class MovePickerPopover extends Component {
const categoryData = [] const categoryData = []
.concat(this._standardFolders) .concat(this._standardFolders)
.concat([{divider: true, id: "category-divider"}]) .concat([{ divider: true, id: 'category-divider' }])
.concat(this._userCategories) .concat(this._userCategories)
.filter((cat) => .filter(
// remove categories that are part of the current perspective or locked cat =>
!hidden.includes(cat.role) && !currentCategoryIds.includes(cat.id) // remove categories that are part of the current perspective or locked
!hidden.includes(cat.role) && !currentCategoryIds.includes(cat.id)
) )
.filter((cat) => .filter(cat => Utils.wordSearchRegExp(searchValue).test(cat.displayName))
Utils.wordSearchRegExp(searchValue).test(cat.displayName) .map(cat => {
)
.map((cat) => {
if (cat.divider) { if (cat.divider) {
return cat return cat;
} }
return { return {
id: cat.id, id: cat.id,
@ -103,24 +97,24 @@ export default class MovePickerPopover extends Component {
const newItemData = { const newItemData = {
searchValue: searchValue, searchValue: searchValue,
newCategoryItem: true, newCategoryItem: true,
id: "category-create-new", id: 'category-create-new',
} };
categoryData.push(newItemData) categoryData.push(newItemData);
} }
return {categoryData, searchValue} return { categoryData, searchValue };
}; };
_onCategoriesChanged = (categories) => { _onCategoriesChanged = categories => {
this._standardFolders = categories.filter(c => c.role && c instanceof Folder); this._standardFolders = categories.filter(c => c.role && c instanceof Folder);
this._userCategories = categories.filter(c => !c.role || !(c instanceof Folder)); this._userCategories = categories.filter(c => !c.role || !(c instanceof Folder));
this.setState(this._recalculateState()) this.setState(this._recalculateState());
}; };
_onEscape = () => { _onEscape = () => {
Actions.closePopover() Actions.closePopover();
}; };
_onSelectCategory = (item) => { _onSelectCategory = item => {
if (this.props.threads.length === 0) { if (this.props.threads.length === 0) {
return; return;
} }
@ -132,7 +126,7 @@ export default class MovePickerPopover extends Component {
} }
Actions.popSheet(); Actions.popSheet();
Actions.closePopover(); Actions.closePopover();
} };
_onCreateCategory = () => { _onCreateCategory = () => {
const syncbackTask = new SyncbackCategoryTask({ const syncbackTask = new SyncbackCategoryTask({
@ -140,46 +134,49 @@ export default class MovePickerPopover extends Component {
accountId: this.props.account.id, accountId: this.props.account.id,
}); });
TaskQueue.waitForPerformRemote(syncbackTask).then((finishedTask) => { TaskQueue.waitForPerformRemote(syncbackTask).then(finishedTask => {
if (!finishedTask.created) { if (!finishedTask.created) {
NylasEnv.showErrorDialog({title: "Error", message: `Could not create folder.`}) NylasEnv.showErrorDialog({ title: 'Error', message: `Could not create folder.` });
return; return;
} }
this._onMoveToCategory({category: finishedTask.created}); this._onMoveToCategory({ category: finishedTask.created });
}); });
Actions.queueTask(syncbackTask); Actions.queueTask(syncbackTask);
} };
_onMoveToCategory = ({category}) => { _onMoveToCategory = ({ category }) => {
const {threads} = this.props const { threads } = this.props;
if (category instanceof Folder) { if (category instanceof Folder) {
Actions.queueTask(new ChangeFolderTask({ Actions.queueTask(
source: "Category Picker: New Category", new ChangeFolderTask({
threads: threads, source: 'Category Picker: New Category',
folder: category, threads: threads,
})); folder: category,
})
);
} else { } else {
const all = []; const all = [];
threads.forEach(({labels}) => all.push(...labels)); threads.forEach(({ labels }) => all.push(...labels));
Actions.queueTask(new ChangeLabelsTask({ Actions.queueTask(
source: "Category Picker: New Category", new ChangeLabelsTask({
labelsToRemove: all, source: 'Category Picker: New Category',
labelsToAdd: [category], labelsToRemove: all,
threads: threads, labelsToAdd: [category],
})); threads: threads,
})
);
} }
}; };
_onSearchValueChange = (event) => { _onSearchValueChange = event => {
this.setState( this.setState(this._recalculateState(this.props, { searchValue: event.target.value }));
this._recalculateState(this.props, {searchValue: event.target.value})
)
}; };
_renderCreateNewItem = ({searchValue}) => { _renderCreateNewItem = ({ searchValue }) => {
const icon = CategoryStore.getInboxCategory(this.props.account) instanceof Folder ? 'folder' : 'tag'; const icon =
CategoryStore.getInboxCategory(this.props.account) instanceof Folder ? 'folder' : 'tag';
return ( return (
<div className="category-item category-create-new"> <div className="category-item category-create-new">
@ -192,37 +189,35 @@ export default class MovePickerPopover extends Component {
<strong>&ldquo;{searchValue}&rdquo;</strong> (create new) <strong>&ldquo;{searchValue}&rdquo;</strong> (create new)
</div> </div>
</div> </div>
) );
}; };
_renderItem = (item) => { _renderItem = item => {
if (item.divider) { if (item.divider) {
return <Menu.Item key={item.id} divider={item.divider} /> return <Menu.Item key={item.id} divider={item.divider} />;
} else if (item.newCategoryItem) { } else if (item.newCategoryItem) {
return this._renderCreateNewItem(item) return this._renderCreateNewItem(item);
} }
const icon = (item.category instanceof Folder) ? ( const icon =
<RetinaImg item.category instanceof Folder ? (
name={`${item.name}.png`} <RetinaImg
fallback={'folder.png'} name={`${item.name}.png`}
mode={RetinaImg.Mode.ContentIsMask} fallback={'folder.png'}
/> mode={RetinaImg.Mode.ContentIsMask}
) : ( />
<RetinaImg ) : (
name={`tag.png`} <RetinaImg name={`tag.png`} mode={RetinaImg.Mode.ContentIsMask} />
mode={RetinaImg.Mode.ContentIsMask} );
/>
);
return ( return (
<div className="category-item"> <div className="category-item">
{icon} {icon}
<div className="category-display-name"> <div className="category-display-name">
<BoldedSearchResult value={item.displayName} query={this.state.searchValue || ""} /> <BoldedSearchResult value={item.displayName} query={this.state.searchValue || ''} />
</div> </div>
</div> </div>
) );
}; };
render() { render() {
@ -236,7 +231,7 @@ export default class MovePickerPopover extends Component {
value={this.state.searchValue} value={this.state.searchValue}
onChange={this._onSearchValueChange} onChange={this._onSearchValueChange}
/>, />,
] ];
return ( return (
<div className="category-picker-popover"> <div className="category-picker-popover">
@ -248,9 +243,9 @@ export default class MovePickerPopover extends Component {
itemContent={this._renderItem} itemContent={this._renderItem}
onSelect={this._onSelectCategory} onSelect={this._onSelectCategory}
onEscape={this._onEscape} onEscape={this._onEscape}
defaultSelectedIndex={this.state.searchValue === "" ? -1 : 0} defaultSelectedIndex={this.state.searchValue === '' ? -1 : 0}
/> />
</div> </div>
) );
} }
} }

View file

@ -1,8 +1,7 @@
_ = require 'underscore' _ = require 'underscore'
React = require 'react'
ReactDOM = require 'react-dom'
{Actions, {Actions,
React, ReactDOM, PropTypes,
AccountStore, AccountStore,
WorkspaceStore} = require 'nylas-exports' WorkspaceStore} = require 'nylas-exports'
@ -19,10 +18,10 @@ class MovePicker extends React.Component
@containerRequired: false @containerRequired: false
@propTypes: @propTypes:
items: React.PropTypes.array items: PropTypes.array
@contextTypes: @contextTypes:
sheetDepth: React.PropTypes.number sheetDepth: PropTypes.number
constructor: (@props) -> constructor: (@props) ->
@_account = AccountStore.accountForItems(@props.items) @_account = AccountStore.accountForItems(@props.items)

View file

@ -1,5 +1,5 @@
const categorizedEmojiList = { const categorizedEmojiList = {
'People': [ People: [
'grinning', 'grinning',
'grimacing', 'grimacing',
'grin', 'grin',
@ -205,7 +205,7 @@ const categorizedEmojiList = {
'ring', 'ring',
'closed_umbrella', 'closed_umbrella',
], ],
'Nature': [ Nature: [
'dog', 'dog',
'cat', 'cat',
'mouse', 'mouse',
@ -422,7 +422,7 @@ const categorizedEmojiList = {
'fork_and_knife', 'fork_and_knife',
'knife_fork_plate', 'knife_fork_plate',
], ],
'Activity': [ Activity: [
'soccer', 'soccer',
'basketball', 'basketball',
'football', 'football',
@ -598,7 +598,7 @@ const categorizedEmojiList = {
'kaaba', 'kaaba',
'shinto_shrine', 'shinto_shrine',
], ],
'Objects': [ Objects: [
'watch', 'watch',
'iphone', 'iphone',
'calling', 'calling',
@ -777,7 +777,7 @@ const categorizedEmojiList = {
'mag', 'mag',
'mag_right', 'mag_right',
], ],
'Symbols': [ Symbols: [
'heart', 'heart',
'yellow_heart', 'yellow_heart',
'green_heart', 'green_heart',
@ -1048,7 +1048,7 @@ const categorizedEmojiList = {
'clock1130', 'clock1130',
'clock1230', 'clock1230',
], ],
'Flags': [ Flags: [
'flag-ac', 'flag-ac',
'flag-ad', 'flag-ad',
'flag-ae', 'flag-ae',
@ -1307,5 +1307,5 @@ const categorizedEmojiList = {
'flag-zm', 'flag-zm',
'flag-zw', 'flag-zw',
], ],
} };
export default categorizedEmojiList export default categorizedEmojiList;

View file

@ -1,9 +1,6 @@
import Reflux from 'reflux'; import Reflux from 'reflux';
const EmojiActions = Reflux.createActions([ const EmojiActions = Reflux.createActions(['selectEmoji', 'useEmoji']);
"selectEmoji",
"useEmoji",
]);
for (const key of Object.keys(EmojiActions)) { for (const key of Object.keys(EmojiActions)) {
EmojiActions[key].sync = true; EmojiActions[key].sync = true;

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import {Actions} from 'nylas-exports'; import { Actions } from 'nylas-exports';
import {RetinaImg, ScrollRegion} from 'nylas-component-kit'; import { RetinaImg, ScrollRegion } from 'nylas-component-kit';
import EmojiStore from './emoji-store'; import EmojiStore from './emoji-store';
import EmojiActions from './emoji-actions'; import EmojiActions from './emoji-actions';
@ -11,15 +11,13 @@ class EmojiButtonPopover extends React.Component {
constructor() { constructor() {
super(); super();
const {categoryNames, const { categoryNames, categorizedEmoji, categoryPositions } = this.getStateFromStore();
categorizedEmoji,
categoryPositions} = this.getStateFromStore();
this.state = { this.state = {
emojiName: "Emoji Picker", emojiName: 'Emoji Picker',
categoryNames: categoryNames, categoryNames: categoryNames,
categorizedEmoji: categorizedEmoji, categorizedEmoji: categorizedEmoji,
categoryPositions: categoryPositions, categoryPositions: categoryPositions,
searchValue: "", searchValue: '',
activeTab: Object.keys(categorizedEmoji)[0], activeTab: Object.keys(categorizedEmoji)[0],
}; };
} }
@ -36,69 +34,77 @@ class EmojiButtonPopover extends React.Component {
this._mounted = false; this._mounted = false;
} }
onMouseDown = (event) => { onMouseDown = event => {
const emojiName = this.calcEmojiByPosition(this.calcPosition(event)); const emojiName = this.calcEmojiByPosition(this.calcPosition(event));
if (!emojiName) return null; if (!emojiName) return null;
EmojiActions.selectEmoji({emojiName: emojiName, replaceSelection: false}); EmojiActions.selectEmoji({ emojiName: emojiName, replaceSelection: false });
Actions.closePopover(); Actions.closePopover();
return null return null;
} };
onScroll = () => { onScroll = () => {
const emojiContainer = document.querySelector(".emoji-finder-container .scroll-region-content"); const emojiContainer = document.querySelector('.emoji-finder-container .scroll-region-content');
const tabContainer = document.querySelector(".emoji-tabs"); const tabContainer = document.querySelector('.emoji-tabs');
tabContainer.className = emojiContainer.scrollTop ? "emoji-tabs shadow" : "emoji-tabs"; tabContainer.className = emojiContainer.scrollTop ? 'emoji-tabs shadow' : 'emoji-tabs';
if (emojiContainer.scrollTop === 0) { if (emojiContainer.scrollTop === 0) {
this.setState({activeTab: Object.keys(this.state.categorizedEmoji)[0]}); this.setState({ activeTab: Object.keys(this.state.categorizedEmoji)[0] });
} else { } else {
for (const category of Object.keys(this.state.categoryPositions)) { for (const category of Object.keys(this.state.categoryPositions)) {
if (emojiContainer.scrollTop >= this.state.categoryPositions[category].top && if (
emojiContainer.scrollTop <= this.state.categoryPositions[category].bottom) { emojiContainer.scrollTop >= this.state.categoryPositions[category].top &&
this.setState({activeTab: category}); emojiContainer.scrollTop <= this.state.categoryPositions[category].bottom
) {
this.setState({ activeTab: category });
} }
} }
} }
} };
onHover = (event) => { onHover = event => {
const emojiName = this.calcEmojiByPosition(this.calcPosition(event)); const emojiName = this.calcEmojiByPosition(this.calcPosition(event));
if (emojiName) { if (emojiName) {
this.setState({emojiName: emojiName}); this.setState({ emojiName: emojiName });
} else { } else {
this.setState({emojiName: "Emoji Picker"}); this.setState({ emojiName: 'Emoji Picker' });
} }
} };
onMouseOut = () => { onMouseOut = () => {
this.setState({emojiName: "Emoji Picker"}); this.setState({ emojiName: 'Emoji Picker' });
} };
onChange = (event) => { onChange = event => {
const searchValue = event.target.value; const searchValue = event.target.value;
if (searchValue.length > 0) { if (searchValue.length > 0) {
const searchMatches = this.findSearchMatches(searchValue); const searchMatches = this.findSearchMatches(searchValue);
this.setState({ this.setState(
categorizedEmoji: { {
'Search Results': searchMatches, categorizedEmoji: {
}, 'Search Results': searchMatches,
categoryPositions: {
'Search Results': {
top: 25,
bottom: 25 + Math.ceil(searchMatches.length / 8) * 24,
}, },
categoryPositions: {
'Search Results': {
top: 25,
bottom: 25 + Math.ceil(searchMatches.length / 8) * 24,
},
},
searchValue: searchValue,
activeTab: null,
}, },
searchValue: searchValue, this.renderCanvas
activeTab: null, );
}, this.renderCanvas);
} else { } else {
this.setState(this.getStateFromStore, () => { this.setState(this.getStateFromStore, () => {
this.setState({ this.setState(
searchValue: searchValue, {
activeTab: Object.keys(this.state.categorizedEmoji)[0], searchValue: searchValue,
}, this.renderCanvas); activeTab: Object.keys(this.state.categorizedEmoji)[0],
},
this.renderCanvas
);
}); });
} }
} };
getStateFromStore = () => { getStateFromStore = () => {
let categorizedEmoji = categorizedEmojiList; let categorizedEmoji = categorizedEmojiList;
@ -115,16 +121,16 @@ class EmojiButtonPopover extends React.Component {
]; ];
const frequentlyUsedEmoji = EmojiStore.frequentlyUsedEmoji(); const frequentlyUsedEmoji = EmojiStore.frequentlyUsedEmoji();
if (frequentlyUsedEmoji.length > 0) { if (frequentlyUsedEmoji.length > 0) {
categorizedEmoji = {'Frequently Used': frequentlyUsedEmoji}; categorizedEmoji = { 'Frequently Used': frequentlyUsedEmoji };
for (const category of Object.keys(categorizedEmojiList)) { for (const category of Object.keys(categorizedEmojiList)) {
categorizedEmoji[category] = categorizedEmojiList[category]; categorizedEmoji[category] = categorizedEmojiList[category];
} }
categoryNames = ["Frequently Used"].concat(categoryNames); categoryNames = ['Frequently Used'].concat(categoryNames);
} }
// Calculates where each category should be (variable because Frequently // Calculates where each category should be (variable because Frequently
// Used may or may not be present) // Used may or may not be present)
for (const name of categoryNames) { for (const name of categoryNames) {
categoryPositions[name] = {top: 0, bottom: 0}; categoryPositions[name] = { top: 0, bottom: 0 };
} }
let verticalPos = 25; let verticalPos = 25;
for (const category of Object.keys(categoryPositions)) { for (const category of Object.keys(categoryPositions)) {
@ -139,12 +145,12 @@ class EmojiButtonPopover extends React.Component {
categorizedEmoji: categorizedEmoji, categorizedEmoji: categorizedEmoji,
categoryPositions: categoryPositions, categoryPositions: categoryPositions,
}; };
} };
scrollToCategory(category) { scrollToCategory(category) {
const container = document.querySelector(".emoji-finder-container .scroll-region-content"); const container = document.querySelector('.emoji-finder-container .scroll-region-content');
if (this.state.searchValue.length > 0) { if (this.state.searchValue.length > 0) {
this.setState({searchValue: ""}); this.setState({ searchValue: '' });
this.setState(this.getStateFromStore, () => { this.setState(this.getStateFromStore, () => {
this.renderCanvas(); this.renderCanvas();
container.scrollTop = this.state.categoryPositions[category].top + 16; container.scrollTop = this.state.categoryPositions[category].top + 16;
@ -152,14 +158,14 @@ class EmojiButtonPopover extends React.Component {
} else { } else {
container.scrollTop = this.state.categoryPositions[category].top + 16; container.scrollTop = this.state.categoryPositions[category].top + 16;
} }
this.setState({activeTab: category}) this.setState({ activeTab: category });
} }
findSearchMatches(searchValue) { findSearchMatches(searchValue) {
// TODO: Find matches for aliases, too. // TODO: Find matches for aliases, too.
const searchMatches = []; const searchMatches = [];
for (const category of Object.keys(categorizedEmojiList)) { for (const category of Object.keys(categorizedEmojiList)) {
categorizedEmojiList[category].forEach((emojiName) => { categorizedEmojiList[category].forEach(emojiName => {
if (emojiName.indexOf(searchValue) !== -1) { if (emojiName.indexOf(searchValue) !== -1) {
searchMatches.push(emojiName); searchMatches.push(emojiName);
} }
@ -177,39 +183,43 @@ class EmojiButtonPopover extends React.Component {
return position; return position;
} }
calcEmojiByPosition = (position) => { calcEmojiByPosition = position => {
for (const category of Object.keys(this.state.categoryPositions)) { for (const category of Object.keys(this.state.categoryPositions)) {
const LEFT_BOUNDARY = 8; const LEFT_BOUNDARY = 8;
const RIGHT_BOUNDARY = 204; const RIGHT_BOUNDARY = 204;
const EMOJI_WIDTH = 24.5; const EMOJI_WIDTH = 24.5;
const EMOJI_HEIGHT = 24; const EMOJI_HEIGHT = 24;
const EMOJI_PER_ROW = 8; const EMOJI_PER_ROW = 8;
if (position.x >= LEFT_BOUNDARY && if (
position.x <= RIGHT_BOUNDARY && position.x >= LEFT_BOUNDARY &&
position.y >= this.state.categoryPositions[category].top && position.x <= RIGHT_BOUNDARY &&
position.y <= this.state.categoryPositions[category].bottom) { position.y >= this.state.categoryPositions[category].top &&
position.y <= this.state.categoryPositions[category].bottom
) {
const x = Math.round((position.x + 5) / EMOJI_WIDTH); const x = Math.round((position.x + 5) / EMOJI_WIDTH);
const y = Math.round((position.y - this.state.categoryPositions[category].top + 10) / EMOJI_HEIGHT); const y = Math.round(
(position.y - this.state.categoryPositions[category].top + 10) / EMOJI_HEIGHT
);
const index = x + (y - 1) * EMOJI_PER_ROW - 1; const index = x + (y - 1) * EMOJI_PER_ROW - 1;
return this.state.categorizedEmoji[category][index]; return this.state.categorizedEmoji[category][index];
} }
} }
return null; return null;
} };
renderTabs() { renderTabs() {
const tabs = []; const tabs = [];
this.state.categoryNames.forEach((category) => { this.state.categoryNames.forEach(category => {
let className = `emoji-tab ${(category.replace(/ /g, '-')).toLowerCase()}` let className = `emoji-tab ${category.replace(/ /g, '-').toLowerCase()}`;
if (category === this.state.activeTab) { if (category === this.state.activeTab) {
className += " active"; className += ' active';
} }
tabs.push( tabs.push(
<div key={`${category} container`} style={{flex: 1}}> <div key={`${category} container`} style={{ flex: 1 }}>
<RetinaImg <RetinaImg
key={`${category} tab`} key={`${category} tab`}
className={className} className={className}
name={`icon-emojipicker-${(category.replace(/ /g, '-')).toLowerCase()}.png`} name={`icon-emojipicker-${category.replace(/ /g, '-').toLowerCase()}.png`}
mode={RetinaImg.Mode.ContentIsMask} mode={RetinaImg.Mode.ContentIsMask}
onMouseDown={() => this.scrollToCategory(category)} onMouseDown={() => this.scrollToCategory(category)}
/> />
@ -222,14 +232,14 @@ class EmojiButtonPopover extends React.Component {
renderCanvas() { renderCanvas() {
const keys = Object.keys(this.state.categoryPositions); const keys = Object.keys(this.state.categoryPositions);
this._canvasEl.height = this.state.categoryPositions[keys[keys.length - 1]].bottom * 2; this._canvasEl.height = this.state.categoryPositions[keys[keys.length - 1]].bottom * 2;
const ctx = this._canvasEl.getContext("2d"); const ctx = this._canvasEl.getContext('2d');
ctx.font = "24px Nylas-Pro"; ctx.font = '24px Nylas-Pro';
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.clearRect(0, 0, this._canvasEl.width, this._canvasEl.height); ctx.clearRect(0, 0, this._canvasEl.width, this._canvasEl.height);
const position = { const position = {
x: 15, x: 15,
y: 45, y: 45,
} };
let idx = 0; let idx = 0;
const categoryNames = Object.keys(this.state.categorizedEmoji); const categoryNames = Object.keys(this.state.categorizedEmoji);
@ -238,12 +248,12 @@ class EmojiButtonPopover extends React.Component {
if (!this._mounted) return; if (!this._mounted) return;
this.renderCategory(categoryNames[idx], idx, ctx, position, renderNextCategory); this.renderCategory(categoryNames[idx], idx, ctx, position, renderNextCategory);
idx += 1; idx += 1;
} };
renderNextCategory(); renderNextCategory();
} }
renderCategory(category, i, ctx, pos, callback) { renderCategory(category, i, ctx, pos, callback) {
const position = pos const position = pos;
if (i > 0) { if (i > 0) {
position.x = 18; position.x = 18;
position.y += 48; position.y += 48;
@ -267,10 +277,10 @@ class EmojiButtonPopover extends React.Component {
position.x += 50; position.x += 50;
} }
return {src, x, y}; return { src, x, y };
}); });
const drawEmojiAt = ({src, x, y} = {}) => { const drawEmojiAt = ({ src, x, y } = {}) => {
if (!src) { if (!src) {
return; return;
} }
@ -282,9 +292,9 @@ class EmojiButtonPopover extends React.Component {
} else { } else {
drawEmojiAt(emojiToDraw.shift()); drawEmojiAt(emojiToDraw.shift());
} }
} };
this._emojiPreloadImage.src = src; this._emojiPreloadImage.src = src;
} };
drawEmojiAt(emojiToDraw.shift()); drawEmojiAt(emojiToDraw.shift());
} }
@ -292,13 +302,8 @@ class EmojiButtonPopover extends React.Component {
render() { render() {
return ( return (
<div className="emoji-button-popover" tabIndex="-1"> <div className="emoji-button-popover" tabIndex="-1">
<div className="emoji-tabs"> <div className="emoji-tabs">{this.renderTabs()}</div>
{this.renderTabs()} <ScrollRegion className="emoji-finder-container" onScroll={this.onScroll}>
</div>
<ScrollRegion
className="emoji-finder-container"
onScroll={this.onScroll}
>
<div className="emoji-search-container"> <div className="emoji-search-container">
<input <input
type="text" type="text"
@ -308,18 +313,18 @@ class EmojiButtonPopover extends React.Component {
/> />
</div> </div>
<canvas <canvas
ref={(el) => { this._canvasEl = el; }} ref={el => {
this._canvasEl = el;
}}
width="400" width="400"
height="2000" height="2000"
onMouseDown={this.onMouseDown} onMouseDown={this.onMouseDown}
onMouseOut={this.onMouseOut} onMouseOut={this.onMouseOut}
onMouseMove={this.onHover} onMouseMove={this.onHover}
style={{zoom: "0.5"}} style={{ zoom: '0.5' }}
/> />
</ScrollRegion> </ScrollRegion>
<div className="emoji-name"> <div className="emoji-name">{this.state.emojiName}</div>
{this.state.emojiName}
</div>
</div> </div>
); );
} }

View file

@ -1,23 +1,24 @@
import {Actions, React, ReactDOM} from 'nylas-exports'; import { Actions, React, ReactDOM } from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit'; import { RetinaImg } from 'nylas-component-kit';
import EmojiButtonPopover from './emoji-button-popover'; import EmojiButtonPopover from './emoji-button-popover';
class EmojiButton extends React.Component { class EmojiButton extends React.Component {
static displayName = 'EmojiButton'; static displayName = 'EmojiButton';
onClick = () => { onClick = () => {
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
Actions.openPopover( Actions.openPopover(<EmojiButtonPopover />, { originRect: buttonRect, direction: 'up' });
<EmojiButtonPopover />, };
{originRect: buttonRect, direction: 'up'}
)
}
render() { render() {
return ( return (
<button tabIndex={-1} className="btn btn-toolbar btn-emoji" title="Insert emoji…" onClick={this.onClick}> <button
tabIndex={-1}
className="btn btn-toolbar btn-emoji"
title="Insert emoji…"
onClick={this.onClick}
>
<RetinaImg name="icon-composer-emoji.png" mode={RetinaImg.Mode.ContentIsMask} /> <RetinaImg name="icon-composer-emoji.png" mode={RetinaImg.Mode.ContentIsMask} />
</button> </button>
); );

View file

@ -1,116 +1,119 @@
import {DOMUtils, ComposerExtension, RegExpUtils} from 'nylas-exports'; import { DOMUtils, ComposerExtension, RegExpUtils } from 'nylas-exports';
import emoji from 'node-emoji'; import emoji from 'node-emoji';
import EmojiStore from './emoji-store'; import EmojiStore from './emoji-store';
import EmojiActions from './emoji-actions'; import EmojiActions from './emoji-actions';
import EmojiPicker from './emoji-picker'; import EmojiPicker from './emoji-picker';
class EmojiComposerExtension extends ComposerExtension { class EmojiComposerExtension extends ComposerExtension {
static selState = null; static selState = null;
static onContentChanged = ({editor}) => { static onContentChanged = ({ editor }) => {
const sel = editor.currentSelection() const sel = editor.currentSelection();
const {emojiOptions, triggerWord} = EmojiComposerExtension._findEmojiOptions(sel); const { emojiOptions, triggerWord } = EmojiComposerExtension._findEmojiOptions(sel);
if (sel.anchorNode && sel.isCollapsed) { if (sel.anchorNode && sel.isCollapsed) {
if (emojiOptions.length > 0) { if (emojiOptions.length > 0) {
if (!DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")) { if (!DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete')) {
const anchorOffset = Math.max(sel.anchorOffset - triggerWord.length - 1, 0); const anchorOffset = Math.max(sel.anchorOffset - triggerWord.length - 1, 0);
editor.select(sel.anchorNode, editor.select(sel.anchorNode, anchorOffset, sel.focusNode, sel.focusOffset);
anchorOffset,
sel.focusNode, DOMUtils.wrap(sel.getRangeAt(0), 'n1-emoji-autocomplete');
sel.focusOffset)
DOMUtils.wrap(sel.getRangeAt(0), "n1-emoji-autocomplete");
editor.currentSelection().collapseToEnd(); editor.currentSelection().collapseToEnd();
} }
} else { } else {
if (DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")) { if (DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete')) {
editor.unwrapNodeAndSelectAll(DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")); editor.unwrapNodeAndSelectAll(DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete'));
editor.currentSelection().collapseToEnd(); editor.currentSelection().collapseToEnd();
} }
} }
} else { } else {
if (DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")) { if (DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete')) {
editor.unwrapNodeAndSelectAll(DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")); editor.unwrapNodeAndSelectAll(DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete'));
editor.currentSelection().collapseToEnd(); editor.currentSelection().collapseToEnd();
} }
} }
}; };
static onBlur = ({editor}) => { static onBlur = ({ editor }) => {
EmojiComposerExtension.selState = editor.currentSelection().exportSelection(); EmojiComposerExtension.selState = editor.currentSelection().exportSelection();
}; };
static onFocus = ({editor}) => { static onFocus = ({ editor }) => {
if (EmojiComposerExtension.selState) { if (EmojiComposerExtension.selState) {
editor.select(EmojiComposerExtension.selState); editor.select(EmojiComposerExtension.selState);
EmojiComposerExtension.selState = null; EmojiComposerExtension.selState = null;
} }
}; };
static toolbarComponentConfig = ({toolbarState}) => { static toolbarComponentConfig = ({ toolbarState }) => {
const sel = toolbarState.selectionSnapshot; const sel = toolbarState.selectionSnapshot;
if (sel) { if (sel) {
const {emojiOptions} = EmojiComposerExtension._findEmojiOptions(sel); const { emojiOptions } = EmojiComposerExtension._findEmojiOptions(sel);
if (emojiOptions.length > 0 && !toolbarState.dragging && !toolbarState.doubleDown) { if (emojiOptions.length > 0 && !toolbarState.dragging && !toolbarState.doubleDown) {
const locationRefNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete"); const locationRefNode = DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete');
if (!locationRefNode) { if (!locationRefNode) {
return null; return null;
} }
const selectedEmoji = locationRefNode.getAttribute("selectedEmoji"); const selectedEmoji = locationRefNode.getAttribute('selectedEmoji');
return { return {
component: EmojiPicker, component: EmojiPicker,
props: {emojiOptions, props: {
selectedEmoji}, emojiOptions,
selectedEmoji,
},
locationRefNode: locationRefNode, locationRefNode: locationRefNode,
width: EmojiComposerExtension._emojiPickerWidth(emojiOptions), width: EmojiComposerExtension._emojiPickerWidth(emojiOptions),
height: EmojiComposerExtension._emojiPickerHeight(emojiOptions), height: EmojiComposerExtension._emojiPickerHeight(emojiOptions),
hidePointer: true, hidePointer: true,
} };
} }
} }
return null; return null;
}; };
static editingActions = () => { static editingActions = () => {
return [{ return [
action: EmojiActions.selectEmoji, {
callback: EmojiComposerExtension._onSelectEmoji, action: EmojiActions.selectEmoji,
}] callback: EmojiComposerExtension._onSelectEmoji,
},
];
}; };
static onKeyDown = ({editor, event}) => { static onKeyDown = ({ editor, event }) => {
const sel = editor.currentSelection() const sel = editor.currentSelection();
const {emojiOptions} = EmojiComposerExtension._findEmojiOptions(sel); const { emojiOptions } = EmojiComposerExtension._findEmojiOptions(sel);
if (emojiOptions.length > 0) { if (emojiOptions.length > 0) {
if (event.key === "ArrowDown" || event.key === "ArrowRight" || if (
event.key === "ArrowUp" || event.key === "ArrowLeft") { event.key === 'ArrowDown' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowLeft'
) {
event.preventDefault(); event.preventDefault();
const moveToNext = (event.key === "ArrowDown" || event.key === "ArrowRight"); const moveToNext = event.key === 'ArrowDown' || event.key === 'ArrowRight';
const emojiNameNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete"); const emojiNameNode = DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete');
if (!emojiNameNode) return null; if (!emojiNameNode) return null;
const selectedEmoji = emojiNameNode.getAttribute("selectedEmoji"); const selectedEmoji = emojiNameNode.getAttribute('selectedEmoji');
if (selectedEmoji) { if (selectedEmoji) {
const emojiIndex = emojiOptions.indexOf(selectedEmoji); const emojiIndex = emojiOptions.indexOf(selectedEmoji);
if (emojiIndex < emojiOptions.length - 1 && moveToNext) { if (emojiIndex < emojiOptions.length - 1 && moveToNext) {
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[emojiIndex + 1]); emojiNameNode.setAttribute('selectedEmoji', emojiOptions[emojiIndex + 1]);
} else if (emojiIndex > 0 && !moveToNext) { } else if (emojiIndex > 0 && !moveToNext) {
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[emojiIndex - 1]); emojiNameNode.setAttribute('selectedEmoji', emojiOptions[emojiIndex - 1]);
} else { } else {
const index = moveToNext ? 0 : emojiOptions.length - 1; const index = moveToNext ? 0 : emojiOptions.length - 1;
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[index]); emojiNameNode.setAttribute('selectedEmoji', emojiOptions[index]);
} }
} else { } else {
const index = moveToNext ? 1 : emojiOptions.length - 1; const index = moveToNext ? 1 : emojiOptions.length - 1;
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[index]); emojiNameNode.setAttribute('selectedEmoji', emojiOptions[index]);
} }
} else if (event.key === "Enter" || event.key === "Tab") { } else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault(); event.preventDefault();
const emojiNameNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete"); const emojiNameNode = DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete');
if (!emojiNameNode) return null; if (!emojiNameNode) return null;
let selectedEmoji = emojiNameNode.getAttribute("selectedEmoji"); let selectedEmoji = emojiNameNode.getAttribute('selectedEmoji');
if (!selectedEmoji) selectedEmoji = emojiOptions[0]; if (!selectedEmoji) selectedEmoji = emojiOptions[0];
const args = { const args = {
editor: editor, editor: editor,
@ -125,8 +128,8 @@ class EmojiComposerExtension extends ComposerExtension {
return null; return null;
}; };
static applyTransformsForSending = ({draftBodyRootNode}) => { static applyTransformsForSending = ({ draftBodyRootNode }) => {
const imgs = draftBodyRootNode.querySelectorAll('img') const imgs = draftBodyRootNode.querySelectorAll('img');
for (const imgEl of Array.from(imgs)) { for (const imgEl of Array.from(imgs)) {
const names = imgEl.className.split(' '); const names = imgEl.className.split(' ');
if (names[0] === 'emoji') { if (names[0] === 'emoji') {
@ -136,9 +139,9 @@ class EmojiComposerExtension extends ComposerExtension {
} }
} }
} }
} };
static unapplyTransformsForSending = ({draftBodyRootNode}) => { static unapplyTransformsForSending = ({ draftBodyRootNode }) => {
const treeWalker = document.createTreeWalker(draftBodyRootNode, NodeFilter.SHOW_TEXT); const treeWalker = document.createTreeWalker(draftBodyRootNode, NodeFilter.SHOW_TEXT);
while (treeWalker.nextNode()) { while (treeWalker.nextNode()) {
const textNode = treeWalker.currentNode; const textNode = treeWalker.currentNode;
@ -148,7 +151,7 @@ class EmojiComposerExtension extends ComposerExtension {
emojiPlusTrailingEl.splitText(match.length); emojiPlusTrailingEl.splitText(match.length);
const emojiEl = emojiPlusTrailingEl; const emojiEl = emojiPlusTrailingEl;
const imgEl = document.createElement('img'); const imgEl = document.createElement('img');
const emojiName = emoji.which(match[0]) const emojiName = emoji.which(match[0]);
imgEl.className = `emoji ${emojiName}`; imgEl.className = `emoji ${emojiName}`;
imgEl.src = EmojiStore.getImagePath(emojiName); imgEl.src = EmojiStore.getImagePath(emojiName);
imgEl.width = '14'; imgEl.width = '14';
@ -157,61 +160,73 @@ class EmojiComposerExtension extends ComposerExtension {
emojiEl.parentNode.replaceChild(imgEl, emojiEl); emojiEl.parentNode.replaceChild(imgEl, emojiEl);
} }
} }
} };
static _findEmojiOptions(sel) { static _findEmojiOptions(sel) {
if (sel.anchorNode && if (
sel.anchorNode.nodeValue && sel.anchorNode &&
sel.anchorNode.nodeValue.length > 0 && sel.anchorNode.nodeValue &&
sel.isCollapsed) { sel.anchorNode.nodeValue.length > 0 &&
sel.isCollapsed
) {
const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset); const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset);
let index = words.lastIndexOf(":"); let index = words.lastIndexOf(':');
let lastWord = ""; let lastWord = '';
if (index !== -1 && words.lastIndexOf(" ") < index) { if (index !== -1 && words.lastIndexOf(' ') < index) {
lastWord = words.substring(index + 1, sel.anchorOffset); lastWord = words.substring(index + 1, sel.anchorOffset);
} else { } else {
const {text} = EmojiComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset); const { text } = EmojiComposerExtension._getTextUntilSpace(
index = text.lastIndexOf(":"); sel.anchorNode,
if (index !== -1 && text.lastIndexOf(" ") < index) { sel.anchorOffset
);
index = text.lastIndexOf(':');
if (index !== -1 && text.lastIndexOf(' ') < index) {
lastWord = text.substring(index + 1); lastWord = text.substring(index + 1);
} else { } else {
return {triggerWord: "", emojiOptions: []}; return { triggerWord: '', emojiOptions: [] };
} }
} }
if (lastWord.length > 0) { if (lastWord.length > 0) {
return {triggerWord: lastWord, emojiOptions: EmojiComposerExtension._findMatches(lastWord)}; return {
triggerWord: lastWord,
emojiOptions: EmojiComposerExtension._findMatches(lastWord),
};
} }
return {triggerWord: lastWord, emojiOptions: []}; return { triggerWord: lastWord, emojiOptions: [] };
} }
return {triggerWord: "", emojiOptions: []}; return { triggerWord: '', emojiOptions: [] };
} }
static _onSelectEmoji = ({editor, actionArg}) => { static _onSelectEmoji = ({ editor, actionArg }) => {
const {emojiName, replaceSelection} = actionArg; const { emojiName, replaceSelection } = actionArg;
if (!emojiName) return null; if (!emojiName) return null;
if (replaceSelection) { if (replaceSelection) {
const sel = editor.currentSelection(); const sel = editor.currentSelection();
if (sel.anchorNode && if (
sel.anchorNode.nodeValue && sel.anchorNode &&
sel.anchorNode.nodeValue.length > 0 && sel.anchorNode.nodeValue &&
sel.isCollapsed) { sel.anchorNode.nodeValue.length > 0 &&
sel.isCollapsed
) {
const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset); const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset);
let index = words.lastIndexOf(":"); let index = words.lastIndexOf(':');
let lastWord = words.substring(index + 1, sel.anchorOffset); let lastWord = words.substring(index + 1, sel.anchorOffset);
if (index !== -1 && words.lastIndexOf(" ") < index) { if (index !== -1 && words.lastIndexOf(' ') < index) {
editor.select(sel.anchorNode, editor.select(
sel.anchorOffset - lastWord.length - 1, sel.anchorNode,
sel.focusNode, sel.anchorOffset - lastWord.length - 1,
sel.focusOffset); sel.focusNode,
sel.focusOffset
);
} else { } else {
const {text, textNode} = EmojiComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset); const { text, textNode } = EmojiComposerExtension._getTextUntilSpace(
index = text.lastIndexOf(":"); sel.anchorNode,
sel.anchorOffset
);
index = text.lastIndexOf(':');
lastWord = text.substring(index + 1); lastWord = text.substring(index + 1);
const offset = textNode.nodeValue.lastIndexOf(":"); const offset = textNode.nodeValue.lastIndexOf(':');
editor.select(textNode, editor.select(textNode, offset, sel.focusNode, sel.focusOffset);
offset,
sel.focusNode,
sel.focusOffset);
editor.delete(); editor.delete();
} }
} }
@ -223,8 +238,8 @@ class EmojiComposerExtension extends ComposerExtension {
width="14" width="14"
height="14" height="14"
style="margin-top: -5px;">`; style="margin-top: -5px;">`;
editor.insertHTML(html, {selectInsertion: false}); editor.insertHTML(html, { selectInsertion: false });
EmojiActions.useEmoji({emojiName: emojiName, emojiChar: emojiChar}); EmojiActions.useEmoji({ emojiName: emojiName, emojiChar: emojiChar });
return null; return null;
}; };
@ -251,25 +266,26 @@ class EmojiComposerExtension extends ComposerExtension {
static _getTextUntilSpace(node, offset) { static _getTextUntilSpace(node, offset) {
let text = node.nodeValue.substring(0, offset); let text = node.nodeValue.substring(0, offset);
let prevTextNode = DOMUtils.previousTextNode(node); let prevTextNode = DOMUtils.previousTextNode(node);
if (!prevTextNode) return {text: text, textNode: node}; if (!prevTextNode) return { text: text, textNode: node };
while (prevTextNode) { while (prevTextNode) {
if (prevTextNode.nodeValue.indexOf(" ") === -1 && if (
prevTextNode.nodeValue.indexOf(":") === -1) { prevTextNode.nodeValue.indexOf(' ') === -1 &&
prevTextNode.nodeValue.indexOf(':') === -1
) {
text = prevTextNode.nodeValue + text; text = prevTextNode.nodeValue + text;
prevTextNode = DOMUtils.previousTextNode(prevTextNode); prevTextNode = DOMUtils.previousTextNode(prevTextNode);
} else if (prevTextNode.nextSibling && } else if (prevTextNode.nextSibling && prevTextNode.nextSibling.nodeName !== 'DIV') {
prevTextNode.nextSibling.nodeName !== "DIV") {
text = prevTextNode.nodeValue.trim() + text; text = prevTextNode.nodeValue.trim() + text;
break; break;
} else { } else {
break; break;
} }
} }
return {text: text, textNode: prevTextNode}; return { text: text, textNode: prevTextNode };
} }
static _findMatches(word) { static _findMatches(word) {
const emojiOptions = [] const emojiOptions = [];
const emojiNames = Object.keys(emoji.emoji).sort(); const emojiNames = Object.keys(emoji.emoji).sort();
for (const emojiName of emojiNames) { for (const emojiName of emojiNames) {
if (word === emojiName.substring(0, word.length)) { if (word === emojiName.substring(0, word.length)) {
@ -278,7 +294,6 @@ class EmojiComposerExtension extends ComposerExtension {
} }
return emojiOptions; return emojiOptions;
} }
} }
export default EmojiComposerExtension; export default EmojiComposerExtension;

View file

@ -1,5 +1,5 @@
/* eslint no-cond-assign:0 */ /* eslint no-cond-assign:0 */
import {MessageViewExtension, RegExpUtils} from 'nylas-exports'; import { MessageViewExtension, RegExpUtils } from 'nylas-exports';
import emoji from 'node-emoji'; import emoji from 'node-emoji';
import EmojiStore from './emoji-store'; import EmojiStore from './emoji-store';
@ -15,7 +15,7 @@ function makeIntoEmojiTag(nodeArg, emojiName) {
} }
class EmojiMessageExtension extends MessageViewExtension { class EmojiMessageExtension extends MessageViewExtension {
static renderedMessageBodyIntoDocument({document}) { static renderedMessageBodyIntoDocument({ document }) {
const emojiRegex = RegExpUtils.emojiRegex(); const emojiRegex = RegExpUtils.emojiRegex();
// Look for emoji in the content of text nodes // Look for emoji in the content of text nodes
@ -27,10 +27,10 @@ class EmojiMessageExtension extends MessageViewExtension {
const node = treeWalker.currentNode; const node = treeWalker.currentNode;
let match = null; let match = null;
while (match = emojiRegex.exec(node.textContent)) { while ((match = emojiRegex.exec(node.textContent))) {
const matchEmojiName = emoji.which(match[0]); const matchEmojiName = emoji.which(match[0]);
if (matchEmojiName) { if (matchEmojiName) {
const matchNode = (match.index === 0) ? node : node.splitText(match.index); const matchNode = match.index === 0 ? node : node.splitText(match.index);
matchNode.splitText(match[0].length); matchNode.splitText(match[0].length);
const imageNode = document.createElement('img'); const imageNode = document.createElement('img');
makeIntoEmojiTag(imageNode, matchEmojiName); makeIntoEmojiTag(imageNode, matchEmojiName);

View file

@ -1,15 +1,14 @@
import {React, ReactDOM} from 'nylas-exports'; import { React, ReactDOM, PropTypes } from 'nylas-exports';
import emoji from 'node-emoji'; import emoji from 'node-emoji';
import EmojiStore from './emoji-store'; import EmojiStore from './emoji-store';
import EmojiActions from './emoji-actions'; import EmojiActions from './emoji-actions';
class EmojiPicker extends React.Component { class EmojiPicker extends React.Component {
static displayName = "EmojiPicker"; static displayName = 'EmojiPicker';
static propTypes = { static propTypes = {
emojiOptions: React.PropTypes.array, emojiOptions: PropTypes.array,
selectedEmoji: React.PropTypes.string, selectedEmoji: PropTypes.string,
}; };
constructor(props) { constructor(props) {
@ -18,14 +17,14 @@ class EmojiPicker extends React.Component {
} }
componentDidUpdate() { componentDidUpdate() {
const selectedButton = ReactDOM.findDOMNode(this).querySelector(".emoji-option"); const selectedButton = ReactDOM.findDOMNode(this).querySelector('.emoji-option');
if (selectedButton) { if (selectedButton) {
selectedButton.scrollIntoViewIfNeeded(); selectedButton.scrollIntoViewIfNeeded();
} }
} }
onMouseDown(emojiName) { onMouseDown(emojiName) {
EmojiActions.selectEmoji({emojiName, replaceSelection: true}); EmojiActions.selectEmoji({ emojiName, replaceSelection: true });
} }
render() { render() {
@ -34,7 +33,7 @@ class EmojiPicker extends React.Component {
if (emojiIndex === -1) emojiIndex = 0; if (emojiIndex === -1) emojiIndex = 0;
if (this.props.emojiOptions) { if (this.props.emojiOptions) {
this.props.emojiOptions.forEach((emojiOption, i) => { this.props.emojiOptions.forEach((emojiOption, i) => {
const emojiClass = emojiIndex === i ? "btn btn-icon emoji-option" : "btn btn-icon"; const emojiClass = emojiIndex === i ? 'btn btn-icon emoji-option' : 'btn btn-icon';
let emojiChar = emoji.get(emojiOption); let emojiChar = emoji.get(emojiOption);
emojiChar = ( emojiChar = (
<img <img
@ -42,7 +41,7 @@ class EmojiPicker extends React.Component {
src={EmojiStore.getImagePath(emojiOption)} src={EmojiStore.getImagePath(emojiOption)}
width="16" width="16"
height="16" height="16"
style={{marginTop: "-4px", marginRight: "3px"}} style={{ marginTop: '-4px', marginRight: '3px' }}
/> />
); );
emojiButtons.push( emojiButtons.push(
@ -57,11 +56,7 @@ class EmojiPicker extends React.Component {
emojiButtons.push(<br key={`${emojiOption} br`} />); emojiButtons.push(<br key={`${emojiOption} br`} />);
}); });
} }
return ( return <div className="emoji-picker">{emojiButtons}</div>;
<div className="emoji-picker">
{emojiButtons}
</div>
);
} }
} }

View file

@ -31,7 +31,7 @@ class EmojiStore extends NylasStore {
const sortedEmoji = this._emoji; const sortedEmoji = this._emoji;
sortedEmoji.sort((a, b) => { sortedEmoji.sort((a, b) => {
if (a.frequency < b.frequency) return 1; if (a.frequency < b.frequency) return 1;
return (b.frequency < a.frequency) ? -1 : 0; return b.frequency < a.frequency ? -1 : 0;
}); });
const sortedEmojiNames = []; const sortedEmojiNames = [];
for (const emoji of sortedEmoji) { for (const emoji of sortedEmoji) {
@ -41,23 +41,23 @@ class EmojiStore extends NylasStore {
return sortedEmojiNames.slice(0, 32); return sortedEmojiNames.slice(0, 32);
} }
return sortedEmojiNames; return sortedEmojiNames;
} };
getImagePath(emojiName) { getImagePath(emojiName) {
emojiData = emojiData || require('./emoji-data').emojiData emojiData = emojiData || require('./emoji-data').emojiData;
for (const emoji of emojiData) { for (const emoji of emojiData) {
if (emoji.short_names.indexOf(emojiName) !== -1) { if (emoji.short_names.indexOf(emojiName) !== -1) {
if (process.platform === "darwin") { if (process.platform === 'darwin') {
return `images/composer-emoji/apple/${emoji.image}`; return `images/composer-emoji/apple/${emoji.image}`;
} }
return `images/composer-emoji/twitter/${emoji.image}`; return `images/composer-emoji/twitter/${emoji.image}`;
} }
} }
return '' return '';
} }
_onUseEmoji = (emoji) => { _onUseEmoji = emoji => {
const savedEmoji = _.find(this._emoji, (curEmoji) => { const savedEmoji = _.find(this._emoji, curEmoji => {
return curEmoji.emojiChar === emoji.emojiChar; return curEmoji.emojiChar === emoji.emojiChar;
}); });
if (savedEmoji) { if (savedEmoji) {
@ -66,17 +66,16 @@ class EmojiStore extends NylasStore {
} }
savedEmoji.frequency++; savedEmoji.frequency++;
} else { } else {
Object.assign(emoji, {frequency: 1}); Object.assign(emoji, { frequency: 1 });
this._emoji.push(emoji); this._emoji.push(emoji);
} }
this._saveEmoji(); this._saveEmoji();
this.trigger(); this.trigger();
} };
_saveEmoji = () => { _saveEmoji = () => {
window.localStorage.setItem(EmojiJSONKey, JSON.stringify(this._emoji)); window.localStorage.setItem(EmojiJSONKey, JSON.stringify(this._emoji));
} };
} }
export default new EmojiStore(); export default new EmojiStore();

View file

@ -1,4 +1,4 @@
import {ExtensionRegistry, ComponentRegistry} from 'nylas-exports'; import { ExtensionRegistry, ComponentRegistry } from 'nylas-exports';
import EmojiStore from './emoji-store'; import EmojiStore from './emoji-store';
import EmojiComposerExtension from './emoji-composer-extension'; import EmojiComposerExtension from './emoji-composer-extension';
import EmojiMessageExtension from './emoji-message-extension'; import EmojiMessageExtension from './emoji-message-extension';
@ -7,7 +7,7 @@ import EmojiButton from './emoji-button';
export function activate() { export function activate() {
ExtensionRegistry.Composer.register(EmojiComposerExtension); ExtensionRegistry.Composer.register(EmojiComposerExtension);
ExtensionRegistry.MessageView.register(EmojiMessageExtension); ExtensionRegistry.MessageView.register(EmojiMessageExtension);
ComponentRegistry.register(EmojiButton, {role: 'Composer:ActionButton'}); ComponentRegistry.register(EmojiButton, { role: 'Composer:ActionButton' });
EmojiStore.activate(); EmojiStore.activate();
} }

View file

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import ReactTestUtils from 'react-dom/test-utils'; import ReactTestUtils from 'react-dom/test-utils';
import {findDOMNode} from 'react-dom'; import { findDOMNode } from 'react-dom';
import {renderIntoDocument} from '../../../spec/nylas-test-utils'; import { renderIntoDocument } from '../../../spec/nylas-test-utils';
import Contenteditable from '../../../src/components/contenteditable/contenteditable'; import Contenteditable from '../../../src/components/contenteditable/contenteditable';
import EmojiButtonPopover from '../lib/emoji-button-popover'; import EmojiButtonPopover from '../lib/emoji-button-popover';
import EmojiComposerExtension from '../lib/emoji-composer-extension'; import EmojiComposerExtension from '../lib/emoji-composer-extension';
@ -12,12 +12,14 @@ describe('EmojiButtonPopover', function emojiButtonPopover() {
this.position = { this.position = {
x: 20, x: 20,
y: 40, y: 40,
} };
spyOn(EmojiButtonPopover.prototype, 'calcPosition').andReturn(this.position); spyOn(EmojiButtonPopover.prototype, 'calcPosition').andReturn(this.position);
spyOn(EmojiComposerExtension, '_onSelectEmoji').andCallThrough(); spyOn(EmojiComposerExtension, '_onSelectEmoji').andCallThrough();
this.component = renderIntoDocument(<EmojiButtonPopover />); this.component = renderIntoDocument(<EmojiButtonPopover />);
this.canvas = findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(this.component, 'canvas')); this.canvas = findDOMNode(
ReactTestUtils.findRenderedDOMComponentWithTag(this.component, 'canvas')
);
this.composer = renderIntoDocument( this.composer = renderIntoDocument(
<Contenteditable <Contenteditable
@ -37,12 +39,14 @@ describe('EmojiButtonPopover', function emojiButtonPopover() {
describe('when searching for emoji', () => { describe('when searching for emoji', () => {
it('should filter for matches', () => { it('should filter for matches', () => {
this.searchNode = findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'search')) this.searchNode = findDOMNode(
ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'search')
);
const event = { const event = {
target: { target: {
value: "heart", value: 'heart',
}, },
} };
ReactTestUtils.Simulate.change(this.searchNode, event); ReactTestUtils.Simulate.change(this.searchNode, event);
ReactTestUtils.Simulate.mouseDown(this.canvas); ReactTestUtils.Simulate.mouseDown(this.canvas);
expect(EmojiComposerExtension._onSelectEmoji).toHaveBeenCalled(); expect(EmojiComposerExtension._onSelectEmoji).toHaveBeenCalled();

View file

@ -2,99 +2,132 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import ReactTestUtils from 'react-dom/test-utils'; import ReactTestUtils from 'react-dom/test-utils';
import {renderIntoDocument} from '../../../spec/nylas-test-utils'; import { renderIntoDocument } from '../../../spec/nylas-test-utils';
import Contenteditable from '../../../src/components/contenteditable/contenteditable'; import Contenteditable from '../../../src/components/contenteditable/contenteditable';
import EmojiComposerExtension from '../lib/emoji-composer-extension'; import EmojiComposerExtension from '../lib/emoji-composer-extension';
xdescribe('EmojiComposerExtension', function emojiComposerExtension() { xdescribe('EmojiComposerExtension', function emojiComposerExtension() {
beforeEach(() => { beforeEach(() => {
spyOn(EmojiComposerExtension, 'onContentChanged').andCallThrough() spyOn(EmojiComposerExtension, 'onContentChanged').andCallThrough();
spyOn(EmojiComposerExtension, '_onSelectEmoji').andCallThrough() spyOn(EmojiComposerExtension, '_onSelectEmoji').andCallThrough();
this.component = renderIntoDocument( this.component = renderIntoDocument(
<Contenteditable <Contenteditable
html={''} html={''}
onChange={jasmine.createSpy('onChange')} onChange={jasmine.createSpy('onChange')}
extensions={[EmojiComposerExtension]} extensions={[EmojiComposerExtension]}
/> />
) );
this.editableNode = ReactDOM.findDOMNode(this.component).querySelector('[contenteditable]'); this.editableNode = ReactDOM.findDOMNode(this.component).querySelector('[contenteditable]');
}) });
describe('when emoji trigger is typed', () => { describe('when emoji trigger is typed', () => {
beforeEach(() => { beforeEach(() => {
this._performEdit = (newHTML) => { this._performEdit = newHTML => {
this.editableNode.innerHTML = newHTML.substr(0, newHTML.length - 1); this.editableNode.innerHTML = newHTML.substr(0, newHTML.length - 1);
const sel = document.getSelection(); const sel = document.getSelection();
const textNode = this.editableNode.childNodes[0]; const textNode = this.editableNode.childNodes[0];
sel.setBaseAndExtent(textNode, textNode.length, textNode, textNode.length); sel.setBaseAndExtent(textNode, textNode.length, textNode, textNode.length);
} };
}) });
it('should show the emoji picker', () => { it('should show the emoji picker', () => {
this._performEdit('Testing! :h'); this._performEdit('Testing! :h');
waitsFor(() => { waitsFor(() => {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length > 0 return (
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length >
0
);
}); });
}) });
it('should be focused on the first emoji in the list', () => { it('should be focused on the first emoji in the list', () => {
this._performEdit('Testing! :h'); this._performEdit('Testing! :h');
waitsFor(() => { waitsFor(() => {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length > 0 return (
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length >
0
);
}); });
runs(() => { runs(() => {
expect(ReactDOM.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')).textContent.indexOf(":haircut:") !== -1).toBe(true); expect(
ReactDOM.findDOMNode(
ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')
).textContent.indexOf(':haircut:') !== -1
).toBe(true);
}); });
}) });
it('should insert an emoji on enter', () => { it('should insert an emoji on enter', () => {
this._performEdit('Testing! :h'); this._performEdit('Testing! :h');
waitsFor(() => { waitsFor(() => {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length > 0 return (
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length >
0
);
}); });
runs(() => { runs(() => {
ReactTestUtils.Simulate.keyDown(this.editableNode, {key: "Enter", keyCode: 13, which: 13}); ReactTestUtils.Simulate.keyDown(this.editableNode, {
key: 'Enter',
keyCode: 13,
which: 13,
});
}); });
waitsFor(() => { waitsFor(() => {
return EmojiComposerExtension._onSelectEmoji.calls.length > 0 return EmojiComposerExtension._onSelectEmoji.calls.length > 0;
})
runs(() => {
expect(this.editableNode.innerHTML).toContain("emoji haircut")
}); });
}) runs(() => {
expect(this.editableNode.innerHTML).toContain('emoji haircut');
});
});
it('should insert an emoji on click', () => { it('should insert an emoji on click', () => {
this._performEdit('Testing! :h'); this._performEdit('Testing! :h');
waitsFor(() => { waitsFor(() => {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length > 0 return (
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length >
0
);
}); });
runs(() => { runs(() => {
const button = ReactDOM.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')) const button = ReactDOM.findDOMNode(
ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')
);
ReactTestUtils.Simulate.mouseDown(button); ReactTestUtils.Simulate.mouseDown(button);
expect(EmojiComposerExtension._onSelectEmoji).toHaveBeenCalled() expect(EmojiComposerExtension._onSelectEmoji).toHaveBeenCalled();
}); });
waitsFor(() => { waitsFor(() => {
return EmojiComposerExtension._onSelectEmoji.calls.length > 0 return EmojiComposerExtension._onSelectEmoji.calls.length > 0;
})
runs(() => {
expect(this.editableNode.innerHTML).toContain("emoji haircut")
}); });
}) runs(() => {
expect(this.editableNode.innerHTML).toContain('emoji haircut');
});
});
it('should move to the next emoji on arrow down', () => { it('should move to the next emoji on arrow down', () => {
this._performEdit('Testing! :h'); this._performEdit('Testing! :h');
waitsFor(() => { waitsFor(() => {
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length > 0 return (
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length >
0
);
}); });
runs(() => { runs(() => {
ReactTestUtils.Simulate.keyDown(this.editableNode, {key: "ArrowDown", keyCode: 40, which: 40}); ReactTestUtils.Simulate.keyDown(this.editableNode, {
key: 'ArrowDown',
keyCode: 40,
which: 40,
});
}); });
waitsFor(() => { waitsFor(() => {
return EmojiComposerExtension.onContentChanged.calls.length > 1 return EmojiComposerExtension.onContentChanged.calls.length > 1;
}); });
runs(() => { runs(() => {
expect(ReactDOM.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')).textContent.indexOf(":hamburger:") !== -1).toBe(true); expect(
ReactDOM.findDOMNode(
ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')
).textContent.indexOf(':hamburger:') !== -1
).toBe(true);
}); });
}) });
}) });
}) });

View file

@ -1,6 +1,6 @@
Utils = require './utils' Utils = require './utils'
SimpleMDE = require 'simplemde' SimpleMDE = require 'simplemde'
{React, ReactDOM, QuotedHTMLTransformer} = require 'nylas-exports' {React, ReactDOM, PropTypes, QuotedHTMLTransformer} = require 'nylas-exports'
# Keep a file-scope variable containing the contents of the markdown stylesheet. # Keep a file-scope variable containing the contents of the markdown stylesheet.
# This will be embedded in the markdown preview iFrame, as well as the email body. # This will be embedded in the markdown preview iFrame, as well as the email body.
@ -19,11 +19,11 @@ class MarkdownEditor extends React.Component
@containerRequired: false @containerRequired: false
@contextTypes: @contextTypes:
parentTabGroup: React.PropTypes.object, parentTabGroup: PropTypes.object,
@propTypes: @propTypes:
body: React.PropTypes.string.isRequired, body: PropTypes.string.isRequired,
onBodyChanged: React.PropTypes.func.isRequired, onBodyChanged: PropTypes.func.isRequired,
componentDidMount: => componentDidMount: =>
@mde = new SimpleMDE( @mde = new SimpleMDE(

View file

@ -1,12 +1,12 @@
import {PreferencesUIStore, ExtensionRegistry, ComponentRegistry} from 'nylas-exports'; import { PreferencesUIStore, ExtensionRegistry, ComponentRegistry } from 'nylas-exports';
import SignatureComposerExtension from './signature-composer-extension'; import SignatureComposerExtension from './signature-composer-extension';
import SignatureComposerDropdown from './signature-composer-dropdown'; import SignatureComposerDropdown from './signature-composer-dropdown';
export function activate() { export function activate() {
this.preferencesTab = new PreferencesUIStore.TabItem({ this.preferencesTab = new PreferencesUIStore.TabItem({
tabId: "Signatures", tabId: 'Signatures',
displayName: "Signatures", displayName: 'Signatures',
componentClassFn: () => require('./preferences-signatures').default, // eslint-disable-line componentClassFn: () => require('./preferences-signatures').default, // eslint-disable-line
}); });

View file

@ -1,104 +1,97 @@
import React from 'react'; import React from 'react';
import { import {
Flexbox, Flexbox,
RetinaImg, RetinaImg,
EditableList, EditableList,
Contenteditable, Contenteditable,
ScrollRegion, ScrollRegion,
MultiselectDropdown, MultiselectDropdown,
} from 'nylas-component-kit'; } from 'nylas-component-kit';
import {AccountStore, SignatureStore, Actions} from 'nylas-exports'; import { AccountStore, SignatureStore, Actions } from 'nylas-exports';
export default class PreferencesSignatures extends React.Component { export default class PreferencesSignatures extends React.Component {
static displayName = 'PreferencesSignatures'; static displayName = 'PreferencesSignatures';
constructor() { constructor() {
super() super();
this.state = this._getStateFromStores() this.state = this._getStateFromStores();
} }
componentDidMount() { componentDidMount() {
this.unsubscribers = [ this.unsubscribers = [SignatureStore.listen(this._onChange)];
SignatureStore.listen(this._onChange),
]
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubscribers.forEach(unsubscribe => unsubscribe()); this.unsubscribers.forEach(unsubscribe => unsubscribe());
} }
_onChange = () => { _onChange = () => {
this.setState(this._getStateFromStores()) this.setState(this._getStateFromStores());
} };
_getStateFromStores() { _getStateFromStores() {
const signatures = SignatureStore.getSignatures() const signatures = SignatureStore.getSignatures();
const accountsAndAliases = AccountStore.aliases() const accountsAndAliases = AccountStore.aliases();
const selected = SignatureStore.selectedSignature() const selected = SignatureStore.selectedSignature();
const defaults = SignatureStore.getDefaults() const defaults = SignatureStore.getDefaults();
return { return {
signatures: signatures, signatures: signatures,
selectedSignature: selected, selectedSignature: selected,
defaults: defaults, defaults: defaults,
accountsAndAliases: accountsAndAliases, accountsAndAliases: accountsAndAliases,
editAsHTML: this.state ? this.state.editAsHTML : false, editAsHTML: this.state ? this.state.editAsHTML : false,
} };
} }
_onCreateButtonClick = () => { _onCreateButtonClick = () => {
this._onAddSignature() this._onAddSignature();
} };
_onAddSignature = () => { _onAddSignature = () => {
Actions.addSignature() Actions.addSignature();
} };
_onDeleteSignature = (signature) => { _onDeleteSignature = signature => {
Actions.removeSignature(signature) Actions.removeSignature(signature);
} };
_onEditSignature = (edit) => { _onEditSignature = edit => {
let editedSig; let editedSig;
if (typeof edit === "object") { if (typeof edit === 'object') {
editedSig = { editedSig = {
title: this.state.selectedSignature.title, title: this.state.selectedSignature.title,
body: edit.target.value, body: edit.target.value,
} };
} else { } else {
editedSig = { editedSig = {
title: edit, title: edit,
body: this.state.selectedSignature.body, body: this.state.selectedSignature.body,
} };
} }
Actions.updateSignature(editedSig, this.state.selectedSignature.id) Actions.updateSignature(editedSig, this.state.selectedSignature.id);
} };
_onSelectSignature = (sig) => { _onSelectSignature = sig => {
Actions.selectSignature(sig.id) Actions.selectSignature(sig.id);
} };
_onToggleAccount = (account) => { _onToggleAccount = account => {
Actions.toggleAccount(account.email) Actions.toggleAccount(account.email);
} };
_onToggleEditAsHTML = () => { _onToggleEditAsHTML = () => {
const toggled = !this.state.editAsHTML const toggled = !this.state.editAsHTML;
this.setState({editAsHTML: toggled}) this.setState({ editAsHTML: toggled });
} };
_renderListItemContent = (sig) => { _renderListItemContent = sig => {
return sig.title return sig.title;
} };
_renderSignatureToolbar() { _renderSignatureToolbar() {
return ( return (
<div className="editable-toolbar"> <div className="editable-toolbar">
<div className="account-picker"> <div className="account-picker">Default for: {this._renderAccountPicker()}</div>
Default for: {this._renderAccountPicker()}
</div>
<div className="render-mode"> <div className="render-mode">
<input <input
type="checkbox" type="checkbox"
@ -109,30 +102,30 @@ export default class PreferencesSignatures extends React.Component {
<label htmlFor="render-mode">Edit raw HTML</label> <label htmlFor="render-mode">Edit raw HTML</label>
</div> </div>
</div> </div>
) );
} }
_selectItemKey = (accountOrAlias) => { _selectItemKey = accountOrAlias => {
return accountOrAlias.id return accountOrAlias.id;
} };
_isChecked = (accountOrAlias) => { _isChecked = accountOrAlias => {
if (!this.state.selectedSignature) { if (!this.state.selectedSignature) {
return false; return false;
} }
return (this.state.defaults[accountOrAlias.email] === this.state.selectedSignature.id); return this.state.defaults[accountOrAlias.email] === this.state.selectedSignature.id;
} };
_labelForAccountPicker() { _labelForAccountPicker() {
const sel = this.state.accountsAndAliases.filter((accountOrAlias) => { const sel = this.state.accountsAndAliases.filter(accountOrAlias => {
return this._isChecked(accountOrAlias) return this._isChecked(accountOrAlias);
}) });
const numSelected = sel.length; const numSelected = sel.length;
return numSelected.toString() + (numSelected === 1 ? " Account" : " Accounts") return numSelected.toString() + (numSelected === 1 ? ' Account' : ' Accounts');
} }
_renderAccountPicker() { _renderAccountPicker() {
const buttonText = this._labelForAccountPicker() const buttonText = this._labelForAccountPicker();
return ( return (
<MultiselectDropdown <MultiselectDropdown
@ -143,33 +136,24 @@ export default class PreferencesSignatures extends React.Component {
itemKey={this._selectItemKey} itemKey={this._selectItemKey}
current={this.selectedSignature} current={this.selectedSignature}
buttonText={buttonText} buttonText={buttonText}
itemContent={(accountOrAlias) => accountOrAlias.email} itemContent={accountOrAlias => accountOrAlias.email}
/>
)
}
_renderEditableSignature() {
const selectedBody = this.state.selectedSignature ? this.state.selectedSignature.body : ""
return (
<Contenteditable
value={selectedBody}
spellcheck={false}
onChange={this._onEditSignature}
/>
)
}
_renderHTMLSignature() {
return (
<textarea
value={this.state.selectedSignature.body}
onChange={this._onEditSignature}
/> />
); );
} }
_renderEditableSignature() {
const selectedBody = this.state.selectedSignature ? this.state.selectedSignature.body : '';
return (
<Contenteditable value={selectedBody} spellcheck={false} onChange={this._onEditSignature} />
);
}
_renderHTMLSignature() {
return <textarea value={this.state.selectedSignature.body} onChange={this._onEditSignature} />;
}
_renderSignatures() { _renderSignatures() {
const sigArr = Object.values(this.state.signatures) const sigArr = Object.values(this.state.signatures);
if (sigArr.length === 0) { if (sigArr.length === 0) {
return ( return (
<div className="empty-list"> <div className="empty-list">
@ -208,16 +192,14 @@ export default class PreferencesSignatures extends React.Component {
{this._renderSignatureToolbar()} {this._renderSignatureToolbar()}
</div> </div>
</Flexbox> </Flexbox>
) );
} }
render() { render() {
return ( return (
<div className="preferences-signatures-container"> <div className="preferences-signatures-container">
<section> <section>{this._renderSignatures()}</section>
{this._renderSignatures()}
</section>
</div> </div>
) );
} }
} }

View file

@ -1,106 +1,101 @@
import { import { React, Actions, PropTypes, SignatureStore } from 'nylas-exports';
React, import { Menu, RetinaImg, ButtonDropdown } from 'nylas-component-kit';
Actions,
SignatureStore,
} from 'nylas-exports'
import {
Menu,
RetinaImg,
ButtonDropdown,
} from 'nylas-component-kit'
import _ from 'underscore'
import SignatureUtils from './signature-utils'
import SignatureUtils from './signature-utils';
export default class SignatureComposerDropdown extends React.Component { export default class SignatureComposerDropdown extends React.Component {
static displayName = 'SignatureComposerDropdown' static displayName = 'SignatureComposerDropdown';
static containerRequired = false static containerRequired = false;
static propTypes = { static propTypes = {
draft: React.PropTypes.object.isRequired, draft: PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired, session: PropTypes.object.isRequired,
currentAccount: React.PropTypes.object, currentAccount: PropTypes.object,
accounts: React.PropTypes.array, accounts: PropTypes.array,
} };
constructor() { constructor() {
super() super();
this.state = this._getStateFromStores() this.state = this._getStateFromStores();
} }
componentDidMount = () => { componentDidMount = () => {
this.unsubscribers = [ this.unsubscribers = [SignatureStore.listen(this._onChange)];
SignatureStore.listen(this._onChange), };
]
}
componentDidUpdate(previousProps) { componentDidUpdate(previousProps) {
if (previousProps.currentAccount.id !== this.props.currentAccount.id) { if (previousProps.currentAccount.id !== this.props.currentAccount.id) {
const nextDefaultSignature = SignatureStore.signatureForEmail(this.props.currentAccount.email) const nextDefaultSignature = SignatureStore.signatureForEmail(
this._changeSignature(nextDefaultSignature) this.props.currentAccount.email
);
this._changeSignature(nextDefaultSignature);
} }
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubscribers.forEach(unsubscribe => unsubscribe()) this.unsubscribers.forEach(unsubscribe => unsubscribe());
} }
_onChange = () => { _onChange = () => {
this.setState(this._getStateFromStores()) this.setState(this._getStateFromStores());
} };
_getStateFromStores() { _getStateFromStores() {
const signatures = SignatureStore.getSignatures() const signatures = SignatureStore.getSignatures();
return { return {
signatures: signatures, signatures: signatures,
} };
} }
_renderSigItem = (sigItem) => { _renderSigItem = sigItem => {
return ( return <span className={`signature-title-${sigItem.title}`}>{sigItem.title}</span>;
<span className={`signature-title-${sigItem.title}`}>{sigItem.title}</span> };
)
}
_changeSignature = (sig) => { _changeSignature = sig => {
let body; let body;
if (sig) { if (sig) {
body = SignatureUtils.applySignature(this.props.draft.body, sig.body) body = SignatureUtils.applySignature(this.props.draft.body, sig.body);
} else { } else {
body = SignatureUtils.applySignature(this.props.draft.body, '') body = SignatureUtils.applySignature(this.props.draft.body, '');
} }
this.props.session.changes.add({body}) this.props.session.changes.add({ body });
} };
_isSelected = (sigObj) => { _isSelected = sigObj => {
// http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex // http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
const escapeRegExp = (str) => { const escapeRegExp = str => {
return str.replace(/[-[\]/}{)(*+?.\\^$|]/g, "\\$&"); return str.replace(/[-[\]/}{)(*+?.\\^$|]/g, '\\$&');
} };
const signatureRegex = new RegExp(escapeRegExp(`<signature>${sigObj.body}</signature>`)) const signatureRegex = new RegExp(escapeRegExp(`<signature>${sigObj.body}</signature>`));
const signatureLocation = signatureRegex.exec(this.props.draft.body) const signatureLocation = signatureRegex.exec(this.props.draft.body);
if (signatureLocation) return true if (signatureLocation) return true;
return false return false;
} };
_onClickNoSignature = () => { _onClickNoSignature = () => {
this._changeSignature({body: ''}) this._changeSignature({ body: '' });
} };
_onClickEditSignatures() { _onClickEditSignatures() {
Actions.switchPreferencesTab('Signatures') Actions.switchPreferencesTab('Signatures');
Actions.openPreferences() Actions.openPreferences();
} }
_renderSignatures() { _renderSignatures() {
// note: these are using onMouseDown to avoid clearing focus in the composer (I think) // note: these are using onMouseDown to avoid clearing focus in the composer (I think)
const header = [<div className="item item-none" key="none" onMouseDown={this._onClickNoSignature}><span>No signature</span></div>] const header = [
const footer = [<div className="item item-edit" key="edit" onMouseDown={this._onClickEditSignatures}><span>Edit Signatures...</span></div>] <div className="item item-none" key="none" onMouseDown={this._onClickNoSignature}>
<span>No signature</span>
</div>,
];
const footer = [
<div className="item item-edit" key="edit" onMouseDown={this._onClickEditSignatures}>
<span>Edit Signatures...</span>
</div>,
];
const sigItems = Object.values(this.state.signatures) const sigItems = Object.values(this.state.signatures);
return ( return (
<Menu <Menu
headerComponents={header} headerComponents={header}
@ -111,7 +106,7 @@ export default class SignatureComposerDropdown extends React.Component {
onSelect={this._changeSignature} onSelect={this._changeSignature}
itemChecked={this._isSelected} itemChecked={this._isSelected}
/> />
) );
} }
_renderSignatureIcon() { _renderSignatureIcon() {
@ -121,26 +116,20 @@ export default class SignatureComposerDropdown extends React.Component {
name="top-signature-dropdown.png" name="top-signature-dropdown.png"
mode={RetinaImg.Mode.ContentIsMask} mode={RetinaImg.Mode.ContentIsMask}
/> />
) );
} }
render() { render() {
const sigs = this.state.signatures; const sigs = this.state.signatures;
const icon = this._renderSignatureIcon() const icon = this._renderSignatureIcon();
if (Object.values(sigs).length > 0) { if (Object.values(sigs).length > 0) {
return ( return (
<div className="signature-button-dropdown"> <div className="signature-button-dropdown">
<ButtonDropdown <ButtonDropdown primaryItem={icon} menu={this._renderSignatures()} bordered={false} />
primaryItem={icon}
menu={this._renderSignatures()}
bordered={false}
/>
</div> </div>
) );
} }
return null return null;
} }
} }

View file

@ -1,12 +1,13 @@
import {ComposerExtension, SignatureStore} from 'nylas-exports'; import { ComposerExtension, SignatureStore } from 'nylas-exports';
import SignatureUtils from './signature-utils'; import SignatureUtils from './signature-utils';
export default class SignatureComposerExtension extends ComposerExtension { export default class SignatureComposerExtension extends ComposerExtension {
static prepareNewDraft = ({draft}) => { static prepareNewDraft = ({ draft }) => {
const signatureObj = draft.from && draft.from[0] ? SignatureStore.signatureForEmail(draft.from[0].email) : null; const signatureObj =
draft.from && draft.from[0] ? SignatureStore.signatureForEmail(draft.from[0].email) : null;
if (!signatureObj) { if (!signatureObj) {
return; return;
} }
draft.body = SignatureUtils.applySignature(draft.body, signatureObj.body); draft.body = SignatureUtils.applySignature(draft.body, signatureObj.body);
} };
} }

View file

@ -1,4 +1,4 @@
import {RegExpUtils} from 'nylas-exports' import { RegExpUtils } from 'nylas-exports';
export default { export default {
applySignature(body, signature) { applySignature(body, signature) {
@ -9,8 +9,8 @@ export default {
let paddingBefore = ''; let paddingBefore = '';
// Remove any existing signature in the body // Remove any existing signature in the body
newBody = newBody.replace(signatureRegex, ""); newBody = newBody.replace(signatureRegex, '');
const signatureInPrevious = newBody !== body const signatureInPrevious = newBody !== body;
// http://www.regexpal.com/?fam=94390 // http://www.regexpal.com/?fam=94390
// prefer to put the signature one <br> before the beginning of the quote, // prefer to put the signature one <br> before the beginning of the quote,
@ -18,7 +18,7 @@ export default {
let insertionPoint = newBody.search(RegExpUtils.n1QuoteStartRegex()); let insertionPoint = newBody.search(RegExpUtils.n1QuoteStartRegex());
if (insertionPoint === -1) { if (insertionPoint === -1) {
insertionPoint = newBody.length; insertionPoint = newBody.length;
if (!signatureInPrevious) paddingBefore = '<br><br>' if (!signatureInPrevious) paddingBefore = '<br><br>';
} }
const contentBefore = newBody.slice(0, insertionPoint); const contentBefore = newBody.slice(0, insertionPoint);

View file

@ -1,10 +1,9 @@
/* eslint quote-props: 0 */ /* eslint quote-props: 0 */
import ReactTestUtils from 'react-dom/test-utils'; import ReactTestUtils from 'react-dom/test-utils';
import React from 'react'; import React from 'react';
import {SignatureStore, Actions} from 'nylas-exports'; import { SignatureStore, Actions } from 'nylas-exports';
import PreferencesSignatures from '../lib/preferences-signatures'; import PreferencesSignatures from '../lib/preferences-signatures';
const SIGNATURES = { const SIGNATURES = {
'1': { '1': {
id: '1', id: '1',
@ -16,76 +15,84 @@ const SIGNATURES = {
title: 'two', title: 'two',
body: 'Here is my second sig!', body: 'Here is my second sig!',
}, },
} };
const DEFAULTS = { const DEFAULTS = {
'one@nylas.com': '1', 'one@nylas.com': '1',
'two@nylas.com': '2', 'two@nylas.com': '2',
} };
const makeComponent = (props = {}) => { const makeComponent = (props = {}) => {
return ReactTestUtils.renderIntoDocument(<PreferencesSignatures {...props} />) return ReactTestUtils.renderIntoDocument(<PreferencesSignatures {...props} />);
} };
describe('PreferencesSignatures', function preferencesSignatures() { describe('PreferencesSignatures', function preferencesSignatures() {
this.component = null this.component = null;
describe('when there are no signatures', () => { describe('when there are no signatures', () => {
it('should add a signature when you click the button', () => { it('should add a signature when you click the button', () => {
spyOn(SignatureStore, 'getSignatures').andReturn({}) spyOn(SignatureStore, 'getSignatures').andReturn({});
spyOn(SignatureStore, 'selectedSignature') spyOn(SignatureStore, 'selectedSignature');
spyOn(SignatureStore, 'getDefaults').andReturn({}) spyOn(SignatureStore, 'getDefaults').andReturn({});
this.component = makeComponent() this.component = makeComponent();
spyOn(Actions, 'addSignature') spyOn(Actions, 'addSignature');
this.button = ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'btn-create-signature') this.button = ReactTestUtils.findRenderedDOMComponentWithClass(
ReactTestUtils.Simulate.click(this.button) this.component,
expect(Actions.addSignature).toHaveBeenCalled() 'btn-create-signature'
}) );
}) ReactTestUtils.Simulate.click(this.button);
expect(Actions.addSignature).toHaveBeenCalled();
});
});
describe('when there are signatures', () => { describe('when there are signatures', () => {
beforeEach(() => { beforeEach(() => {
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES) spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES);
spyOn(SignatureStore, 'selectedSignature').andReturn(SIGNATURES['1']) spyOn(SignatureStore, 'selectedSignature').andReturn(SIGNATURES['1']);
spyOn(SignatureStore, 'getDefaults').andReturn(DEFAULTS) spyOn(SignatureStore, 'getDefaults').andReturn(DEFAULTS);
this.component = makeComponent() this.component = makeComponent();
}) });
it('should add a signature when you click the plus button', () => { it('should add a signature when you click the plus button', () => {
spyOn(Actions, 'addSignature') spyOn(Actions, 'addSignature');
this.plus = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'btn-editable-list')[0] this.plus = ReactTestUtils.scryRenderedDOMComponentsWithClass(
ReactTestUtils.Simulate.click(this.plus) this.component,
expect(Actions.addSignature).toHaveBeenCalled() 'btn-editable-list'
}) )[0];
ReactTestUtils.Simulate.click(this.plus);
expect(Actions.addSignature).toHaveBeenCalled();
});
it('should delete a signature when you click the minus button', () => { it('should delete a signature when you click the minus button', () => {
spyOn(Actions, 'removeSignature') spyOn(Actions, 'removeSignature');
this.minus = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'btn-editable-list')[1] this.minus = ReactTestUtils.scryRenderedDOMComponentsWithClass(
ReactTestUtils.Simulate.click(this.minus) this.component,
expect(Actions.removeSignature).toHaveBeenCalledWith(SIGNATURES['1']) 'btn-editable-list'
}) )[1];
ReactTestUtils.Simulate.click(this.minus);
expect(Actions.removeSignature).toHaveBeenCalledWith(SIGNATURES['1']);
});
it('should toggle default status when you click an email on the dropdown', () => { it('should toggle default status when you click an email on the dropdown', () => {
spyOn(Actions, 'toggleAccount') spyOn(Actions, 'toggleAccount');
this.account = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'item')[0] this.account = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'item')[0];
ReactTestUtils.Simulate.mouseDown(this.account) ReactTestUtils.Simulate.mouseDown(this.account);
expect(Actions.toggleAccount).toHaveBeenCalledWith('tester@nylas.com') expect(Actions.toggleAccount).toHaveBeenCalledWith('tester@nylas.com');
}) });
it('should set the selected signature when you click on one that is not currently selected', () => { it('should set the selected signature when you click on one that is not currently selected', () => {
spyOn(Actions, 'selectSignature') spyOn(Actions, 'selectSignature');
this.item = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'list-item')[1] this.item = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'list-item')[1];
ReactTestUtils.Simulate.click(this.item) ReactTestUtils.Simulate.click(this.item);
expect(Actions.selectSignature).toHaveBeenCalledWith('2') expect(Actions.selectSignature).toHaveBeenCalledWith('2');
}) });
it('should modify the signature body when edited', () => { it('should modify the signature body when edited', () => {
spyOn(Actions, 'updateSignature') spyOn(Actions, 'updateSignature');
const newText = 'Changed <strong>NEW 1 HTML</strong><br>' const newText = 'Changed <strong>NEW 1 HTML</strong><br>';
this.component._onEditSignature({target: {value: newText}}); this.component._onEditSignature({ target: { value: newText } });
expect(Actions.updateSignature).toHaveBeenCalled() expect(Actions.updateSignature).toHaveBeenCalled();
}) });
it('should modify the signature title when edited', () => { it('should modify the signature title when edited', () => {
spyOn(Actions, 'updateSignature') spyOn(Actions, 'updateSignature');
const newTitle = 'Changed' const newTitle = 'Changed';
this.component._onEditSignature(newTitle) this.component._onEditSignature(newTitle);
expect(Actions.updateSignature).toHaveBeenCalled() expect(Actions.updateSignature).toHaveBeenCalled();
}) });
}) });
}) });

View file

@ -1,10 +1,10 @@
/* eslint quote-props: 0 */ /* eslint quote-props: 0 */
import React from 'react'; import React from 'react';
import ReactTestUtils from 'react-dom/test-utils' import ReactTestUtils from 'react-dom/test-utils';
import {SignatureStore} from 'nylas-exports'; import { SignatureStore } from 'nylas-exports';
import SignatureComposerDropdown from '../lib/signature-composer-dropdown' import SignatureComposerDropdown from '../lib/signature-composer-dropdown';
import {renderIntoDocument} from '../../../spec/nylas-test-utils' import { renderIntoDocument } from '../../../spec/nylas-test-utils';
const SIGNATURES = { const SIGNATURES = {
'1': { '1': {
@ -17,42 +17,59 @@ const SIGNATURES = {
title: 'two', title: 'two',
body: 'Here is my second sig!', body: 'Here is my second sig!',
}, },
} };
describe('SignatureComposerDropdown', function signatureComposerDropdown() { describe('SignatureComposerDropdown', function signatureComposerDropdown() {
beforeEach(() => { beforeEach(() => {
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES) spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES);
spyOn(SignatureStore, 'selectedSignature') spyOn(SignatureStore, 'selectedSignature');
this.session = { this.session = {
changes: { changes: {
add: jasmine.createSpy('add'), add: jasmine.createSpy('add'),
}, },
} };
this.draft = { this.draft = {
body: "draft body", body: 'draft body',
} };
this.button = renderIntoDocument(<SignatureComposerDropdown draft={this.draft} session={this.session} />) this.button = renderIntoDocument(
}) <SignatureComposerDropdown draft={this.draft} session={this.session} />
);
});
describe('the button dropdown', () => { describe('the button dropdown', () => {
it('calls add signature with the correct signature', () => { it('calls add signature with the correct signature', () => {
const sigToAdd = SIGNATURES['2'] const sigToAdd = SIGNATURES['2'];
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item')) ReactTestUtils.Simulate.click(
this.signature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, `signature-title-${sigToAdd.title}`) ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item')
ReactTestUtils.Simulate.mouseDown(this.signature) );
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({body: `${this.button.props.draft.body}<br><br><signature>${sigToAdd.body}</signature>`}) this.signature = ReactTestUtils.findRenderedDOMComponentWithClass(
}) this.button,
`signature-title-${sigToAdd.title}`
);
ReactTestUtils.Simulate.mouseDown(this.signature);
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({
body: `${this.button.props.draft.body}<br><br><signature>${sigToAdd.body}</signature>`,
});
});
it('calls add signature with nothing when no signature is clicked and there is no current signature', () => { it('calls add signature with nothing when no signature is clicked and there is no current signature', () => {
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item')) ReactTestUtils.Simulate.click(
this.noSignature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'item-none') ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item')
ReactTestUtils.Simulate.mouseDown(this.noSignature) );
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({body: `${this.button.props.draft.body}<br><br><signature></signature>`}) this.noSignature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'item-none');
}) ReactTestUtils.Simulate.mouseDown(this.noSignature);
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({
body: `${this.button.props.draft.body}<br><br><signature></signature>`,
});
});
it('finds and removes the signature when no signature is clicked and there is a current signature', () => { it('finds and removes the signature when no signature is clicked and there is a current signature', () => {
this.draft = 'draft body<signature>Remove me</signature>' this.draft = 'draft body<signature>Remove me</signature>';
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item')) ReactTestUtils.Simulate.click(
this.noSignature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'item-none') ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item')
ReactTestUtils.Simulate.mouseDown(this.noSignature) );
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({body: `${this.button.props.draft.body}<br><br><signature></signature>`}) this.noSignature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'item-none');
}) ReactTestUtils.Simulate.mouseDown(this.noSignature);
}) expect(this.button.props.session.changes.add).toHaveBeenCalledWith({
}) body: `${this.button.props.draft.body}<br><br><signature></signature>`,
});
});
});
});

View file

@ -1,28 +1,28 @@
import {Message, SignatureStore} from 'nylas-exports'; import { Message, SignatureStore } from 'nylas-exports';
import SignatureComposerExtension from '../lib/signature-composer-extension'; import SignatureComposerExtension from '../lib/signature-composer-extension';
const TEST_ID = 1 const TEST_ID = 1;
const TEST_SIGNATURE = { const TEST_SIGNATURE = {
id: TEST_ID, id: TEST_ID,
title: 'test-sig', title: 'test-sig',
body: '<div class="something">This is my signature.</div>', body: '<div class="something">This is my signature.</div>',
} };
const TEST_SIGNATURES = {} const TEST_SIGNATURES = {};
TEST_SIGNATURES[TEST_ID] = TEST_SIGNATURE TEST_SIGNATURES[TEST_ID] = TEST_SIGNATURE;
describe('SignatureComposerExtension', function signatureComposerExtension() { describe('SignatureComposerExtension', function signatureComposerExtension() {
describe("prepareNewDraft", () => { describe('prepareNewDraft', () => {
describe("when a signature is defined", () => { describe('when a signature is defined', () => {
beforeEach(() => { beforeEach(() => {
spyOn(NylasEnv.config, 'get').andCallFake((key) => spyOn(NylasEnv.config, 'get').andCallFake(
(key === 'nylas.signatures' ? TEST_SIGNATURES : null) key => (key === 'nylas.signatures' ? TEST_SIGNATURES : null)
); );
spyOn(SignatureStore, 'signatureForEmail').andReturn(TEST_SIGNATURE) spyOn(SignatureStore, 'signatureForEmail').andReturn(TEST_SIGNATURE);
SignatureStore.activate() SignatureStore.activate();
}); });
it("should insert the signature at the end of the message or before the first quoted text block and have a newline", () => { it('should insert the signature at the end of the message or before the first quoted text block and have a newline', () => {
const a = new Message({ const a = new Message({
draft: true, draft: true,
from: ['one@nylas.com'], from: ['one@nylas.com'],
@ -36,10 +36,14 @@ describe('SignatureComposerExtension', function signatureComposerExtension() {
body: 'This is a another test.', body: 'This is a another test.',
}); });
SignatureComposerExtension.prepareNewDraft({draft: a}); SignatureComposerExtension.prepareNewDraft({ draft: a });
expect(a.body).toEqual(`This is a test! <signature>${TEST_SIGNATURE.body}</signature><div class="gmail_quote">Hello world</div>`); expect(a.body).toEqual(
SignatureComposerExtension.prepareNewDraft({draft: b}); `This is a test! <signature>${TEST_SIGNATURE.body}</signature><div class="gmail_quote">Hello world</div>`
expect(b.body).toEqual(`This is a another test.<br><br><signature>${TEST_SIGNATURE.body}</signature>`); );
SignatureComposerExtension.prepareNewDraft({ draft: b });
expect(b.body).toEqual(
`This is a another test.<br><br><signature>${TEST_SIGNATURE.body}</signature>`
);
}); });
const scenarios = [ const scenarios = [
@ -63,18 +67,18 @@ describe('SignatureComposerExtension', function signatureComposerExtension() {
body: 'This is a test!<br/> <signature>\n<br>\n<div>SIG</div>\n</signature>', body: 'This is a test!<br/> <signature>\n<br>\n<div>SIG</div>\n</signature>',
expected: `This is a test!<br/> <signature>${TEST_SIGNATURE.body}</signature>`, expected: `This is a test!<br/> <signature>${TEST_SIGNATURE.body}</signature>`,
}, },
] ];
scenarios.forEach((scenario) => { scenarios.forEach(scenario => {
it(`should replace the signature if a signature is already present (${scenario.name})`, () => { it(`should replace the signature if a signature is already present (${scenario.name})`, () => {
const message = new Message({ const message = new Message({
draft: true, draft: true,
from: ['one@nylas.com'], from: ['one@nylas.com'],
body: scenario.body, body: scenario.body,
accountId: TEST_ACCOUNT_ID, accountId: TEST_ACCOUNT_ID,
}) });
SignatureComposerExtension.prepareNewDraft({draft: message}); SignatureComposerExtension.prepareNewDraft({ draft: message });
expect(message.body).toEqual(scenario.expected) expect(message.body).toEqual(scenario.expected);
}); });
}); });
}); });

View file

@ -1,5 +1,5 @@
/* eslint quote-props: 0 */ /* eslint quote-props: 0 */
import {SignatureStore} from 'nylas-exports' import { SignatureStore } from 'nylas-exports';
let SIGNATURES = { let SIGNATURES = {
'1': { '1': {
@ -12,35 +12,36 @@ let SIGNATURES = {
title: 'two', title: 'two',
body: 'Here is my second sig!', body: 'Here is my second sig!',
}, },
} };
const DEFAULTS = { const DEFAULTS = {
'one@nylas.com': '2', 'one@nylas.com': '2',
'two@nylas.com': '2', 'two@nylas.com': '2',
'three@nylas.com': null, 'three@nylas.com': null,
} };
describe('SignatureStore', function signatureStore() { describe('SignatureStore', function signatureStore() {
beforeEach(() => { beforeEach(() => {
spyOn(NylasEnv.config, 'get').andCallFake((key) => (key === 'nylas.signatures' ? SIGNATURES : null)) spyOn(NylasEnv.config, 'get').andCallFake(
key => (key === 'nylas.signatures' ? SIGNATURES : null)
);
spyOn(SignatureStore, '_saveSignatures').andCallFake(() => { spyOn(SignatureStore, '_saveSignatures').andCallFake(() => {
NylasEnv.config.set(`nylas.signatures`, SignatureStore.signatures) NylasEnv.config.set(`nylas.signatures`, SignatureStore.signatures);
}) });
spyOn(SignatureStore, 'signatureForEmail').andCallFake((email) => SIGNATURES[DEFAULTS[email]]) spyOn(SignatureStore, 'signatureForEmail').andCallFake(email => SIGNATURES[DEFAULTS[email]]);
spyOn(SignatureStore, 'selectedSignature').andCallFake(() => SIGNATURES['1']) spyOn(SignatureStore, 'selectedSignature').andCallFake(() => SIGNATURES['1']);
SignatureStore.activate() SignatureStore.activate();
}) });
describe('signatureForAccountId', () => { describe('signatureForAccountId', () => {
it('should return the default signature for that account', () => { it('should return the default signature for that account', () => {
const titleForAccount1 = SignatureStore.signatureForEmail('one@nylas.com').title const titleForAccount1 = SignatureStore.signatureForEmail('one@nylas.com').title;
expect(titleForAccount1).toEqual(SIGNATURES['2'].title) expect(titleForAccount1).toEqual(SIGNATURES['2'].title);
const account2Def = SignatureStore.signatureForEmail('three@nylas.com') const account2Def = SignatureStore.signatureForEmail('three@nylas.com');
expect(account2Def).toEqual(undefined) expect(account2Def).toEqual(undefined);
}) });
}) });
describe('removeSignature', () => { describe('removeSignature', () => {
beforeEach(() => { beforeEach(() => {
@ -48,17 +49,17 @@ describe('SignatureStore', function signatureStore() {
if (key === 'nylas.signatures') { if (key === 'nylas.signatures') {
SIGNATURES = newObject; SIGNATURES = newObject;
} }
}) });
}) });
it('should remove the signature from our list of signatures', () => { it('should remove the signature from our list of signatures', () => {
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId] const toRemove = SIGNATURES[SignatureStore.selectedSignatureId];
SignatureStore._onRemoveSignature(toRemove) SignatureStore._onRemoveSignature(toRemove);
expect(SIGNATURES['1']).toEqual(undefined) expect(SIGNATURES['1']).toEqual(undefined);
}) });
it('should reset selectedSignatureId to a different signature', () => { it('should reset selectedSignatureId to a different signature', () => {
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId] const toRemove = SIGNATURES[SignatureStore.selectedSignatureId];
SignatureStore._onRemoveSignature(toRemove) SignatureStore._onRemoveSignature(toRemove);
expect(SignatureStore.selectedSignatureId).toNotEqual('1') expect(SignatureStore.selectedSignatureId).toNotEqual('1');
}) });
}) });
}) });

View file

@ -1,14 +1,14 @@
import {ExtensionRegistry} from 'nylas-exports'; import { ExtensionRegistry } from 'nylas-exports';
import SpellcheckComposerExtension from './spellcheck-composer-extension'; import SpellcheckComposerExtension from './spellcheck-composer-extension';
export function activate() { export function activate() {
if (NylasEnv.config.get("core.composing.spellcheck")) { if (NylasEnv.config.get('core.composing.spellcheck')) {
ExtensionRegistry.Composer.register(SpellcheckComposerExtension); ExtensionRegistry.Composer.register(SpellcheckComposerExtension);
} }
} }
export function deactivate() { export function deactivate() {
if (NylasEnv.config.get("core.composing.spellcheck")) { if (NylasEnv.config.get('core.composing.spellcheck')) {
ExtensionRegistry.Composer.unregister(SpellcheckComposerExtension); ExtensionRegistry.Composer.unregister(SpellcheckComposerExtension);
} }
} }

View file

@ -1,8 +1,8 @@
import _ from 'underscore' import _ from 'underscore';
import {DOMUtils, ComposerExtension, Spellchecker} from 'nylas-exports'; import { DOMUtils, ComposerExtension, Spellchecker } from 'nylas-exports';
const recycled = []; const recycled = [];
const MAX_MISPELLINGS = 10 const MAX_MISPELLINGS = 10;
function getSpellingNodeForText(text) { function getSpellingNodeForText(text) {
let node = recycled.pop(); let node = recycled.pop();
@ -28,12 +28,17 @@ function whileApplyingSelectionChanges(rootNode, cb) {
modified: false, modified: false,
}; };
rootNode.style.display = 'none' rootNode.style.display = 'none';
cb(selectionSnapshot); cb(selectionSnapshot);
rootNode.style.display = 'block' rootNode.style.display = 'block';
if (selectionSnapshot.modified) { if (selectionSnapshot.modified) {
selection.setBaseAndExtent(selectionSnapshot.anchorNode, selectionSnapshot.anchorOffset, selectionSnapshot.focusNode, selectionSnapshot.focusOffset); selection.setBaseAndExtent(
selectionSnapshot.anchorNode,
selectionSnapshot.anchorOffset,
selectionSnapshot.focusNode,
selectionSnapshot.focusOffset
);
} }
} }
@ -41,7 +46,7 @@ function whileApplyingSelectionChanges(rootNode, cb) {
// It normalizes the DOM after removing spelling nodes to ensure that words // It normalizes the DOM after removing spelling nodes to ensure that words
// are not split between text nodes. (ie: doesn, 't => doesn't) // are not split between text nodes. (ie: doesn, 't => doesn't)
function unwrapWords(rootNode) { function unwrapWords(rootNode) {
whileApplyingSelectionChanges(rootNode, (selectionSnapshot) => { whileApplyingSelectionChanges(rootNode, selectionSnapshot => {
const spellingNodes = rootNode.querySelectorAll('spelling'); const spellingNodes = rootNode.querySelectorAll('spelling');
for (let ii = 0; ii < spellingNodes.length; ii++) { for (let ii = 0; ii < spellingNodes.length; ii++) {
const node = spellingNodes[ii]; const node = spellingNodes[ii];
@ -66,16 +71,22 @@ function unwrapWords(rootNode) {
// text node with a misspelled word, it splits it, wraps the misspelled word // 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. // with a <spelling> node and updates the selection to account for the change.
function wrapMisspelledWords(rootNode) { function wrapMisspelledWords(rootNode) {
whileApplyingSelectionChanges(rootNode, (selectionSnapshot) => { whileApplyingSelectionChanges(rootNode, selectionSnapshot => {
const treeWalker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { const treeWalker = document.createTreeWalker(
acceptNode: (node) => { rootNode,
// skip the entire subtree inside <code> tags and <a> tags... NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
if ((node.nodeType === Node.ELEMENT_NODE) && (["CODE", "A", "PRE"].includes(node.tagName))) { {
return NodeFilter.FILTER_REJECT; acceptNode: node => {
} // skip the entire subtree inside <code> tags and <a> tags...
return (node.nodeType === Node.TEXT_NODE) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; if (node.nodeType === Node.ELEMENT_NODE && ['CODE', 'A', 'PRE'].includes(node.tagName)) {
}, return NodeFilter.FILTER_REJECT;
}); }
return node.nodeType === Node.TEXT_NODE
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
},
}
);
const nodeList = []; const nodeList = [];
@ -89,7 +100,7 @@ function wrapMisspelledWords(rootNode) {
while (true) { while (true) {
const node = nodeList.shift(); const node = nodeList.shift();
if ((node === undefined) || (nodeMisspellingsFound > MAX_MISPELLINGS)) { if (node === undefined || nodeMisspellingsFound > MAX_MISPELLINGS) {
break; break;
} }
@ -98,18 +109,21 @@ function wrapMisspelledWords(rootNode) {
while (true) { while (true) {
const match = nodeWordRegexp.exec(nodeContent); const match = nodeWordRegexp.exec(nodeContent);
if ((match === null) || (nodeMisspellingsFound > MAX_MISPELLINGS)) { if (match === null || nodeMisspellingsFound > MAX_MISPELLINGS) {
break; break;
} }
if (Spellchecker.isMisspelled(match[0])) { if (Spellchecker.isMisspelled(match[0])) {
// The insertion point is currently at the end of this misspelled word. // The insertion point is currently at the end of this misspelled word.
// Do not mark it until the user types a space or leaves. // Do not mark it until the user types a space or leaves.
if ((selectionSnapshot.focusNode === node) && (selectionSnapshot.focusOffset === match.index + match[0].length)) { if (
selectionSnapshot.focusNode === node &&
selectionSnapshot.focusOffset === match.index + match[0].length
) {
continue; continue;
} }
const matchNode = (match.index === 0) ? node : node.splitText(match.index); const matchNode = match.index === 0 ? node : node.splitText(match.index);
const afterMatchNode = matchNode.splitText(match[0].length); const afterMatchNode = matchNode.splitText(match[0].length);
const spellingSpan = getSpellingNodeForText(match[0]); const spellingSpan = getSpellingNodeForText(match[0]);
@ -139,11 +153,11 @@ function wrapMisspelledWords(rootNode) {
} }
let currentlyRunningSpellChecker = false; let currentlyRunningSpellChecker = false;
const runSpellChecker = _.debounce((rootNode) => { const runSpellChecker = _.debounce(rootNode => {
currentlyRunningSpellChecker = true; currentlyRunningSpellChecker = true;
unwrapWords(rootNode); unwrapWords(rootNode);
Spellchecker.handler.provideHintText(rootNode.textContent).then(() => { Spellchecker.handler.provideHintText(rootNode.textContent).then(() => {
wrapMisspelledWords(rootNode) wrapMisspelledWords(rootNode);
// We defer here so that when the MutationObserver fires the // We defer here so that when the MutationObserver fires the
// SpellcheckComposerExtension.onContentChanged callback we will properly // SpellcheckComposerExtension.onContentChanged callback we will properly
@ -153,20 +167,18 @@ const runSpellChecker = _.debounce((rootNode) => {
_.defer(() => { _.defer(() => {
currentlyRunningSpellChecker = false; currentlyRunningSpellChecker = false;
}); });
}) });
}, 1000) }, 1000);
export default class SpellcheckComposerExtension extends ComposerExtension { export default class SpellcheckComposerExtension extends ComposerExtension {
static onContentChanged({ editor }) {
static onContentChanged({editor}) { const { rootNode } = editor;
const {rootNode} = editor
if (!currentlyRunningSpellChecker) { if (!currentlyRunningSpellChecker) {
runSpellChecker(rootNode); runSpellChecker(rootNode);
} }
} }
static onShowContextMenu({editor, menu}) { static onShowContextMenu({ editor, menu }) {
const selection = editor.currentSelection(); const selection = editor.currentSelection();
const range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0); const range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0);
const word = range.toString(); const word = range.toString();
@ -174,17 +186,17 @@ export default class SpellcheckComposerExtension extends ComposerExtension {
Spellchecker.appendSpellingItemsToMenu({ Spellchecker.appendSpellingItemsToMenu({
menu, menu,
word, word,
onCorrect: (correction) => { onCorrect: correction => {
DOMUtils.Mutating.applyTextInRange(range, selection, correction); DOMUtils.Mutating.applyTextInRange(range, selection, correction);
SpellcheckComposerExtension.onContentChanged({editor}); SpellcheckComposerExtension.onContentChanged({ editor });
}, },
onDidLearn: () => { onDidLearn: () => {
SpellcheckComposerExtension.onContentChanged({editor}); SpellcheckComposerExtension.onContentChanged({ editor });
}, },
}); });
} }
static applyTransformsForSending({draftBodyRootNode}) { static applyTransformsForSending({ draftBodyRootNode }) {
const spellingEls = draftBodyRootNode.querySelectorAll('spelling'); const spellingEls = draftBodyRootNode.querySelectorAll('spelling');
for (const spellingEl of Array.from(spellingEls)) { for (const spellingEl of Array.from(spellingEls)) {
// move contents out of the spelling node, remove the node // move contents out of the spelling node, remove the node
@ -199,5 +211,4 @@ export default class SpellcheckComposerExtension extends ComposerExtension {
static unapplyTransformsForSending() { static unapplyTransformsForSending() {
// no need to put spelling nodes back! // no need to put spelling nodes back!
} }
} }

View file

@ -1,6 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import {Spellchecker, Message} from 'nylas-exports'; import { Spellchecker, Message } from 'nylas-exports';
import SpellcheckComposerExtension from '../lib/spellcheck-composer-extension'; import SpellcheckComposerExtension from '../lib/spellcheck-composer-extension';
@ -14,31 +14,31 @@ describe('SpellcheckComposerExtension', function spellcheckComposerExtension() {
// Avoid differences between node-spellcheck on different platforms // Avoid differences between node-spellcheck on different platforms
const lookupPath = path.join(__dirname, 'fixtures', 'california-spelling-lookup.json'); const lookupPath = path.join(__dirname, 'fixtures', 'california-spelling-lookup.json');
const spellings = JSON.parse(fs.readFileSync(lookupPath)); const spellings = JSON.parse(fs.readFileSync(lookupPath));
spyOn(Spellchecker, 'isMisspelled').andCallFake(word => spellings[word]) spyOn(Spellchecker, 'isMisspelled').andCallFake(word => spellings[word]);
spyOn(Spellchecker.handler, 'provideHintText').andReturn({ spyOn(Spellchecker.handler, 'provideHintText').andReturn({
then(cb) { then(cb) {
cb() cb();
}, },
}) });
}); });
describe("onContentChanged", () => { describe('onContentChanged', () => {
it("correctly walks a DOM tree and surrounds mispelled words", () => { it('correctly walks a DOM tree and surrounds mispelled words', () => {
const node = document.createElement('div'); const node = document.createElement('div');
node.innerHTML = initialHTML; node.innerHTML = initialHTML;
const editor = { const editor = {
rootNode: node, rootNode: node,
whilePreservingSelection: (cb) => cb(), whilePreservingSelection: cb => cb(),
}; };
SpellcheckComposerExtension.onContentChanged({editor}); SpellcheckComposerExtension.onContentChanged({ editor });
advanceClock(1000) // Wait for debounce advanceClock(1000); // Wait for debounce
advanceClock(1) // Wait for defer advanceClock(1); // Wait for defer
expect(node.innerHTML).toEqual(afterHTML); expect(node.innerHTML).toEqual(afterHTML);
}); });
it("does not mark misspelled words inside A, CODE and PRE tags", () => { it('does not mark misspelled words inside A, CODE and PRE tags', () => {
const node = document.createElement('div'); const node = document.createElement('div');
node.innerHTML = ` node.innerHTML = `
<br> <br>
@ -54,12 +54,12 @@ describe('SpellcheckComposerExtension', function spellcheckComposerExtension() {
const editor = { const editor = {
rootNode: node, rootNode: node,
whilePreservingSelection: (cb) => cb(), whilePreservingSelection: cb => cb(),
}; };
SpellcheckComposerExtension.onContentChanged({editor}); SpellcheckComposerExtension.onContentChanged({ editor });
advanceClock(1000) // Wait for debounce advanceClock(1000); // Wait for debounce
advanceClock(1) // Wait for defer advanceClock(1); // Wait for defer
expect(node.innerHTML).toEqual(` expect(node.innerHTML).toEqual(`
<br> <br>
This is a <spelling class="misspelled">testst</spelling>! I have a few <spelling class="misspelled">misspellled</spelling> words. This is a <spelling class="misspelled">testst</spelling>! I have a few <spelling class="misspelled">misspellled</spelling> words.
@ -73,14 +73,14 @@ describe('SpellcheckComposerExtension', function spellcheckComposerExtension() {
}); });
}); });
describe("applyTransformsForSending", () => { describe('applyTransformsForSending', () => {
it("removes the spelling annotations it inserted", () => { it('removes the spelling annotations it inserted', () => {
const draft = new Message({ body: afterHTML }); const draft = new Message({ body: afterHTML });
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
const draftBodyRootNode = document.createElement('root') const draftBodyRootNode = document.createElement('root');
fragment.appendChild(draftBodyRootNode) fragment.appendChild(draftBodyRootNode);
draftBodyRootNode.innerHTML = afterHTML draftBodyRootNode.innerHTML = afterHTML;
SpellcheckComposerExtension.applyTransformsForSending({draftBodyRootNode, draft}); SpellcheckComposerExtension.applyTransformsForSending({ draftBodyRootNode, draft });
expect(draftBodyRootNode.innerHTML).toEqual(initialHTML); expect(draftBodyRootNode.innerHTML).toEqual(initialHTML);
}); });
}); });

View file

@ -1,5 +1,5 @@
/* eslint global-require: 0 */ /* eslint global-require: 0 */
import {PreferencesUIStore, ComponentRegistry, ExtensionRegistry} from 'nylas-exports'; import { PreferencesUIStore, ComponentRegistry, ExtensionRegistry } from 'nylas-exports';
import TemplatePicker from './template-picker'; import TemplatePicker from './template-picker';
import TemplateStatusBar from './template-status-bar'; import TemplateStatusBar from './template-status-bar';
import TemplateComposerExtension from './template-composer-extension'; import TemplateComposerExtension from './template-composer-extension';
@ -11,8 +11,8 @@ export function activate(state = {}) {
displayName: 'Quick Replies', displayName: 'Quick Replies',
componentClassFn: () => require('./preferences-templates').default, componentClassFn: () => require('./preferences-templates').default,
}); });
ComponentRegistry.register(TemplatePicker, {role: 'Composer:ActionButton'}); ComponentRegistry.register(TemplatePicker, { role: 'Composer:ActionButton' });
ComponentRegistry.register(TemplateStatusBar, {role: 'Composer:Footer'}); ComponentRegistry.register(TemplateStatusBar, { role: 'Composer:Footer' });
PreferencesUIStore.registerPreferencesTab(this.preferencesTab); PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
ExtensionRegistry.Composer.register(TemplateComposerExtension); ExtensionRegistry.Composer.register(TemplateComposerExtension);
} }

View file

@ -1,11 +1,10 @@
import _ from 'underscore'; import _ from 'underscore';
import {Contenteditable, RetinaImg} from 'nylas-component-kit'; import { Contenteditable, RetinaImg } from 'nylas-component-kit';
import {React} from 'nylas-exports'; import { React } from 'nylas-exports';
import TemplateStore from './template-store'; import TemplateStore from './template-store';
import TemplateEditor from './template-editor'; import TemplateEditor from './template-editor';
class PreferencesTemplates extends React.Component { class PreferencesTemplates extends React.Component {
static displayName = 'PreferencesTemplates'; static displayName = 'PreferencesTemplates';
@ -13,10 +12,10 @@ class PreferencesTemplates extends React.Component {
super(); super();
this._templateSaveQueue = {}; this._templateSaveQueue = {};
const {templates, selectedTemplate, selectedTemplateName} = this._getStateFromStores(); const { templates, selectedTemplate, selectedTemplateName } = this._getStateFromStores();
this.state = { this.state = {
editAsHTML: false, editAsHTML: false,
editState: templates.length === 0 ? "new" : null, editState: templates.length === 0 ? 'new' : null,
templates: templates, templates: templates,
selectedTemplate: selectedTemplate, selectedTemplate: selectedTemplate,
selectedTemplateName: selectedTemplateName, selectedTemplateName: selectedTemplateName,
@ -36,13 +35,13 @@ class PreferencesTemplates extends React.Component {
} }
// SAVING AND LOADING TEMPLATES // SAVING AND LOADING TEMPLATES
_loadTemplateContents = (template) => { _loadTemplateContents = template => {
if (template) { if (template) {
TemplateStore.getTemplateContents(template.id, (contents) => { TemplateStore.getTemplateContents(template.id, contents => {
this.setState({contents: contents}); this.setState({ contents: contents });
}); });
} }
} };
_saveTemplateNow(name, contents, callback) { _saveTemplateNow(name, contents, callback) {
TemplateStore.saveTemplate(name, contents, callback); TemplateStore.saveTemplate(name, contents, callback);
@ -60,17 +59,20 @@ class PreferencesTemplates extends React.Component {
this._templateSaveQueue = {}; this._templateSaveQueue = {};
} }
_saveTemplatesFromCache = _.debounce(PreferencesTemplates.prototype.__saveTemplatesFromCache, 500); _saveTemplatesFromCache = _.debounce(
PreferencesTemplates.prototype.__saveTemplatesFromCache,
500
);
// OVERALL STATE HANDLING // OVERALL STATE HANDLING
_onChange = () => { _onChange = () => {
this.setState(this._getStateFromStores()); this.setState(this._getStateFromStores());
} };
_getStateFromStores() { _getStateFromStores() {
const templates = TemplateStore.items(); const templates = TemplateStore.items();
let selectedTemplate = this.state ? this.state.selectedTemplate : null; let selectedTemplate = this.state ? this.state.selectedTemplate : null;
if (selectedTemplate && !_.pluck(templates, "id").includes(selectedTemplate.id)) { if (selectedTemplate && !_.pluck(templates, 'id').includes(selectedTemplate.id)) {
selectedTemplate = null; selectedTemplate = null;
} else if (!selectedTemplate) { } else if (!selectedTemplate) {
selectedTemplate = templates.length > 0 ? templates[0] : null; selectedTemplate = templates.length > 0 ? templates[0] : null;
@ -80,27 +82,25 @@ class PreferencesTemplates extends React.Component {
if (selectedTemplate) { if (selectedTemplate) {
selectedTemplateName = this.state ? this.state.selectedTemplateName : selectedTemplate.name; selectedTemplateName = this.state ? this.state.selectedTemplateName : selectedTemplate.name;
} }
return {templates, selectedTemplate, selectedTemplateName}; return { templates, selectedTemplate, selectedTemplateName };
} }
// TEMPLATE CONTENT EDITING // TEMPLATE CONTENT EDITING
_onEditTemplate = (event) => { _onEditTemplate = event => {
const html = event.target.value; const html = event.target.value;
this.setState({contents: html}); this.setState({ contents: html });
if (this.state.selectedTemplate) { if (this.state.selectedTemplate) {
this._saveTemplateSoon(this.state.selectedTemplate.name, html); this._saveTemplateSoon(this.state.selectedTemplate.name, html);
} }
} };
_onSelectTemplate = (event) => { _onSelectTemplate = event => {
if (this.state.selectedTemplate) { if (this.state.selectedTemplate) {
this._saveTemplateNow(this.state.selectedTemplate.name, this.state.contents); this._saveTemplateNow(this.state.selectedTemplate.name, this.state.contents);
} }
const selectedId = event.target.value; const selectedId = event.target.value;
const selectedTemplate = this.state.templates.find((template) => const selectedTemplate = this.state.templates.find(template => template.id === selectedId);
template.id === selectedId
);
this.setState({ this.setState({
selectedTemplate: selectedTemplate, selectedTemplate: selectedTemplate,
@ -108,15 +108,22 @@ class PreferencesTemplates extends React.Component {
contents: null, contents: null,
}); });
this._loadTemplateContents(selectedTemplate); this._loadTemplateContents(selectedTemplate);
} };
_renderTemplatePicker() { _renderTemplatePicker() {
const options = this.state.templates.map((template) => { const options = this.state.templates.map(template => {
return <option value={template.id} key={template.id}>{template.name}</option> return (
<option value={template.id} key={template.id}>
{template.name}
</option>
);
}); });
return ( return (
<select value={this.state.selectedTemplate ? this.state.selectedTemplate.id : null} onChange={this._onSelectTemplate}> <select
value={this.state.selectedTemplate ? this.state.selectedTemplate.id : null}
onChange={this._onSelectTemplate}
>
{options} {options}
</select> </select>
); );
@ -125,7 +132,7 @@ class PreferencesTemplates extends React.Component {
_renderEditableTemplate() { _renderEditableTemplate() {
return ( return (
<Contenteditable <Contenteditable
value={this.state.contents || ""} value={this.state.contents || ''}
onChange={this._onEditTemplate} onChange={this._onEditTemplate}
extensions={[TemplateEditor]} extensions={[TemplateEditor]}
spellcheck={false} spellcheck={false}
@ -134,76 +141,118 @@ class PreferencesTemplates extends React.Component {
} }
_renderHTMLTemplate() { _renderHTMLTemplate() {
return ( return <textarea value={this.state.contents || ''} onChange={this._onEditTemplate} />;
<textarea
value={this.state.contents || ""}
onChange={this._onEditTemplate}
/>
);
} }
_renderModeToggle() { _renderModeToggle() {
if (this.state.editAsHTML) { if (this.state.editAsHTML) {
return (<a onClick={() => { this.setState({editAsHTML: false}); }}>Edit live preview</a>); return (
<a
onClick={() => {
this.setState({ editAsHTML: false });
}}
>
Edit live preview
</a>
);
} }
return (<a onClick={() => { this.setState({editAsHTML: true}); }}>Edit raw HTML</a>); return (
<a
onClick={() => {
this.setState({ editAsHTML: true });
}}
>
Edit raw HTML
</a>
);
} }
_onEnter(action) { _onEnter(action) {
return (event) => { return event => {
if (event.key === "Enter") { if (event.key === 'Enter') {
action() action();
} }
} };
} }
// TEMPLATE NAME EDITING // TEMPLATE NAME EDITING
_renderEditName() { _renderEditName() {
return ( return (
<div className="section-title"> <div className="section-title">
Template Name: <input type="text" className="template-name-input" value={this.state.selectedTemplateName} onChange={this._onEditName} onKeyDown={this._onEnter(this._saveName)} /> Template Name:{' '}
<button className="btn template-name-btn" onClick={this._saveName}>Save Name</button> <input
<button className="btn template-name-btn" onClick={this._cancelEditName}>Cancel</button> type="text"
className="template-name-input"
value={this.state.selectedTemplateName}
onChange={this._onEditName}
onKeyDown={this._onEnter(this._saveName)}
/>
<button className="btn template-name-btn" onClick={this._saveName}>
Save Name
</button>
<button className="btn template-name-btn" onClick={this._cancelEditName}>
Cancel
</button>
</div> </div>
); );
} }
_renderName() { _renderName() {
const rawText = this.state.editAsHTML ? "Raw HTML " : ""; const rawText = this.state.editAsHTML ? 'Raw HTML ' : '';
return ( return (
<div className="section-title"> <div className="section-title">
{rawText}Template: {this._renderTemplatePicker()} {rawText}Template: {this._renderTemplatePicker()}
<button className="btn template-name-btn" title="New template" onClick={this._startNewTemplate}>New</button> <button
<button className="btn template-name-btn" onClick={() => { this.setState({editState: "name"}); }}>Rename</button> className="btn template-name-btn"
title="New template"
onClick={this._startNewTemplate}
>
New
</button>
<button
className="btn template-name-btn"
onClick={() => {
this.setState({ editState: 'name' });
}}
>
Rename
</button>
</div> </div>
); );
} }
_onEditName = (event) => { _onEditName = event => {
this.setState({selectedTemplateName: event.target.value}); this.setState({ selectedTemplateName: event.target.value });
} };
_cancelEditName = () => { _cancelEditName = () => {
this.setState({ this.setState({
selectedTemplateName: this.state.selectedTemplate ? this.state.selectedTemplate.name : null, selectedTemplateName: this.state.selectedTemplate ? this.state.selectedTemplate.name : null,
editState: null, editState: null,
}); });
} };
_saveName = () => { _saveName = () => {
if (this.state.selectedTemplate && this.state.selectedTemplate.name !== this.state.selectedTemplateName) { if (
TemplateStore.renameTemplate(this.state.selectedTemplate.name, this.state.selectedTemplateName, (renamedTemplate) => { this.state.selectedTemplate &&
this.setState({ this.state.selectedTemplate.name !== this.state.selectedTemplateName
selectedTemplate: renamedTemplate, ) {
editState: null, TemplateStore.renameTemplate(
}); this.state.selectedTemplate.name,
}); this.state.selectedTemplateName,
renamedTemplate => {
this.setState({
selectedTemplate: renamedTemplate,
editState: null,
});
}
);
} else { } else {
this.setState({ this.setState({
editState: null, editState: null,
}); });
} }
} };
// DELETE AND NEW // DELETE AND NEW
_deleteTemplate = () => { _deleteTemplate = () => {
@ -213,32 +262,32 @@ class PreferencesTemplates extends React.Component {
} }
if (numTemplates === 1) { if (numTemplates === 1) {
this.setState({ this.setState({
editState: "new", editState: 'new',
selectedTemplate: null, selectedTemplate: null,
selectedTemplateName: "", selectedTemplateName: '',
contents: "", contents: '',
}); });
} }
} };
_startNewTemplate = () => { _startNewTemplate = () => {
this.setState({ this.setState({
editState: "new", editState: 'new',
selectedTemplate: null, selectedTemplate: null,
selectedTemplateName: "", selectedTemplateName: '',
contents: "", contents: '',
}); });
} };
_saveNewTemplate = () => { _saveNewTemplate = () => {
this.setState({contents: ""}) this.setState({ contents: '' });
TemplateStore.saveNewTemplate(this.state.selectedTemplateName, "", (template) => { TemplateStore.saveNewTemplate(this.state.selectedTemplateName, '', template => {
this.setState({ this.setState({
selectedTemplate: template, selectedTemplate: template,
editState: null, editState: null,
}); });
}); });
} };
_cancelNewTemplate = () => { _cancelNewTemplate = () => {
const template = this.state.templates.length > 0 ? this.state.templates[0] : null; const template = this.state.templates.length > 0 ? this.state.templates[0] : null;
@ -248,14 +297,27 @@ class PreferencesTemplates extends React.Component {
editState: null, editState: null,
}); });
this._loadTemplateContents(template); this._loadTemplateContents(template);
} };
_renderCreateNew() { _renderCreateNew() {
const cancel = (<button className="btn template-name-btn" onClick={this._cancelNewTemplate}>Cancel</button>); const cancel = (
<button className="btn template-name-btn" onClick={this._cancelNewTemplate}>
Cancel
</button>
);
return ( return (
<div className="section-title"> <div className="section-title">
Template Name: <input type="text" className="template-name-input" value={this.state.selectedTemplateName} onChange={this._onEditName} onKeyDown={this._onEnter(this._saveNewTemplate)} /> Template Name:{' '}
<button className="btn btn-emphasis template-name-btn" onClick={this._saveNewTemplate}>Save</button> <input
type="text"
className="template-name-input"
value={this.state.selectedTemplateName}
onChange={this._onEditName}
onKeyDown={this._onEnter(this._saveNewTemplate)}
/>
<button className="btn btn-emphasis template-name-btn" onClick={this._saveNewTemplate}>
Save
</button>
{this.state.templates.length ? cancel : null} {this.state.templates.length ? cancel : null}
</div> </div>
); );
@ -274,23 +336,23 @@ class PreferencesTemplates extends React.Component {
<div className="template-wrap"> <div className="template-wrap">
{this.state.editAsHTML ? this._renderHTMLTemplate() : this._renderEditableTemplate()} {this.state.editAsHTML ? this._renderHTMLTemplate() : this._renderEditableTemplate()}
</div> </div>
<div style={{marginTop: "5px"}}> <div style={{ marginTop: '5px' }}>
<span className="editor-note"> <span className="editor-note">
{_.size(this._templateSaveQueue) === 0 ? "Changes saved." : ""} {_.size(this._templateSaveQueue) === 0 ? 'Changes saved.' : ''}
&nbsp; &nbsp;
</span> </span>
<span style={{"float": "right"}}>{this.state.editState === null ? deleteBtn : ""}</span> <span style={{ float: 'right' }}>{this.state.editState === null ? deleteBtn : ''}</span>
</div> </div>
<div className="toggle-mode" style={{marginTop: "1em"}}> <div className="toggle-mode" style={{ marginTop: '1em' }}>
{this._renderModeToggle()} {this._renderModeToggle()}
</div> </div>
</div> </div>
); );
let editContainer = this._renderName(); let editContainer = this._renderName();
if (this.state.editState === "name") { if (this.state.editState === 'name') {
editContainer = this._renderEditName(); editContainer = this._renderEditName();
} else if (this.state.editState === "new") { } else if (this.state.editState === 'new') {
editContainer = this._renderCreateNew(); editContainer = this._renderCreateNew();
} }
@ -302,26 +364,31 @@ class PreferencesTemplates extends React.Component {
return ( return (
<div className="container-templates"> <div className="container-templates">
<section style={this.state.editState === "new" ? {marginBottom: 50} : null}> <section style={this.state.editState === 'new' ? { marginBottom: 50 } : null}>
{editContainer} {editContainer}
{this.state.editState !== "new" ? editor : null} {this.state.editState !== 'new' ? editor : null}
{this.state.templates.length === 0 ? noTemplatesMessage : null} {this.state.templates.length === 0 ? noTemplatesMessage : null}
</section> </section>
<section className="templates-instructions"> <section className="templates-instructions">
<p> <p>
{`To create a variable, type a set of double curly {`To create a variable, type a set of double curly
brackets wrapping the variable's name, like this`}: <strong>{"{{"}variable_name{"}}"}</strong>. The highlighting in the variable regions will be removed before the message is brackets wrapping the variable's name, like this`}:{' '}
sent. <strong>
{'{{'}variable_name{'}}'}
</strong>. The highlighting in the variable regions will be removed before the message
is sent.
</p> </p>
<p> <p>
Reply templates are saved as HTML files in the <strong>{`${NylasEnv.getConfigDirPath()}/templates`}</strong> directory on your computer. In raw HTML, variables are defined as HTML &lt;code&gt; tags with class &quot;var empty&quot;. Reply templates are saved as HTML files in the{' '}
<strong>{`${NylasEnv.getConfigDirPath()}/templates`}</strong> directory on your
computer. In raw HTML, variables are defined as HTML &lt;code&gt; tags with class
&quot;var empty&quot;.
</p> </p>
</section> </section>
</div> </div>
); );
} }
} }
export default PreferencesTemplates; export default PreferencesTemplates;

View file

@ -1,8 +1,7 @@
import {DOMUtils, ComposerExtension} from 'nylas-exports'; import { DOMUtils, ComposerExtension } from 'nylas-exports';
export default class TemplatesComposerExtension extends ComposerExtension { export default class TemplatesComposerExtension extends ComposerExtension {
static warningsForSending({ draft }) {
static warningsForSending({draft}) {
const warnings = []; const warnings = [];
if (draft.body.search(/<code[^>]*empty[^>]*>/i) > 0) { if (draft.body.search(/<code[^>]*empty[^>]*>/i) > 0) {
warnings.push('with an empty template area'); warnings.push('with an empty template area');
@ -10,26 +9,32 @@ export default class TemplatesComposerExtension extends ComposerExtension {
return warnings; return warnings;
} }
static applyTransformsForSending = ({draftBodyRootNode}) => { static applyTransformsForSending = ({ draftBodyRootNode }) => {
draftBodyRootNode.innerHTML = draftBodyRootNode.innerHTML.replace(/<\/?code[^>]*>/g, (match) => draftBodyRootNode.innerHTML = draftBodyRootNode.innerHTML.replace(
`<!-- ${match} -->` /<\/?code[^>]*>/g,
match => `<!-- ${match} -->`
); );
} };
static unapplyTransformsForSending = ({draftBodyRootNode}) => { static unapplyTransformsForSending = ({ draftBodyRootNode }) => {
draftBodyRootNode.innerHTML = draftBodyRootNode.innerHTML.replace(/<!-- (<\/?code[^>]*>) -->/g, (match, node) => draftBodyRootNode.innerHTML = draftBodyRootNode.innerHTML.replace(
node /<!-- (<\/?code[^>]*>) -->/g,
(match, node) => node
); );
} };
static onClick({editor, event}) { static onClick({ editor, event }) {
const node = event.target; const node = event.target;
if (node.nodeName === 'CODE' && node.classList.contains('var') && node.classList.contains('empty')) { if (
node.nodeName === 'CODE' &&
node.classList.contains('var') &&
node.classList.contains('empty')
) {
editor.selectAllChildren(node); editor.selectAllChildren(node);
} }
} }
static onKeyDown({editor, event}) { static onKeyDown({ editor, event }) {
const editableNode = editor.rootNode; const editableNode = editor.rootNode;
if (event.key === 'Tab') { if (event.key === 'Tab') {
const nodes = editableNode.querySelectorAll('code.var'); const nodes = editableNode.querySelectorAll('code.var');
@ -59,7 +64,10 @@ export default class TemplatesComposerExtension extends ComposerExtension {
// If we failed to find a <code> that the selection is within, select the // If we failed to find a <code> that the selection is within, select the
// nearest <code> before/after the selection (depending on shift). // nearest <code> before/after the selection (depending on shift).
if (!found) { if (!found) {
const treeWalker = document.createTreeWalker(editableNode, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT); const treeWalker = document.createTreeWalker(
editableNode,
NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT
);
let curIndex = 0; let curIndex = 0;
let nextIndex = null; let nextIndex = null;
let node = treeWalker.nextNode(); let node = treeWalker.nextNode();
@ -88,13 +96,15 @@ export default class TemplatesComposerExtension extends ComposerExtension {
} }
} }
static onContentChanged({editor}) { static onContentChanged({ editor }) {
const editableNode = editor.rootNode; const editableNode = editor.rootNode;
const selection = editor.currentSelection().rawSelection; const selection = editor.currentSelection().rawSelection;
const isWithinNode = (node) => { const isWithinNode = node => {
let test = selection.baseNode; let test = selection.baseNode;
while (test !== editableNode) { while (test !== editableNode) {
if (test === node) { return true; } if (test === node) {
return true;
}
test = test.parentNode; test = test.parentNode;
} }
return false; return false;

View file

@ -1,22 +1,27 @@
import {DOMUtils, ContenteditableExtension} from 'nylas-exports'; import { DOMUtils, ContenteditableExtension } from 'nylas-exports';
export default class TemplateEditor extends ContenteditableExtension { export default class TemplateEditor extends ContenteditableExtension {
static onContentChanged = ({ editor }) => {
static onContentChanged = ({editor}) => {
// Run through and remove all code nodes that are invalid // Run through and remove all code nodes that are invalid
const codeNodes = editor.rootNode.querySelectorAll("code.var.empty"); const codeNodes = editor.rootNode.querySelectorAll('code.var.empty');
for (let ii = 0; ii < codeNodes.length; ii++) { for (let ii = 0; ii < codeNodes.length; ii++) {
const codeNode = codeNodes[ii]; const codeNode = codeNodes[ii];
// remove any style that was added by contenteditable // remove any style that was added by contenteditable
codeNode.removeAttribute("style"); codeNode.removeAttribute('style');
// grab the text content and the indexable text content // grab the text content and the indexable text content
const codeNodeText = codeNode.textContent; const codeNodeText = codeNode.textContent;
const indexText = DOMUtils.getIndexedTextContent(codeNode).map(({text}) => text).join(""); 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 // 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)) { if (
!codeNodeText.startsWith('{{') ||
!codeNodeText.endsWith('}}') ||
indexText.indexOf('\n') > -1
) {
editor.whilePreservingSelection(() => { editor.whilePreservingSelection(() => {
DOMUtils.unwrapNode(codeNode); DOMUtils.unwrapNode(codeNode);
}); });
@ -29,20 +34,20 @@ export default class TemplateEditor extends ContenteditableExtension {
// as inline style, including the yellow text from <code> nodes that we insert. This is contenteditable // 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 // 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. // below code is a hack to prevent yellow text from appearing.
const starNodes = editor.rootNode.querySelectorAll("*"); const starNodes = editor.rootNode.querySelectorAll('*');
for (let ii = 0; ii < starNodes.length; ii++) { for (let ii = 0; ii < starNodes.length; ii++) {
const node = starNodes[ii]; const node = starNodes[ii];
if ((!node.className) && (node.style.color === "#c79b11")) { if (!node.className && node.style.color === '#c79b11') {
editor.whilePreservingSelection(() => { editor.whilePreservingSelection(() => {
DOMUtils.unwrapNode(node); DOMUtils.unwrapNode(node);
}); });
} }
} }
const fontNodes = editor.rootNode.querySelectorAll("font"); const fontNodes = editor.rootNode.querySelectorAll('font');
for (let ii = 0; ii < fontNodes.length; ii++) { for (let ii = 0; ii < fontNodes.length; ii++) {
const node = fontNodes[ii]; const node = fontNodes[ii];
if (node.color === "#c79b11") { if (node.color === '#c79b11') {
editor.whilePreservingSelection(() => { editor.whilePreservingSelection(() => {
DOMUtils.unwrapNode(node); DOMUtils.unwrapNode(node);
}); });
@ -53,11 +58,11 @@ export default class TemplateEditor extends ContenteditableExtension {
// Regex finds any {{ <contents> }} that doesn't contain {, }, or \n // Regex finds any {{ <contents> }} that doesn't contain {, }, or \n
// https://regex101.com/r/jF2oF4/1 // https://regex101.com/r/jF2oF4/1
for (const range of editor.regExpSelectorAll(/\{\{[^\n{}]*?\}\}/g)) { for (const range of editor.regExpSelectorAll(/\{\{[^\n{}]*?\}\}/g)) {
if (!DOMUtils.isWrapped(range, "CODE")) { if (!DOMUtils.isWrapped(range, 'CODE')) {
// Preserve the selection based on text index within the range matched by the regex // Preserve the selection based on text index within the range matched by the regex
const selIndex = editor.getSelectionTextIndex(range); const selIndex = editor.getSelectionTextIndex(range);
const codeNode = DOMUtils.wrap(range, "CODE"); const codeNode = DOMUtils.wrap(range, 'CODE');
codeNode.className = "var empty"; codeNode.className = 'var empty';
// Sets node contents to just its textContent, strips HTML // Sets node contents to just its textContent, strips HTML
codeNode.textContent = codeNode.textContent; codeNode.textContent = codeNode.textContent;
@ -67,5 +72,5 @@ export default class TemplateEditor extends ContenteditableExtension {
} }
} }
} }
} };
} }

View file

@ -1,13 +1,13 @@
/* eslint jsx-a11y/tabindex-no-positive: 0 */ /* eslint jsx-a11y/tabindex-no-positive: 0 */
import {Actions, React, ReactDOM} from 'nylas-exports'; import { Actions, React, ReactDOM, PropTypes } from 'nylas-exports';
import {Menu, RetinaImg} from 'nylas-component-kit'; import { Menu, RetinaImg } from 'nylas-component-kit';
import TemplateStore from './template-store'; import TemplateStore from './template-store';
class TemplatePopover extends React.Component { class TemplatePopover extends React.Component {
static displayName = 'TemplatePopover'; static displayName = 'TemplatePopover';
static propTypes = { static propTypes = {
headerMessageId: React.PropTypes.string, headerMessageId: PropTypes.string,
}; };
constructor() { constructor() {
@ -20,7 +20,7 @@ class TemplatePopover extends React.Component {
componentDidMount() { componentDidMount() {
this.unsubscribe = TemplateStore.listen(() => { this.unsubscribe = TemplateStore.listen(() => {
this.setState({templates: TemplateStore.items()}); this.setState({ templates: TemplateStore.items() });
}); });
} }
@ -31,38 +31,40 @@ class TemplatePopover extends React.Component {
} }
_filteredTemplates() { _filteredTemplates() {
const {searchValue, templates} = this.state; const { searchValue, templates } = this.state;
if (!searchValue.length) { return templates; } if (!searchValue.length) {
return templates;
}
return templates.filter((t) => { return templates.filter(t => {
return t.name.toLowerCase().indexOf(searchValue.toLowerCase()) === 0; return t.name.toLowerCase().indexOf(searchValue.toLowerCase()) === 0;
}); });
} }
_onSearchValueChange = (event) => { _onSearchValueChange = event => {
this.setState({searchValue: event.target.value}); this.setState({ searchValue: event.target.value });
}; };
_onChooseTemplate = (template) => { _onChooseTemplate = template => {
Actions.insertTemplateId({templateId: template.id, headerMessageId: this.props.headerMessageId}); Actions.insertTemplateId({
templateId: template.id,
headerMessageId: this.props.headerMessageId,
});
Actions.closePopover(); Actions.closePopover();
} };
_onManageTemplates = () => { _onManageTemplates = () => {
Actions.showTemplates(); Actions.showTemplates();
}; };
_onNewTemplate = () => { _onNewTemplate = () => {
Actions.createTemplate({headerMessageId: this.props.headerMessageId}); Actions.createTemplate({ headerMessageId: this.props.headerMessageId });
}; };
_onClickButton = () => { _onClickButton = () => {
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect() const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
Actions.openPopover( Actions.openPopover(this._renderPopover(), { originRect: buttonRect, direction: 'up' });
this._renderPopover(),
{originRect: buttonRect, direction: 'up'}
)
}; };
render() { render() {
@ -81,8 +83,12 @@ class TemplatePopover extends React.Component {
// note: these are using onMouseDown to avoid clearing focus in the composer (I think) // note: these are using onMouseDown to avoid clearing focus in the composer (I think)
const footerComponents = [ const footerComponents = [
<div className="item" key="new" onMouseDown={this._onNewTemplate}>Save Draft as Template...</div>, <div className="item" key="new" onMouseDown={this._onNewTemplate}>
<div className="item" key="manage" onMouseDown={this._onManageTemplates}>Manage Templates...</div>, Save Draft as Template...
</div>,
<div className="item" key="manage" onMouseDown={this._onManageTemplates}>
Manage Templates...
</div>,
]; ];
return ( return (
@ -91,28 +97,27 @@ class TemplatePopover extends React.Component {
headerComponents={headerComponents} headerComponents={headerComponents}
footerComponents={footerComponents} footerComponents={footerComponents}
items={filteredTemplates} items={filteredTemplates}
itemKey={(item) => item.id} itemKey={item => item.id}
itemContent={(item) => item.name} itemContent={item => item.name}
onSelect={this._onChooseTemplate} onSelect={this._onChooseTemplate}
/> />
); );
} }
} }
class TemplatePicker extends React.Component { class TemplatePicker extends React.Component {
static displayName = 'TemplatePicker'; static displayName = 'TemplatePicker';
static propTypes = { static propTypes = {
headerMessageId: React.PropTypes.string, headerMessageId: PropTypes.string,
}; };
_onClickButton = () => { _onClickButton = () => {
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect() const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
Actions.openPopover( Actions.openPopover(<TemplatePopover headerMessageId={this.props.headerMessageId} />, {
<TemplatePopover headerMessageId={this.props.headerMessageId} />, originRect: buttonRect,
{originRect: buttonRect, direction: 'up'} direction: 'up',
) });
}; };
render() { render() {
@ -128,10 +133,7 @@ class TemplatePicker extends React.Component {
mode={RetinaImg.Mode.ContentIsMask} mode={RetinaImg.Mode.ContentIsMask}
/> />
&nbsp; &nbsp;
<RetinaImg <RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
name="icon-composer-dropdown.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
</button> </button>
); );
} }

View file

@ -1,17 +1,17 @@
import {React} from 'nylas-exports'; import { React, PropTypes } from 'nylas-exports';
class TemplateStatusBar extends React.Component { class TemplateStatusBar extends React.Component {
static displayName = 'TemplateStatusBar'; static displayName = 'TemplateStatusBar';
static propTypes = { static propTypes = {
draft: React.PropTypes.object.isRequired, draft: PropTypes.object.isRequired,
}; };
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
return (this._usingTemplate(nextProps) !== this._usingTemplate(this.props)); return this._usingTemplate(nextProps) !== this._usingTemplate(this.props);
} }
_usingTemplate({draft}) { _usingTemplate({ draft }) {
return draft && draft.body.search(/<code[^>]*class="var[^>]*>/i) > 0; return draft && draft.body.search(/<code[^>]*class="var[^>]*>/i) > 0;
} }
@ -19,13 +19,13 @@ class TemplateStatusBar extends React.Component {
if (this._usingTemplate(this.props)) { if (this._usingTemplate(this.props)) {
return ( return (
<div className="template-status-bar"> <div className="template-status-bar">
Press &quot;tab&quot; to quickly move between the blanks - highlighting will not be visible to recipients. Press &quot;tab&quot; to quickly move between the blanks - highlighting will not be
visible to recipients.
</div> </div>
); );
} }
return <div />; return <div />;
} }
} }
TemplateStatusBar.containerStyles = { TemplateStatusBar.containerStyles = {

View file

@ -1,12 +1,11 @@
/* eslint global-require: 0*/ /* eslint global-require: 0*/
import {DraftStore, Actions, QuotedHTMLTransformer} from 'nylas-exports'; import { DraftStore, Actions, QuotedHTMLTransformer } from 'nylas-exports';
import NylasStore from 'nylas-store'; import NylasStore from 'nylas-store';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
class TemplateStore extends NylasStore { class TemplateStore extends NylasStore {
// Support accented characters in template names // Support accented characters in template names
// https://regex101.com/r/nD3eY8/1 // https://regex101.com/r/nD3eY8/1
static INVALID_TEMPLATE_NAME_REGEX = /[^a-zA-Z\u00C0-\u017F0-9_\- ]+/g; static INVALID_TEMPLATE_NAME_REGEX = /[^a-zA-Z\u00C0-\u017F0-9_\- ]+/g;
@ -42,7 +41,7 @@ class TemplateStore extends NylasStore {
// I know this is a bit of pain but don't do anything that // I know this is a bit of pain but don't do anything that
// could possibly slow down app launch // could possibly slow down app launch
fs.exists(this._templatesDir, (exists) => { fs.exists(this._templatesDir, exists => {
if (exists) { if (exists) {
this._populate(); this._populate();
this.watch(); this.watch();
@ -92,15 +91,18 @@ class TemplateStore extends NylasStore {
fs.readdir(this._templatesDir, (err, filenames) => { fs.readdir(this._templatesDir, (err, filenames) => {
if (err) { if (err) {
NylasEnv.showErrorDialog({ NylasEnv.showErrorDialog({
title: "Cannot scan templates directory", title: 'Cannot scan templates directory',
message: `N1 was unable to read the contents of your templates directory (${this._templatesDir}). You may want to delete this folder or ensure filesystem permissions are set correctly.`, message: `N1 was unable to read the contents of your templates directory (${this
._templatesDir}). You may want to delete this folder or ensure filesystem permissions are set correctly.`,
}); });
return; return;
} }
this._items = []; this._items = [];
for (let i = 0, filename; i < filenames.length; i++) { for (let i = 0, filename; i < filenames.length; i++) {
filename = filenames[i]; filename = filenames[i];
if (filename[0] === '.') { continue; } if (filename[0] === '.') {
continue;
}
const displayname = path.basename(filename, path.extname(filename)); const displayname = path.basename(filename, path.extname(filename));
this._items.push({ this._items.push({
id: filename, id: filename,
@ -112,11 +114,12 @@ class TemplateStore extends NylasStore {
}); });
} }
_onCreateTemplate({headerMessageId, name, contents} = {}) { _onCreateTemplate({ headerMessageId, name, contents } = {}) {
if (headerMessageId) { if (headerMessageId) {
DraftStore.sessionForClientId(headerMessageId).then((session) => { DraftStore.sessionForClientId(headerMessageId).then(session => {
const draft = session.draft(); const draft = session.draft();
const draftName = name || draft.subject.replace(TemplateStore.INVALID_TEMPLATE_NAME_REGEX, ''); const draftName =
name || draft.subject.replace(TemplateStore.INVALID_TEMPLATE_NAME_REGEX, '');
let draftContents = contents || QuotedHTMLTransformer.removeQuotedHTML(draft.body); let draftContents = contents || QuotedHTMLTransformer.removeQuotedHTML(draft.body);
const sigIndex = draftContents.indexOf('<signature>'); const sigIndex = draftContents.indexOf('<signature>');
@ -125,7 +128,9 @@ class TemplateStore extends NylasStore {
this._displayError('Give your draft a subject to name your template.'); this._displayError('Give your draft a subject to name your template.');
} }
if (!draftContents || draftContents.length === 0) { if (!draftContents || draftContents.length === 0) {
this._displayError('To create a template you need to fill the body of the current draft.'); this._displayError(
'To create a template you need to fill the body of the current draft.'
);
} }
this.saveNewTemplate(draftName, draftContents, this._onShowTemplates); this.saveNewTemplate(draftName, draftContents, this._onShowTemplates);
}); });
@ -151,13 +156,15 @@ class TemplateStore extends NylasStore {
} }
_displayDialog(title, message, buttons) { _displayDialog(title, message, buttons) {
const dialog = require('electron').remote.dialog; const dialog = require('electron').remote.dialog;
return (dialog.showMessageBox({ return (
title: title, dialog.showMessageBox({
message: title, title: title,
detail: message, message: title,
buttons: buttons, detail: message,
type: 'info', buttons: buttons,
}) === 0); type: 'info',
}) === 0
);
} }
saveNewTemplate(name, contents, callback) { saveNewTemplate(name, contents, callback) {
@ -167,7 +174,9 @@ class TemplateStore extends NylasStore {
} }
if (name.match(TemplateStore.INVALID_TEMPLATE_NAME_REGEX)) { if (name.match(TemplateStore.INVALID_TEMPLATE_NAME_REGEX)) {
this._displayError('Invalid template name! Names can only contain letters, numbers, spaces, dashes, and underscores.'); this._displayError(
'Invalid template name! Names can only contain letters, numbers, spaces, dashes, and underscores.'
);
return; return;
} }
@ -195,9 +204,11 @@ class TemplateStore extends NylasStore {
let template = this._getTemplate(name); let template = this._getTemplate(name);
this.unwatch(); this.unwatch();
fs.writeFile(templatePath, contents, (err) => { fs.writeFile(templatePath, contents, err => {
this.watch(); this.watch();
if (err) { this._displayError(err); } if (err) {
this._displayError(err);
}
if (template === null) { if (template === null) {
template = { template = {
id: filename, id: filename,
@ -214,13 +225,17 @@ class TemplateStore extends NylasStore {
deleteTemplate(name, callback) { deleteTemplate(name, callback) {
const template = this._getTemplate(name); const template = this._getTemplate(name);
if (!template) { return; } if (!template) {
return;
}
if (this._displayDialog( if (
this._displayDialog(
'Delete this template?', 'Delete this template?',
'The template and its file will be permanently deleted.', 'The template and its file will be permanently deleted.',
['Delete', 'Cancel'] ['Delete', 'Cancel']
)) { )
) {
fs.unlink(template.path, () => { fs.unlink(template.path, () => {
this._populate(); this._populate();
if (callback) { if (callback) {
@ -232,10 +247,14 @@ class TemplateStore extends NylasStore {
renameTemplate(oldName, newName, callback) { renameTemplate(oldName, newName, callback) {
const template = this._getTemplate(oldName); const template = this._getTemplate(oldName);
if (!template) { return; } if (!template) {
return;
}
if (newName.match(TemplateStore.INVALID_TEMPLATE_NAME_REGEX)) { if (newName.match(TemplateStore.INVALID_TEMPLATE_NAME_REGEX)) {
this._displayError('Invalid template name! Names can only contain letters, numbers, spaces, dashes, and underscores.'); this._displayError(
'Invalid template name! Names can only contain letters, numbers, spaces, dashes, and underscores.'
);
return; return;
} }
if (newName.length === 0) { if (newName.length === 0) {
@ -255,16 +274,16 @@ class TemplateStore extends NylasStore {
}); });
} }
_onInsertTemplateId({templateId, headerMessageId} = {}) { _onInsertTemplateId({ templateId, headerMessageId } = {}) {
this.getTemplateContents(templateId, (templateBody) => { this.getTemplateContents(templateId, templateBody => {
DraftStore.sessionForClientId(headerMessageId).then((session) => { DraftStore.sessionForClientId(headerMessageId).then(session => {
let proceed = true; let proceed = true;
if (!session.draft().pristine && !session.draft().hasEmptyBody()) { if (!session.draft().pristine && !session.draft().hasEmptyBody()) {
proceed = this._displayDialog( proceed = this._displayDialog(
'Replace draft contents?', 'Replace draft contents?',
'It looks like your draft already has some content. Loading this template will ' + 'It looks like your draft already has some content. Loading this template will ' +
'overwrite all draft contents.', 'overwrite all draft contents.',
['Replace contents', 'Cancel'] ['Replace contents', 'Cancel']
); );
} }
@ -273,9 +292,12 @@ class TemplateStore extends NylasStore {
const sigIndex = draftContents.indexOf('<signature>'); const sigIndex = draftContents.indexOf('<signature>');
const signature = sigIndex > -1 ? draftContents.slice(sigIndex) : ''; const signature = sigIndex > -1 ? draftContents.slice(sigIndex) : '';
const draftHtml = QuotedHTMLTransformer.appendQuotedHTML(templateBody + signature, session.draft().body); const draftHtml = QuotedHTMLTransformer.appendQuotedHTML(
Actions.recordUserEvent("Email Template Inserted") templateBody + signature,
session.changes.add({body: draftHtml}); session.draft().body
);
Actions.recordUserEvent('Email Template Inserted');
session.changes.add({ body: draftHtml });
} }
}); });
}); });
@ -283,7 +305,9 @@ class TemplateStore extends NylasStore {
getTemplateContents(templateId, callback) { getTemplateContents(templateId, callback) {
const template = this._getTemplate(null, templateId); const template = this._getTemplate(null, templateId);
if (!template) { return; } if (!template) {
return;
}
fs.readFile(template.path, (err, data) => { fs.readFile(template.path, (err, data) => {
const body = data.toString(); const body = data.toString();
@ -293,4 +317,4 @@ class TemplateStore extends NylasStore {
} }
const store = new TemplateStore(); const store = new TemplateStore();
export default store export default store;

View file

@ -1,6 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import { remote } from 'electron'; import { remote } from 'electron';
import {Message, DraftStore} from 'nylas-exports'; import { Message, DraftStore } from 'nylas-exports';
import TemplateStore from '../lib/template-store'; import TemplateStore from '../lib/template-store';
const { shell } = remote; const { shell } = remote;
@ -13,8 +13,8 @@ const stubTemplateFiles = {
}; };
const stubTemplates = [ const stubTemplates = [
{id: 'template1.html', name: 'template1', path: `${stubTemplatesDir}/template1.html`}, { id: 'template1.html', name: 'template1', path: `${stubTemplatesDir}/template1.html` },
{id: 'template2.html', name: 'template2', path: `${stubTemplatesDir}/template2.html`}, { id: 'template2.html', name: 'template2', path: `${stubTemplatesDir}/template2.html` },
]; ];
xdescribe('TemplateStore', function templateStore() { xdescribe('TemplateStore', function templateStore() {
@ -38,9 +38,15 @@ xdescribe('TemplateStore', function templateStore() {
it('should expose templates in the templates directory', () => { it('should expose templates in the templates directory', () => {
let watchCallback; let watchCallback;
spyOn(fs, 'exists').andCallFake((path, callback) => { callback(true); }); spyOn(fs, 'exists').andCallFake((path, callback) => {
spyOn(fs, 'watch').andCallFake((path, callback) => { watchCallback = callback }); callback(true);
spyOn(fs, 'readdir').andCallFake((path, callback) => { callback(null, Object.keys(stubTemplateFiles)); }); });
spyOn(fs, 'watch').andCallFake((path, callback) => {
watchCallback = callback;
});
spyOn(fs, 'readdir').andCallFake((path, callback) => {
callback(null, Object.keys(stubTemplateFiles));
});
TemplateStore._init(stubTemplatesDir); TemplateStore._init(stubTemplatesDir);
watchCallback(); watchCallback();
expect(TemplateStore.items()).toEqual(stubTemplates); expect(TemplateStore.items()).toEqual(stubTemplates);
@ -51,7 +57,9 @@ xdescribe('TemplateStore', function templateStore() {
let watchFired = false; let watchFired = false;
spyOn(fs, 'exists').andCallFake((path, callback) => callback(true)); spyOn(fs, 'exists').andCallFake((path, callback) => callback(true));
spyOn(fs, 'watch').andCallFake((path, callback) => { watchCallback = callback }); spyOn(fs, 'watch').andCallFake((path, callback) => {
watchCallback = callback;
});
spyOn(fs, 'readdir').andCallFake((path, callback) => { spyOn(fs, 'readdir').andCallFake((path, callback) => {
if (watchFired) { if (watchFired) {
callback(null, Object.keys(stubTemplateFiles)); callback(null, Object.keys(stubTemplateFiles));
@ -70,14 +78,20 @@ xdescribe('TemplateStore', function templateStore() {
describe('insertTemplateId', () => { describe('insertTemplateId', () => {
xit('should insert the template with the given id into the draft with the given id', () => { xit('should insert the template with the given id into the draft with the given id', () => {
let watchCallback; let watchCallback;
spyOn(fs, 'exists').andCallFake((path, callback) => { callback(true); }); spyOn(fs, 'exists').andCallFake((path, callback) => {
spyOn(fs, 'watch').andCallFake((path, callback) => { watchCallback = callback }); callback(true);
spyOn(fs, 'readdir').andCallFake((path, callback) => { callback(null, Object.keys(stubTemplateFiles)); }); });
spyOn(fs, 'watch').andCallFake((path, callback) => {
watchCallback = callback;
});
spyOn(fs, 'readdir').andCallFake((path, callback) => {
callback(null, Object.keys(stubTemplateFiles));
});
TemplateStore._init(stubTemplatesDir); TemplateStore._init(stubTemplatesDir);
watchCallback(); watchCallback();
const add = jasmine.createSpy('add'); const add = jasmine.createSpy('add');
spyOn(DraftStore, 'sessionForClientId').andCallFake(() => { spyOn(DraftStore, 'sessionForClientId').andCallFake(() => {
return Promise.resolve({changes: {add}}); return Promise.resolve({ changes: { add } });
}); });
runs(() => { runs(() => {
@ -98,13 +112,17 @@ xdescribe('TemplateStore', function templateStore() {
describe('onCreateTemplate', () => { describe('onCreateTemplate', () => {
beforeEach(() => { beforeEach(() => {
let d; let d;
spyOn(DraftStore, 'sessionForClientId').andCallFake((headerMessageId) => { spyOn(DraftStore, 'sessionForClientId').andCallFake(headerMessageId => {
if (headerMessageId === 'localid-nosubject') { if (headerMessageId === 'localid-nosubject') {
d = new Message({subject: '', body: '<p>Body</p>'}); d = new Message({ subject: '', body: '<p>Body</p>' });
} else { } else {
d = new Message({subject: 'Subject', body: '<p>Body</p>'}); d = new Message({ subject: 'Subject', body: '<p>Body</p>' });
} }
const session = {draft() { return d; }}; const session = {
draft() {
return d;
},
};
return Promise.resolve(session); return Promise.resolve(session);
}); });
TemplateStore._init(stubTemplatesDir); TemplateStore._init(stubTemplatesDir);
@ -112,8 +130,8 @@ xdescribe('TemplateStore', function templateStore() {
xit('should create a template with the given name and contents', () => { xit('should create a template with the given name and contents', () => {
const ref = TemplateStore.items(); const ref = TemplateStore.items();
TemplateStore._onCreateTemplate({name: '123', contents: 'bla'}); TemplateStore._onCreateTemplate({ name: '123', contents: 'bla' });
const item = (ref != null ? ref[0] : undefined); const item = ref != null ? ref[0] : undefined;
expect(item.id).toBe('123.html'); expect(item.id).toBe('123.html');
expect(item.name).toBe('123'); expect(item.name).toBe('123');
expect(item.path.split('/').pop()).toBe('123.html'); expect(item.path.split('/').pop()).toBe('123.html');
@ -121,18 +139,18 @@ xdescribe('TemplateStore', function templateStore() {
xit('should display an error if no name is provided', () => { xit('should display an error if no name is provided', () => {
spyOn(TemplateStore, '_displayError'); spyOn(TemplateStore, '_displayError');
TemplateStore._onCreateTemplate({contents: 'bla'}); TemplateStore._onCreateTemplate({ contents: 'bla' });
expect(TemplateStore._displayError).toHaveBeenCalled(); expect(TemplateStore._displayError).toHaveBeenCalled();
}); });
xit('should display an error if no content is provided', () => { xit('should display an error if no content is provided', () => {
spyOn(TemplateStore, '_displayError'); spyOn(TemplateStore, '_displayError');
TemplateStore._onCreateTemplate({name: 'bla'}); TemplateStore._onCreateTemplate({ name: 'bla' });
expect(TemplateStore._displayError).toHaveBeenCalled(); expect(TemplateStore._displayError).toHaveBeenCalled();
}); });
xit('should save the template file to the templates folder', () => { xit('should save the template file to the templates folder', () => {
TemplateStore._onCreateTemplate({name: '123', contents: 'bla'}); TemplateStore._onCreateTemplate({ name: '123', contents: 'bla' });
const path = `${stubTemplatesDir}/123.html`; const path = `${stubTemplatesDir}/123.html`;
expect(fs.writeFile).toHaveBeenCalled(); expect(fs.writeFile).toHaveBeenCalled();
expect(fs.writeFile.mostRecentCall.args[0]).toEqual(path); expect(fs.writeFile.mostRecentCall.args[0]).toEqual(path);
@ -140,7 +158,7 @@ xdescribe('TemplateStore', function templateStore() {
}); });
xit('should open the template so you can see it', () => { xit('should open the template so you can see it', () => {
TemplateStore._onCreateTemplate({name: '123', contents: 'bla'}); TemplateStore._onCreateTemplate({ name: '123', contents: 'bla' });
expect(shell.showItemInFolder).toHaveBeenCalled(); expect(shell.showItemInFolder).toHaveBeenCalled();
}); });
@ -149,7 +167,7 @@ xdescribe('TemplateStore', function templateStore() {
spyOn(TemplateStore, 'trigger'); spyOn(TemplateStore, 'trigger');
spyOn(TemplateStore, '_populate'); spyOn(TemplateStore, '_populate');
runs(() => { runs(() => {
TemplateStore._onCreateTemplate({headerMessageId: 'localid-b'}); TemplateStore._onCreateTemplate({ headerMessageId: 'localid-b' });
}); });
waitsFor(() => TemplateStore.trigger.callCount > 0); waitsFor(() => TemplateStore.trigger.callCount > 0);
runs(() => { runs(() => {
@ -161,7 +179,7 @@ xdescribe('TemplateStore', function templateStore() {
spyOn(TemplateStore, '_displayError'); spyOn(TemplateStore, '_displayError');
spyOn(fs, 'watch'); spyOn(fs, 'watch');
runs(() => { runs(() => {
TemplateStore._onCreateTemplate({headerMessageId: 'localid-nosubject'}); TemplateStore._onCreateTemplate({ headerMessageId: 'localid-nosubject' });
}); });
waitsFor(() => TemplateStore._displayError.callCount > 0); waitsFor(() => TemplateStore._displayError.callCount > 0);
runs(() => { runs(() => {

View file

@ -9,18 +9,17 @@
import { import {
React, React,
ReactDOM, ReactDOM,
PropTypes,
ComponentRegistry, ComponentRegistry,
QuotedHTMLTransformer, QuotedHTMLTransformer,
Actions, Actions,
} from 'nylas-exports'; } from 'nylas-exports';
import { import { Menu, RetinaImg } from 'nylas-component-kit';
Menu,
RetinaImg,
} from 'nylas-component-kit';
const YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate'; const YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate';
const YandexTranslationKey = 'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e'; const YandexTranslationKey =
'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e';
const YandexLanguages = { const YandexLanguages = {
English: 'en', English: 'en',
Spanish: 'es', Spanish: 'es',
@ -35,7 +34,6 @@ const YandexLanguages = {
}; };
class TranslateButton extends React.Component { class TranslateButton extends React.Component {
// Adding a `displayName` makes debugging React easier // Adding a `displayName` makes debugging React easier
static displayName = 'TranslateButton'; static displayName = 'TranslateButton';
@ -43,8 +41,8 @@ class TranslateButton extends React.Component {
// we receive the local id of the current draft as a `prop` (a read-only // 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. // property). Since our code depends on this prop, we mark it as a requirement.
static propTypes = { static propTypes = {
draft: React.PropTypes.object.isRequired, draft: PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired, session: PropTypes.object.isRequired,
}; };
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
@ -54,13 +52,13 @@ class TranslateButton extends React.Component {
} }
_onError(error) { _onError(error) {
Actions.closePopover() Actions.closePopover();
const dialog = require('electron').remote.dialog; const dialog = require('electron').remote.dialog;
dialog.showErrorBox('Language Conversion Failed', error.toString()); dialog.showErrorBox('Language Conversion Failed', error.toString());
} }
_onTranslate = async (lang) => { _onTranslate = async lang => {
Actions.closePopover() Actions.closePopover();
// Obtain the session for the current draft. The draft session provides us // 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 // the draft object and also manages saving changes to the local cache and
@ -68,9 +66,9 @@ class TranslateButton extends React.Component {
const draftHtml = this.props.draft.body; const draftHtml = this.props.draft.body;
const text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml); const text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml);
Actions.recordUserEvent("Email Translated", { Actions.recordUserEvent('Email Translated', {
language: YandexLanguages[lang], language: YandexLanguages[lang],
}) });
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
queryParams.set('key', YandexTranslationKey); queryParams.set('key', YandexTranslationKey);
@ -79,9 +77,9 @@ class TranslateButton extends React.Component {
queryParams.set('format', 'html'); queryParams.set('format', 'html');
try { try {
const resp = await fetch(YandexTranslationURL, {body: queryParams}); const resp = await fetch(YandexTranslationURL, { body: queryParams });
if (!resp.ok) { if (!resp.ok) {
throw new Error("Sorry, we were unable to complete the translation request."); throw new Error('Sorry, we were unable to complete the translation request.');
} }
const json = await resp.json(); const json = await resp.json();
let translated = json.text.join(''); let translated = json.text.join('');
@ -93,7 +91,7 @@ class TranslateButton extends React.Component {
// To update the draft, we add the new body to it's session. The session object // 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 // automatically marshalls changes to the database and ensures that others accessing
// the same draft are notified of changes. // the same draft are notified of changes.
this.props.session.changes.add({body: translated}); this.props.session.changes.add({ body: translated });
this.props.session.changes.commit(); this.props.session.changes.commit();
} catch (error) { } catch (error) {
this._onError(error); this._onError(error);
@ -101,29 +99,24 @@ class TranslateButton extends React.Component {
}; };
_onClickTranslateButton = () => { _onClickTranslateButton = () => {
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect() const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
Actions.openPopover( Actions.openPopover(this._renderPopover(), { originRect: buttonRect, direction: 'up' });
this._renderPopover(),
{originRect: buttonRect, direction: 'up'}
)
}; };
// Helper method that will render the contents of our popover. // Helper method that will render the contents of our popover.
_renderPopover() { _renderPopover() {
const headerComponents = [ const headerComponents = [<span>Translate:</span>];
<span>Translate:</span>,
];
return ( return (
<Menu <Menu
className="translate-language-picker" className="translate-language-picker"
items={Object.keys(YandexLanguages)} items={Object.keys(YandexLanguages)}
itemKey={(item) => item} itemKey={item => item}
itemContent={(item) => item} itemContent={item => item}
headerComponents={headerComponents} headerComponents={headerComponents}
defaultSelectedIndex={-1} defaultSelectedIndex={-1}
onSelect={this._onTranslate} onSelect={this._onTranslate}
/> />
) );
} }
// The `render` method returns a React Virtual DOM element. This code looks // The `render` method returns a React Virtual DOM element. This code looks
@ -152,10 +145,7 @@ class TranslateButton extends React.Component {
url="mailspring://composer-translate/assets/icon-composer-translate@2x.png" url="mailspring://composer-translate/assets/icon-composer-translate@2x.png"
/> />
&nbsp; &nbsp;
<RetinaImg <RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
name="icon-composer-dropdown.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
</button> </button>
); );
} }
@ -183,9 +173,7 @@ export function activate() {
}); });
} }
export function serialize() { export function serialize() {}
}
export function deactivate() { export function deactivate() {
ComponentRegistry.unregister(TranslateButton); ComponentRegistry.unregister(TranslateButton);

View file

@ -1,32 +1,29 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import { import { AccountStore } from 'nylas-exports';
AccountStore, import { Menu, ButtonDropdown, InjectedComponentSet } from 'nylas-component-kit';
} from 'nylas-exports';
import {Menu, ButtonDropdown, InjectedComponentSet} from 'nylas-component-kit';
export default class AccountContactField extends React.Component { export default class AccountContactField extends React.Component {
static displayName = 'AccountContactField'; static displayName = 'AccountContactField';
static propTypes = { static propTypes = {
value: React.PropTypes.object, value: PropTypes.object,
accounts: React.PropTypes.array, accounts: PropTypes.array,
session: React.PropTypes.object.isRequired, session: PropTypes.object.isRequired,
draft: React.PropTypes.object.isRequired, draft: PropTypes.object.isRequired,
onChange: React.PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
}; };
_onChooseContact = (contact) => { _onChooseContact = contact => {
this.props.onChange({from: [contact]}); this.props.onChange({ from: [contact] });
this.props.session.ensureCorrectAccount() this.props.session.ensureCorrectAccount();
this._dropdownComponent.toggleDropdown(); this._dropdownComponent.toggleDropdown();
} };
_renderAccountSelector() { _renderAccountSelector() {
if (!this.props.value) { if (!this.props.value) {
return ( return <span />;
<span />
);
} }
const label = this.props.value.toString(); const label = this.props.value.toString();
@ -36,7 +33,9 @@ export default class AccountContactField extends React.Component {
if (multipleAccounts || hasAliases) { if (multipleAccounts || hasAliases) {
return ( return (
<ButtonDropdown <ButtonDropdown
ref={(cm) => { this._dropdownComponent = cm; }} ref={cm => {
this._dropdownComponent = cm;
}}
bordered={false} bordered={false}
primaryItem={<span>{label}</span>} primaryItem={<span>{label}</span>}
menu={this._renderAccounts(this.props.accounts)} menu={this._renderAccounts(this.props.accounts)}
@ -46,23 +45,21 @@ export default class AccountContactField extends React.Component {
return this._renderAccountSpan(label); return this._renderAccountSpan(label);
} }
_renderAccountSpan = (label) => { _renderAccountSpan = label => {
return ( return (
<span className="from-single-name" style={{position: "relative", top: 13, left: "0.5em"}}> <span className="from-single-name" style={{ position: 'relative', top: 13, left: '0.5em' }}>
{label} {label}
</span> </span>
); );
} };
_renderMenuItem = (contact) => { _renderMenuItem = contact => {
const className = classnames({ const className = classnames({
'contact': true, contact: true,
'is-alias': contact.isAlias, 'is-alias': contact.isAlias,
}); });
return ( return <span className={className}>{contact.toString()}</span>;
<span className={className}>{contact.toString()}</span> };
);
}
_renderAccounts(accounts) { _renderAccounts(accounts) {
const items = AccountStore.aliasesFor(accounts); const items = AccountStore.aliasesFor(accounts);
@ -76,13 +73,12 @@ export default class AccountContactField extends React.Component {
); );
} }
_renderFromFieldComponents = () => { _renderFromFieldComponents = () => {
const {draft, session, accounts} = this.props const { draft, session, accounts } = this.props;
return ( return (
<InjectedComponentSet <InjectedComponentSet
className="dropdown-component" className="dropdown-component"
matching={{role: "Composer:FromFieldComponents"}} matching={{ role: 'Composer:FromFieldComponents' }}
exposedProps={{ exposedProps={{
draft, draft,
session, session,
@ -90,8 +86,8 @@ export default class AccountContactField extends React.Component {
currentAccount: draft.from[0], currentAccount: draft.from[0],
}} }}
/> />
) );
} };
render() { render() {
return ( return (

View file

@ -1,26 +1,25 @@
import React from 'react' import classnames from 'classnames';
import classnames from 'classnames' import { React, PropTypes, ComponentRegistry } from 'nylas-exports';
import {ComponentRegistry} from 'nylas-exports' import { InjectedComponentSet } from 'nylas-component-kit';
import {InjectedComponentSet} from 'nylas-component-kit'
const ROLE = "Composer:ActionButton"; const ROLE = 'Composer:ActionButton';
export default class ActionBarPlugins extends React.Component { export default class ActionBarPlugins extends React.Component {
static displayName = "ActionBarPlugins"; static displayName = 'ActionBarPlugins';
static propTypes = { static propTypes = {
draft: React.PropTypes.object, draft: PropTypes.object,
session: React.PropTypes.object, session: PropTypes.object,
isValidDraft: React.PropTypes.func, isValidDraft: PropTypes.func,
} };
constructor(props) { constructor(props) {
super(props); super(props);
this.state = this._getStateFromStores() this.state = this._getStateFromStores();
} }
componentDidMount() { componentDidMount() {
this._usub = ComponentRegistry.listen(this._onComponentsChange) this._usub = ComponentRegistry.listen(this._onComponentsChange);
} }
componentWillUnmount() { componentWillUnmount() {
@ -37,26 +36,26 @@ export default class ActionBarPlugins extends React.Component {
// It also takes 2 frames to reliably get all of the icons painted. // It also takes 2 frames to reliably get all of the icons painted.
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
this.setState(this._getStateFromStores()) this.setState(this._getStateFromStores());
}) });
}) });
} }
} };
_getPluginsLength() { _getPluginsLength() {
return ComponentRegistry.findComponentsMatching({role: ROLE}).length; return ComponentRegistry.findComponentsMatching({ role: ROLE }).length;
} }
_getStateFromStores() { _getStateFromStores() {
return { return {
pluginsLoaded: this._getPluginsLength() > 0, pluginsLoaded: this._getPluginsLength() > 0,
} };
} }
render() { render() {
const className = classnames({ const className = classnames({
"action-bar-animation-wrap": true, 'action-bar-animation-wrap': true,
"plugins-loaded": this.state.pluginsLoaded, 'plugins-loaded': this.state.pluginsLoaded,
}); });
return ( return (
@ -64,7 +63,7 @@ export default class ActionBarPlugins extends React.Component {
<div className="action-bar-cover" /> <div className="action-bar-cover" />
<InjectedComponentSet <InjectedComponentSet
className="composer-action-bar-plugins" className="composer-action-bar-plugins"
matching={{role: ROLE}} matching={{ role: ROLE }}
exposedProps={{ exposedProps={{
draft: this.props.draft, draft: this.props.draft,
threadId: this.props.draft.threadId, threadId: this.props.draft.threadId,
@ -74,6 +73,6 @@ export default class ActionBarPlugins extends React.Component {
}} }}
/> />
</span> </span>
) );
} }
} }

View file

@ -1,20 +1,19 @@
import React from 'react'; import { React, PropTypes, Utils } from 'nylas-exports';
import {Utils} from 'nylas-exports'; import { DropZone, InjectedComponentSet } from 'nylas-component-kit';
import {DropZone, InjectedComponentSet} from 'nylas-component-kit';
const NUM_TO_DISPLAY_MAX = 999; const NUM_TO_DISPLAY_MAX = 999;
export default class CollapsedParticipants extends React.Component { export default class CollapsedParticipants extends React.Component {
static displayName = "CollapsedParticipants"; static displayName = 'CollapsedParticipants';
static propTypes = { static propTypes = {
// Arrays of Contact objects. // Arrays of Contact objects.
to: React.PropTypes.array, to: PropTypes.array,
cc: React.PropTypes.array, cc: PropTypes.array,
bcc: React.PropTypes.array, bcc: PropTypes.array,
onDrop: React.PropTypes.func, onDrop: PropTypes.func,
onDragChange: React.PropTypes.func, onDragChange: PropTypes.func,
} };
static defaultProps = { static defaultProps = {
to: [], to: [],
@ -22,7 +21,7 @@ export default class CollapsedParticipants extends React.Component {
bcc: [], bcc: [],
onDrop: () => {}, onDrop: () => {},
onDragChange: () => {}, onDragChange: () => {},
} };
constructor(props = {}) { constructor(props = {}) {
super(props); super(props);
@ -30,7 +29,7 @@ export default class CollapsedParticipants extends React.Component {
numToDisplay: NUM_TO_DISPLAY_MAX, numToDisplay: NUM_TO_DISPLAY_MAX,
numRemaining: 0, numRemaining: 0,
numBccRemaining: 0, numBccRemaining: 0,
} };
} }
componentDidMount() { componentDidMount() {
@ -60,8 +59,8 @@ export default class CollapsedParticipants extends React.Component {
_setNumHiddenParticipants() { _setNumHiddenParticipants() {
const $wrap = this._participantsWrapEl; const $wrap = this._participantsWrapEl;
const $regulars = Array.from($wrap.getElementsByClassName("regular-contact")); const $regulars = Array.from($wrap.getElementsByClassName('regular-contact'));
const $bccs = Array.from($wrap.getElementsByClassName("bcc-contact")); const $bccs = Array.from($wrap.getElementsByClassName('bcc-contact'));
const availableSpace = $wrap.getBoundingClientRect().width; const availableSpace = $wrap.getBoundingClientRect().width;
let numRemaining = this.props.to.length + this.props.cc.length; let numRemaining = this.props.to.length + this.props.cc.length;
@ -87,7 +86,7 @@ export default class CollapsedParticipants extends React.Component {
numToDisplay += 1; numToDisplay += 1;
} }
this.setState({numToDisplay, numRemaining, numBccRemaining}); this.setState({ numToDisplay, numRemaining, numBccRemaining });
} }
_renderNumRemaining() { _renderNumRemaining() {
@ -99,7 +98,8 @@ export default class CollapsedParticipants extends React.Component {
} else if (this.state.numRemaining === 0 && this.state.numBccRemaining > 0) { } else if (this.state.numRemaining === 0 && this.state.numBccRemaining > 0) {
str = `${this.state.numBccRemaining} Bcc`; str = `${this.state.numBccRemaining} Bcc`;
} else if (this.state.numRemaining > 0 && this.state.numBccRemaining > 0) { } else if (this.state.numRemaining > 0 && this.state.numBccRemaining > 0) {
str = `${this.state.numRemaining + this.state.numBccRemaining} more (${this.state.numBccRemaining} Bcc)`; str = `${this.state.numRemaining + this.state.numBccRemaining} more (${this.state
.numBccRemaining} Bcc)`;
} }
return ( return (
@ -110,25 +110,22 @@ export default class CollapsedParticipants extends React.Component {
); );
} }
_collapsedContact = (contact) => { _collapsedContact = contact => {
const name = contact.displayName(); const name = contact.displayName();
const key = contact.email + contact.name; const key = contact.email + contact.name;
return ( return (
<span <span key={key} className="collapsed-contact regular-contact">
key={key}
className="collapsed-contact regular-contact"
>
<InjectedComponentSet <InjectedComponentSet
matching={{role: "Composer:RecipientChip"}} matching={{ role: 'Composer:RecipientChip' }}
exposedProps={{contact: contact, collapsed: true}} exposedProps={{ contact: contact, collapsed: true }}
direction="row" direction="row"
inline inline
/> />
{name} {name}
</span> </span>
); );
} };
_collapsedBccContact = (contact, i) => { _collapsedBccContact = (contact, i) => {
let name = contact.displayName(); let name = contact.displayName();
@ -137,18 +134,20 @@ export default class CollapsedParticipants extends React.Component {
name = `Bcc: ${name}`; name = `Bcc: ${name}`;
} }
return ( return (
<span key={key} className="collapsed-contact bcc-contact">{name}</span> <span key={key} className="collapsed-contact bcc-contact">
{name}
</span>
); );
} };
render() { render() {
const contacts = this.props.to.concat(this.props.cc).map(this._collapsedContact) const contacts = this.props.to.concat(this.props.cc).map(this._collapsedContact);
const bcc = this.props.bcc.map(this._collapsedBccContact); const bcc = this.props.bcc.map(this._collapsedBccContact);
let toDisplay = contacts.concat(bcc); let toDisplay = contacts.concat(bcc);
toDisplay = toDisplay.splice(0, this.state.numToDisplay); toDisplay = toDisplay.splice(0, this.state.numToDisplay);
if (toDisplay.length === 0) { if (toDisplay.length === 0) {
toDisplay = "Recipients"; toDisplay = 'Recipients';
} }
return ( return (
@ -159,7 +158,9 @@ export default class CollapsedParticipants extends React.Component {
> >
<div <div
tabIndex={0} tabIndex={0}
ref={(el) => { this._participantsWrapEl = el; }} ref={el => {
this._participantsWrapEl = el;
}}
className="collapsed-composer-participants" className="collapsed-composer-participants"
> >
{this._renderNumRemaining()} {this._renderNumRemaining()}

View file

@ -1,13 +1,13 @@
import React from 'react'; import React from 'react';
import {Actions} from 'nylas-exports'; import { Actions } from 'nylas-exports';
import {RetinaImg} from 'nylas-component-kit'; import { RetinaImg } from 'nylas-component-kit';
export default class ComposeButton extends React.Component { export default class ComposeButton extends React.Component {
static displayName = 'ComposeButton'; static displayName = 'ComposeButton';
_onNewCompose = () => { _onNewCompose = () => {
Actions.composeNewBlankDraft() Actions.composeNewBlankDraft();
} };
render() { render() {
return ( return (

View file

@ -1,7 +1,7 @@
import React, {Component} from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types' import PropTypes from 'prop-types';
import {ExtensionRegistry, DOMUtils} from 'nylas-exports'; import { ExtensionRegistry, DOMUtils } from 'nylas-exports';
import {DropZone, ScrollRegion, Contenteditable} from 'nylas-component-kit'; import { DropZone, ScrollRegion, Contenteditable } from 'nylas-component-kit';
/** /**
* Renders the text editor for the composer * Renders the text editor for the composer
@ -89,7 +89,6 @@ class ComposerEditor extends Component {
this.unsub(); this.unsub();
} }
// Public methods // Public methods
// TODO Get rid of these selection methods // TODO Get rid of these selection methods
@ -110,21 +109,21 @@ class ComposerEditor extends Component {
// the body. Be sure to choose the last node /above/ the signature and any // the body. Be sure to choose the last node /above/ the signature and any
// quoted text that is visible. (as in forwarded messages.) // quoted text that is visible. (as in forwarded messages.)
// //
this._contenteditableComponent.atomicEdit(({editor}) => { this._contenteditableComponent.atomicEdit(({ editor }) => {
editor.rootNode.focus(); editor.rootNode.focus();
const lastNode = this._findLastNodeBeforeQuoteOrSignature(editor) const lastNode = this._findLastNodeBeforeQuoteOrSignature(editor);
if (lastNode) { if (lastNode) {
this._selectNode(lastNode, {collapseTo: NODE_END}); this._selectNode(lastNode, { collapseTo: NODE_END });
} else { } else {
this._selectNode(editor.rootNode, {collapseTo: NODE_BEGINNING}); this._selectNode(editor.rootNode, { collapseTo: NODE_BEGINNING });
} }
}); });
} }
focusAbsoluteEnd() { focusAbsoluteEnd() {
this._contenteditableComponent.atomicEdit(({editor}) => { this._contenteditableComponent.atomicEdit(({ editor }) => {
editor.rootNode.focus(); editor.rootNode.focus();
this._selectNode(editor.rootNode, {collapseTo: NODE_END}); this._selectNode(editor.rootNode, { collapseTo: NODE_END });
}); });
} }
@ -132,7 +131,9 @@ class ComposerEditor extends Component {
// <br> tags contain no text nodes. // <br> tags contain no text nodes.
_findLastNodeBeforeQuoteOrSignature(editor) { _findLastNodeBeforeQuoteOrSignature(editor) {
const walker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT); const walker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT);
const nodesBelowUserBody = editor.rootNode.querySelectorAll('signature, .gmail_quote, blockquote'); const nodesBelowUserBody = editor.rootNode.querySelectorAll(
'signature, .gmail_quote, blockquote'
);
let lastNode = null; let lastNode = null;
let node = walker.nextNode(); let node = walker.nextNode();
@ -150,10 +151,10 @@ class ComposerEditor extends Component {
lastNode = node; lastNode = node;
node = walker.nextNode(); node = walker.nextNode();
} }
return lastNode return lastNode;
} }
_selectNode(node, {collapseTo} = {}) { _selectNode(node, { collapseTo } = {}) {
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(node); range.selectNodeContents(node);
range.collapse(collapseTo); range.collapse(collapseTo);
@ -171,17 +172,17 @@ class ComposerEditor extends Component {
this._contenteditableComponent._onDOMMutated(mutations); this._contenteditableComponent._onDOMMutated(mutations);
} }
_onDrop = (event) => { _onDrop = event => {
this._contenteditableComponent._onDrop(event) this._contenteditableComponent._onDrop(event);
} };
_onDragOver = (event) => { _onDragOver = event => {
this._contenteditableComponent._onDragOver(event) this._contenteditableComponent._onDragOver(event);
} };
_shouldAcceptDrop = (event) => { _shouldAcceptDrop = event => {
return this._contenteditableComponent._shouldAcceptDrop(event) return this._contenteditableComponent._shouldAcceptDrop(event);
} };
// Helpers // Helpers
_scrollToBottom = () => { _scrollToBottom = () => {
@ -200,7 +201,7 @@ class ComposerEditor extends Component {
* of the contenteditable. props.parentActions.scrollToBottom moves to the bottom of * of the contenteditable. props.parentActions.scrollToBottom moves to the bottom of
* the "send" button. * the "send" button.
*/ */
_bottomIsNearby = (editableNode) => { _bottomIsNearby = editableNode => {
const parentRect = this.props.parentActions.getComposerBoundingRect(); const parentRect = this.props.parentActions.getComposerBoundingRect();
const selfRect = editableNode.getBoundingClientRect(); const selfRect = editableNode.getBoundingClientRect();
return Math.abs(parentRect.bottom - selfRect.bottom) <= 250; return Math.abs(parentRect.bottom - selfRect.bottom) <= 250;
@ -246,19 +247,17 @@ class ComposerEditor extends Component {
rect = DOMUtils.getSelectionRectFromDOM(selection); rect = DOMUtils.getSelectionRectFromDOM(selection);
} }
if (rect) { if (rect) {
this.props.parentActions.scrollTo({rect}); this.props.parentActions.scrollTo({ rect });
} }
} }
}; };
// Handlers // Handlers
_onExtensionsChanged = () => { _onExtensionsChanged = () => {
this.setState({extensions: ExtensionRegistry.Composer.extensions()}); this.setState({ extensions: ExtensionRegistry.Composer.extensions() });
}; };
// Renderers // Renderers
render() { render() {
@ -270,7 +269,11 @@ class ComposerEditor extends Component {
shouldAcceptDrop={this._shouldAcceptDrop} shouldAcceptDrop={this._shouldAcceptDrop}
> >
<Contenteditable <Contenteditable
ref={(cm) => { if (cm) { this._contenteditableComponent = cm; } }} ref={cm => {
if (cm) {
this._contenteditableComponent = cm;
}
}}
value={this.props.body} value={this.props.body}
onChange={this.props.onBodyChanged} onChange={this.props.onBodyChanged}
onFilePaste={this.props.onFilePaste} onFilePaste={this.props.onFilePaste}
@ -281,6 +284,6 @@ class ComposerEditor extends Component {
); );
} }
} }
ComposerEditor.containerRequired = false ComposerEditor.containerRequired = false;
export default ComposerEditor; export default ComposerEditor;

View file

@ -1,21 +1,22 @@
import React from 'react'; import React from 'react';
import {Actions} from 'nylas-exports'; import PropTypes from 'prop-types';
import {RetinaImg} from 'nylas-component-kit'; import { Actions } from 'nylas-exports';
import { RetinaImg } from 'nylas-component-kit';
import Fields from './fields'; import Fields from './fields';
export default class ComposerHeaderActions extends React.Component { export default class ComposerHeaderActions extends React.Component {
static displayName = 'ComposerHeaderActions'; static displayName = 'ComposerHeaderActions';
static propTypes = { static propTypes = {
headerMessageId: React.PropTypes.string.isRequired, headerMessageId: PropTypes.string.isRequired,
enabledFields: React.PropTypes.array.isRequired, enabledFields: PropTypes.array.isRequired,
participantsFocused: React.PropTypes.bool, participantsFocused: PropTypes.bool,
onShowAndFocusField: React.PropTypes.func.isRequired, onShowAndFocusField: PropTypes.func.isRequired,
} };
_onPopoutComposer = () => { _onPopoutComposer = () => {
Actions.composePopoutDraft(this.props.headerMessageId); Actions.composePopoutDraft(this.props.headerMessageId);
} };
render() { render() {
const items = []; const items = [];
@ -24,18 +25,24 @@ export default class ComposerHeaderActions extends React.Component {
if (!this.props.enabledFields.includes(Fields.Cc)) { if (!this.props.enabledFields.includes(Fields.Cc)) {
items.push( items.push(
<span <span
className="action show-cc" key="cc" className="action show-cc"
key="cc"
onClick={() => this.props.onShowAndFocusField(Fields.Cc)} onClick={() => this.props.onShowAndFocusField(Fields.Cc)}
>Cc</span> >
Cc
</span>
); );
} }
if (!this.props.enabledFields.includes(Fields.Bcc)) { if (!this.props.enabledFields.includes(Fields.Bcc)) {
items.push( items.push(
<span <span
className="action show-bcc" key="bcc" className="action show-bcc"
key="bcc"
onClick={() => this.props.onShowAndFocusField(Fields.Bcc)} onClick={() => this.props.onShowAndFocusField(Fields.Bcc)}
>Bcc</span> >
Bcc
</span>
); );
} }
} }
@ -43,9 +50,12 @@ export default class ComposerHeaderActions extends React.Component {
if (!this.props.enabledFields.includes(Fields.Subject)) { if (!this.props.enabledFields.includes(Fields.Subject)) {
items.push( items.push(
<span <span
className="action show-subject" key="subject" className="action show-subject"
key="subject"
onClick={() => this.props.onShowAndFocusField(Fields.Subject)} onClick={() => this.props.onShowAndFocusField(Fields.Subject)}
>Subject</span> >
Subject
</span>
); );
} }
@ -60,16 +70,12 @@ export default class ComposerHeaderActions extends React.Component {
<RetinaImg <RetinaImg
name="composer-popout.png" name="composer-popout.png"
mode={RetinaImg.Mode.ContentIsMask} mode={RetinaImg.Mode.ContentIsMask}
style={{position: "relative", top: "-2px"}} style={{ position: 'relative', top: '-2px' }}
/> />
</span> </span>
); );
} }
return ( return <div className="composer-header-actions">{items}</div>;
<div className="composer-header-actions">
{items}
</div>
);
} }
} }

View file

@ -1,7 +1,13 @@
import _ from 'underscore'; import _ from 'underscore';
import React from 'react'; import {
import ReactDOM from 'react-dom'; React,
import {Utils, DraftHelpers, Actions, AccountStore} from 'nylas-exports'; ReactDOM,
PropTypes,
Utils,
DraftHelpers,
Actions,
AccountStore,
} from 'nylas-exports';
import { import {
InjectedComponent, InjectedComponent,
KeyCommandsRegion, KeyCommandsRegion,
@ -14,36 +20,35 @@ import ComposerHeaderActions from './composer-header-actions';
import SubjectTextField from './subject-text-field'; import SubjectTextField from './subject-text-field';
import Fields from './fields'; import Fields from './fields';
const ScopedFromField = ListensToFluxStore(AccountContactField, { const ScopedFromField = ListensToFluxStore(AccountContactField, {
stores: [AccountStore], stores: [AccountStore],
getStateFromStores: (props) => { getStateFromStores: props => {
const savedOrReplyToThread = !!props.draft.threadId; const savedOrReplyToThread = !!props.draft.threadId;
if (savedOrReplyToThread) { if (savedOrReplyToThread) {
return {accounts: [AccountStore.accountForId(props.draft.accountId)]}; return { accounts: [AccountStore.accountForId(props.draft.accountId)] };
} }
return {accounts: AccountStore.accounts()} return { accounts: AccountStore.accounts() };
}, },
}); });
export default class ComposerHeader extends React.Component { export default class ComposerHeader extends React.Component {
static displayName = "ComposerHeader"; static displayName = 'ComposerHeader';
static propTypes = { static propTypes = {
draft: React.PropTypes.object.isRequired, draft: PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired, session: PropTypes.object.isRequired,
initiallyFocused: React.PropTypes.bool, initiallyFocused: PropTypes.bool,
// Subject text field injected component needs to call this function // Subject text field injected component needs to call this function
// when it is rendered with a new header component // when it is rendered with a new header component
onNewHeaderComponents: React.PropTypes.func, onNewHeaderComponents: PropTypes.func,
} };
static contextTypes = { static contextTypes = {
parentTabGroup: React.PropTypes.object, parentTabGroup: PropTypes.object,
} };
constructor(props = {}) { constructor(props = {}) {
super(props) super(props);
this._els = {}; this._els = {};
this.state = this._initialStateForDraft(this.props.draft, props); this.state = this._initialStateForDraft(this.props.draft, props);
} }
@ -66,26 +71,26 @@ export default class ComposerHeader extends React.Component {
this.showAndFocusField(Fields.To); this.showAndFocusField(Fields.To);
} }
showAndFocusField = (fieldName) => { showAndFocusField = fieldName => {
const enabledFields = _.uniq([].concat(this.state.enabledFields, [fieldName])); const enabledFields = _.uniq([].concat(this.state.enabledFields, [fieldName]));
const participantsFocused = this.state.participantsFocused || Fields.ParticipantFields.includes(fieldName); const participantsFocused =
this.state.participantsFocused || Fields.ParticipantFields.includes(fieldName);
Utils.waitFor(() => this._els[fieldName]).then(() => Utils.waitFor(() => this._els[fieldName])
this._els[fieldName].focus() .then(() => this._els[fieldName].focus())
).catch(() => { .catch(() => {});
})
this.setState({enabledFields, participantsFocused}); this.setState({ enabledFields, participantsFocused });
} };
hideField = (fieldName) => { hideField = fieldName => {
if (ReactDOM.findDOMNode(this._els[fieldName]).contains(document.activeElement)) { if (ReactDOM.findDOMNode(this._els[fieldName]).contains(document.activeElement)) {
this.context.parentTabGroup.shiftFocus(-1) this.context.parentTabGroup.shiftFocus(-1);
} }
const enabledFields = _.without(this.state.enabledFields, fieldName) const enabledFields = _.without(this.state.enabledFields, fieldName);
this.setState({enabledFields}) this.setState({ enabledFields });
} };
_ensureFilledFieldsEnabled(draft) { _ensureFilledFieldsEnabled(draft) {
let enabledFields = this.state.enabledFields; let enabledFields = this.state.enabledFields;
@ -96,7 +101,7 @@ export default class ComposerHeader extends React.Component {
enabledFields = enabledFields.concat([Fields.Bcc]); enabledFields = enabledFields.concat([Fields.Bcc]);
} }
if (enabledFields !== this.state.enabledFields) { if (enabledFields !== this.state.enabledFields) {
this.setState({enabledFields}); this.setState({ enabledFields });
} }
} }
@ -131,67 +136,65 @@ export default class ComposerHeader extends React.Component {
return false; return false;
} }
return true; return true;
} };
_onChangeParticipants = (changes) => { _onChangeParticipants = changes => {
this.props.session.changes.add(changes); this.props.session.changes.add(changes);
Actions.draftParticipantsChanged(this.props.draft.id, changes); Actions.draftParticipantsChanged(this.props.draft.id, changes);
} };
_onSubjectChange = (value) => { _onSubjectChange = value => {
this.props.session.changes.add({subject: value}); this.props.session.changes.add({ subject: value });
} };
_onFocusInParticipants = () => { _onFocusInParticipants = () => {
const fieldName = this.state.participantsLastActiveField || Fields.To; const fieldName = this.state.participantsLastActiveField || Fields.To;
Utils.waitFor(() => Utils.waitFor(() => this._els[fieldName])
this._els[fieldName] .then(() => this._els[fieldName].focus())
).then(() => .catch(() => {});
this._els[fieldName].focus()
).catch(() => {
});
this.setState({ this.setState({
participantsFocused: true, participantsFocused: true,
participantsLastActiveField: null, participantsLastActiveField: null,
}); });
} };
_onFocusOutParticipants = (lastFocusedEl) => { _onFocusOutParticipants = lastFocusedEl => {
const active = Fields.ParticipantFields.find((fieldName) => { const active = Fields.ParticipantFields.find(fieldName => {
return this._els[fieldName] ? ReactDOM.findDOMNode(this._els[fieldName]).contains(lastFocusedEl) : false return this._els[fieldName]
} ? ReactDOM.findDOMNode(this._els[fieldName]).contains(lastFocusedEl)
); : false;
});
this.setState({ this.setState({
participantsFocused: false, participantsFocused: false,
participantsLastActiveField: active, participantsLastActiveField: active,
}); });
} };
_onFocusInSubject = () => { _onFocusInSubject = () => {
this.setState({ this.setState({
subjectFocused: true, subjectFocused: true,
}); });
} };
_onFocusOutSubject = () => { _onFocusOutSubject = () => {
this.setState({ this.setState({
subjectFocused: false, subjectFocused: false,
}); });
} };
isFocused() { isFocused() {
return this.state.participantsFocused || this.state.subjectFocused; return this.state.participantsFocused || this.state.subjectFocused;
} }
_onDragCollapsedParticipants = ({isDropping}) => { _onDragCollapsedParticipants = ({ isDropping }) => {
if (isDropping) { if (isDropping) {
this.setState({ this.setState({
participantsFocused: true, participantsFocused: true,
enabledFields: [...Fields.ParticipantFields, Fields.From, Fields.Subject], enabledFields: [...Fields.ParticipantFields, Fields.From, Fields.Subject],
}) });
} }
} };
_renderParticipants = () => { _renderParticipants = () => {
let content = null; let content = null;
@ -205,7 +208,7 @@ export default class ComposerHeader extends React.Component {
bcc={this.props.draft.bcc} bcc={this.props.draft.bcc}
onDragChange={this._onDragCollapsedParticipants} onDragChange={this._onDragCollapsedParticipants}
/> />
) );
} }
// When the participants field collapses, we store the field that was last // When the participants field collapses, we store the field that was last
@ -214,7 +217,11 @@ export default class ComposerHeader extends React.Component {
return ( return (
<KeyCommandsRegion <KeyCommandsRegion
tabIndex={-1} tabIndex={-1}
ref={(el) => { if (el) { this._els.participantsContainer = el; } }} ref={el => {
if (el) {
this._els.participantsContainer = el;
}
}}
className="expanded-participants" className="expanded-participants"
onFocusIn={this._onFocusInParticipants} onFocusIn={this._onFocusInParticipants}
onFocusOut={this._onFocusOutParticipants} onFocusOut={this._onFocusOutParticipants}
@ -222,24 +229,32 @@ export default class ComposerHeader extends React.Component {
{content} {content}
</KeyCommandsRegion> </KeyCommandsRegion>
); );
} };
_renderSubject = () => { _renderSubject = () => {
if (!this.state.enabledFields.includes(Fields.Subject)) { if (!this.state.enabledFields.includes(Fields.Subject)) {
return false; return false;
} }
const {draft, session} = this.props const { draft, session } = this.props;
return ( return (
<KeyCommandsRegion <KeyCommandsRegion
tabIndex={-1} tabIndex={-1}
ref={(el) => { if (el) { this._els.subjectContainer = el; } }} ref={el => {
if (el) {
this._els.subjectContainer = el;
}
}}
onFocusIn={this._onFocusInSubject} onFocusIn={this._onFocusInSubject}
onFocusOut={this._onFocusOutSubject} onFocusOut={this._onFocusOutSubject}
> >
<InjectedComponent <InjectedComponent
ref={(el) => { if (el) { this._els[Fields.Subject] = el; } }} ref={el => {
if (el) {
this._els[Fields.Subject] = el;
}
}}
key="subject-wrap" key="subject-wrap"
matching={{role: 'Composer:SubjectTextField'}} matching={{ role: 'Composer:SubjectTextField' }}
exposedProps={{ exposedProps={{
draft, draft,
session, session,
@ -252,11 +267,11 @@ export default class ComposerHeader extends React.Component {
onComponentDidChange={this.props.onNewHeaderComponents} onComponentDidChange={this.props.onNewHeaderComponents}
/> />
</KeyCommandsRegion> </KeyCommandsRegion>
) );
} };
_renderFields = () => { _renderFields = () => {
const {to, cc, bcc, from} = this.props.draft; const { to, cc, bcc, from } = this.props.draft;
// Note: We need to physically add and remove these elements, not just hide them. // Note: We need to physically add and remove these elements, not just hide them.
// If they're hidden, shift-tab between fields breaks. // If they're hidden, shift-tab between fields breaks.
@ -264,64 +279,80 @@ export default class ComposerHeader extends React.Component {
fields.push( fields.push(
<ParticipantsTextField <ParticipantsTextField
ref={(el) => { if (el) { this._els[Fields.To] = el; } }} ref={el => {
if (el) {
this._els[Fields.To] = el;
}
}}
key="to" key="to"
field="to" field="to"
change={this._onChangeParticipants} change={this._onChangeParticipants}
className="composer-participant-field to-field" className="composer-participant-field to-field"
participants={{to, cc, bcc}} participants={{ to, cc, bcc }}
draft={this.props.draft} draft={this.props.draft}
session={this.props.session} session={this.props.session}
/> />
) );
if (this.state.enabledFields.includes(Fields.Cc)) { if (this.state.enabledFields.includes(Fields.Cc)) {
fields.push( fields.push(
<ParticipantsTextField <ParticipantsTextField
ref={(el) => { if (el) { this._els[Fields.Cc] = el; } }} ref={el => {
if (el) {
this._els[Fields.Cc] = el;
}
}}
key="cc" key="cc"
field="cc" field="cc"
change={this._onChangeParticipants} change={this._onChangeParticipants}
onEmptied={() => this.hideField(Fields.Cc)} onEmptied={() => this.hideField(Fields.Cc)}
className="composer-participant-field cc-field" className="composer-participant-field cc-field"
participants={{to, cc, bcc}} participants={{ to, cc, bcc }}
draft={this.props.draft} draft={this.props.draft}
session={this.props.session} session={this.props.session}
/> />
) );
} }
if (this.state.enabledFields.includes(Fields.Bcc)) { if (this.state.enabledFields.includes(Fields.Bcc)) {
fields.push( fields.push(
<ParticipantsTextField <ParticipantsTextField
ref={(el) => { if (el) { this._els[Fields.Bcc] = el; } }} ref={el => {
if (el) {
this._els[Fields.Bcc] = el;
}
}}
key="bcc" key="bcc"
field="bcc" field="bcc"
change={this._onChangeParticipants} change={this._onChangeParticipants}
onEmptied={() => this.hideField(Fields.Bcc)} onEmptied={() => this.hideField(Fields.Bcc)}
className="composer-participant-field bcc-field" className="composer-participant-field bcc-field"
participants={{to, cc, bcc}} participants={{ to, cc, bcc }}
draft={this.props.draft} draft={this.props.draft}
session={this.props.session} session={this.props.session}
/> />
) );
} }
if (this.state.enabledFields.includes(Fields.From)) { if (this.state.enabledFields.includes(Fields.From)) {
fields.push( fields.push(
<ScopedFromField <ScopedFromField
key="from" key="from"
ref={(el) => { if (el) { this._els[Fields.From] = el; } }} ref={el => {
if (el) {
this._els[Fields.From] = el;
}
}}
value={from[0]} value={from[0]}
draft={this.props.draft} draft={this.props.draft}
session={this.props.session} session={this.props.session}
onChange={this._onChangeParticipants} onChange={this._onChangeParticipants}
/> />
) );
} }
return fields; return fields;
} };
render() { render() {
return ( return (
@ -335,6 +366,6 @@ export default class ComposerHeader extends React.Component {
{this._renderParticipants()} {this._renderParticipants()}
{this._renderSubject()} {this._renderSubject()}
</div> </div>
) );
} }
} }

View file

@ -1,13 +1,14 @@
import React from 'react' import { remote } from 'electron';
import ReactDOM from 'react-dom'
import {remote} from 'electron'
import { import {
React,
ReactDOM,
PropTypes,
Utils, Utils,
Actions, Actions,
DraftStore, DraftStore,
AttachmentStore, AttachmentStore,
DraftHelpers, DraftHelpers,
} from 'nylas-exports' } from 'nylas-exports';
import { import {
DropZone, DropZone,
RetinaImg, RetinaImg,
@ -19,12 +20,12 @@ import {
OverlaidComponents, OverlaidComponents,
ImageAttachmentItem, ImageAttachmentItem,
InjectedComponentSet, InjectedComponentSet,
} from 'nylas-component-kit' } from 'nylas-component-kit';
import ComposerEditor from './composer-editor' import ComposerEditor from './composer-editor';
import ComposerHeader from './composer-header' import ComposerHeader from './composer-header';
import SendActionButton from './send-action-button' import SendActionButton from './send-action-button';
import ActionBarPlugins from './action-bar-plugins' import ActionBarPlugins from './action-bar-plugins';
import Fields from './fields' import Fields from './fields';
// The ComposerView is a unique React component because it (currently) is a // The ComposerView is a unique React component because it (currently) is a
// singleton. Normally, the React way to do things would be to re-render the // singleton. Normally, the React way to do things would be to re-render the
@ -33,24 +34,24 @@ export default class ComposerView extends React.Component {
static displayName = 'ComposerView'; static displayName = 'ComposerView';
static propTypes = { static propTypes = {
session: React.PropTypes.object.isRequired, session: PropTypes.object.isRequired,
draft: React.PropTypes.object.isRequired, draft: PropTypes.object.isRequired,
// Sometimes when changes in the composer happens it's desirable to // Sometimes when changes in the composer happens it's desirable to
// have the parent scroll to a certain location. A parent component can // have the parent scroll to a certain location. A parent component can
// pass a callback that gets called when this composer wants to be // pass a callback that gets called when this composer wants to be
// scrolled to. // scrolled to.
scrollTo: React.PropTypes.func, scrollTo: PropTypes.func,
className: React.PropTypes.string, className: PropTypes.string,
} };
constructor(props) { constructor(props) {
super(props) super(props);
this._els = {}; this._els = {};
this.state = { this.state = {
showQuotedText: DraftHelpers.isForwardedMessage(props.draft), showQuotedText: DraftHelpers.isForwardedMessage(props.draft),
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(props.draft), showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(props.draft),
} };
} }
componentDidMount() { componentDidMount() {
@ -64,8 +65,12 @@ export default class ComposerView extends React.Component {
this._teardownForProps(); this._teardownForProps();
this._setupForProps(nextProps); this._setupForProps(nextProps);
} }
if (DraftHelpers.isForwardedMessage(this.props.draft) !== DraftHelpers.isForwardedMessage(nextProps.draft) || if (
DraftHelpers.shouldAppendQuotedText(this.props.draft) !== DraftHelpers.shouldAppendQuotedText(nextProps.draft)) { DraftHelpers.isForwardedMessage(this.props.draft) !==
DraftHelpers.isForwardedMessage(nextProps.draft) ||
DraftHelpers.shouldAppendQuotedText(this.props.draft) !==
DraftHelpers.shouldAppendQuotedText(nextProps.draft)
) {
this.setState({ this.setState({
showQuotedText: DraftHelpers.isForwardedMessage(nextProps.draft), showQuotedText: DraftHelpers.isForwardedMessage(nextProps.draft),
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(nextProps.draft), showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(nextProps.draft),
@ -96,13 +101,13 @@ export default class ComposerView extends React.Component {
'composer:show-and-focus-bcc': () => this._els.header.showAndFocusField(Fields.Bcc), 'composer:show-and-focus-bcc': () => this._els.header.showAndFocusField(Fields.Bcc),
'composer:show-and-focus-cc': () => this._els.header.showAndFocusField(Fields.Cc), 'composer:show-and-focus-cc': () => this._els.header.showAndFocusField(Fields.Cc),
'composer:focus-to': () => this._els.header.showAndFocusField(Fields.To), 'composer:focus-to': () => this._els.header.showAndFocusField(Fields.To),
"composer:show-and-focus-from": () => {}, 'composer:show-and-focus-from': () => {},
"core:undo": (event) => { 'core:undo': event => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.props.session.undo(); this.props.session.undo();
}, },
"core:redo": (event) => { 'core:redo': event => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.props.session.redo(); this.props.session.redo();
@ -110,7 +115,7 @@ export default class ComposerView extends React.Component {
}; };
} }
_setupForProps({draft, session}) { _setupForProps({ draft, session }) {
this.setState({ this.setState({
showQuotedText: DraftHelpers.isForwardedMessage(draft), showQuotedText: DraftHelpers.isForwardedMessage(draft),
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(draft), showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(draft),
@ -128,13 +133,13 @@ export default class ComposerView extends React.Component {
return this._els[Fields.Body].getPreviousSelection(); return this._els[Fields.Body].getPreviousSelection();
} }
return null; return null;
} };
session._composerViewSelectionRestore = (selection) => { session._composerViewSelectionRestore = selection => {
this._els[Fields.Body].setSelection(selection); this._els[Fields.Body].setSelection(selection);
} };
draft.files.forEach((file) => { draft.files.forEach(file => {
if (Utils.shouldDisplayAsImage(file)) { if (Utils.shouldDisplayAsImage(file)) {
Actions.fetchFile(file); Actions.fetchFile(file);
} }
@ -148,15 +153,19 @@ export default class ComposerView extends React.Component {
} }
} }
_setSREl = (el) => { _setSREl = el => {
this._els.scrollregion = el; this._els.scrollregion = el;
} };
_renderContentScrollRegion() { _renderContentScrollRegion() {
if (NylasEnv.isComposerWindow()) { if (NylasEnv.isComposerWindow()) {
return ( return (
<ScrollRegion <ScrollRegion
className="compose-body-scroll" className="compose-body-scroll"
ref={(el) => { if (el) { this._els.scrollregion = el; } }} ref={el => {
if (el) {
this._els.scrollregion = el;
}
}}
> >
{this._renderContent()} {this._renderContent()}
</ScrollRegion> </ScrollRegion>
@ -167,15 +176,19 @@ export default class ComposerView extends React.Component {
_onNewHeaderComponents = () => { _onNewHeaderComponents = () => {
if (this._els.header) { if (this._els.header) {
this.focus() this.focus();
} }
} };
_renderContent() { _renderContent() {
return ( return (
<div className="composer-centered"> <div className="composer-centered">
<ComposerHeader <ComposerHeader
ref={(el) => { if (el) { this._els.header = el; } }} ref={el => {
if (el) {
this._els.header = el;
}
}}
draft={this.props.draft} draft={this.props.draft}
session={this.props.session} session={this.props.session}
initiallyFocused={this.props.draft.to.length === 0} initiallyFocused={this.props.draft.to.length === 0}
@ -183,7 +196,11 @@ export default class ComposerView extends React.Component {
/> />
<div <div
className="compose-body" className="compose-body"
ref={(el) => { if (el) { this._els.composeBody = el; } }} ref={el => {
if (el) {
this._els.composeBody = el;
}
}}
onMouseUp={this._onMouseUpComposerBody} onMouseUp={this._onMouseUpComposerBody}
onMouseDown={this._onMouseDownComposerBody} onMouseDown={this._onMouseDownComposerBody}
> >
@ -198,15 +215,17 @@ export default class ComposerView extends React.Component {
const exposedProps = { const exposedProps = {
draft: this.props.draft, draft: this.props.draft,
session: this.props.session, session: this.props.session,
} };
return ( return (
<div <div
ref={(el) => { if (el) { this._els.composerBodyWrap = el; } }} ref={el => {
if (el) {
this._els.composerBodyWrap = el;
}
}}
className="composer-body-wrap" className="composer-body-wrap"
> >
<OverlaidComponents exposedProps={exposedProps}> <OverlaidComponents exposedProps={exposedProps}>{this._renderEditor()}</OverlaidComponents>
{this._renderEditor()}
</OverlaidComponents>
{this._renderQuotedTextControl()} {this._renderQuotedTextControl()}
{this._renderAttachments()} {this._renderAttachments()}
</div> </div>
@ -227,9 +246,13 @@ export default class ComposerView extends React.Component {
return ( return (
<InjectedComponent <InjectedComponent
ref={(el) => { if (el) { this._els[Fields.Body] = el; } }} ref={el => {
if (el) {
this._els[Fields.Body] = el;
}
}}
className="body-field" className="body-field"
matching={{role: "Composer:Editor"}} matching={{ role: 'Composer:Editor' }}
fallback={ComposerEditor} fallback={ComposerEditor}
requiredMethods={[ requiredMethods={[
'focus', 'focus',
@ -248,8 +271,8 @@ export default class ComposerView extends React.Component {
// component. We provide it our boundingClientRect so it can calculate // component. We provide it our boundingClientRect so it can calculate
// this value. // this value.
_getComposerBoundingRect = () => { _getComposerBoundingRect = () => {
return ReactDOM.findDOMNode(this._els.composerWrap).getBoundingClientRect() return ReactDOM.findDOMNode(this._els.composerWrap).getBoundingClientRect();
} };
_renderQuotedTextControl() { _renderQuotedTextControl() {
if (this.state.showQuotedTextControl) { if (this.state.showQuotedTextControl) {
@ -270,36 +293,38 @@ export default class ComposerView extends React.Component {
} }
_onExpandQuotedText = () => { _onExpandQuotedText = () => {
this.setState({ this.setState(
showQuotedText: true, {
showQuotedTextControl: false, showQuotedText: true,
}, () => { showQuotedTextControl: false,
DraftHelpers.appendQuotedTextToDraft(this.props.draft) },
.then((draftWithQuotedText) => { () => {
this.props.session.changes.add({ DraftHelpers.appendQuotedTextToDraft(this.props.draft).then(draftWithQuotedText => {
body: `${draftWithQuotedText.body}<div id="n1-quoted-text-marker" />`, this.props.session.changes.add({
}) body: `${draftWithQuotedText.body}<div id="n1-quoted-text-marker" />`,
}) });
}) });
} }
);
};
_onRemoveQuotedText = (event) => { _onRemoveQuotedText = event => {
event.stopPropagation() event.stopPropagation();
const {session, draft} = this.props const { session, draft } = this.props;
session.changes.add({ session.changes.add({
body: `${draft.body}<div id="n1-quoted-text-marker" />`, body: `${draft.body}<div id="n1-quoted-text-marker" />`,
}) });
this.setState({ this.setState({
showQuotedText: false, showQuotedText: false,
showQuotedTextControl: false, showQuotedTextControl: false,
}) });
} };
_renderFooterRegions() { _renderFooterRegions() {
return ( return (
<div className="composer-footer-region"> <div className="composer-footer-region">
<InjectedComponentSet <InjectedComponentSet
matching={{role: "Composer:Footer"}} matching={{ role: 'Composer:Footer' }}
exposedProps={{ exposedProps={{
draft: this.props.draft, draft: this.props.draft,
threadId: this.props.draft.threadId, threadId: this.props.draft.threadId,
@ -313,11 +338,11 @@ export default class ComposerView extends React.Component {
} }
_renderAttachments() { _renderAttachments() {
const {files, headerMessageId} = this.props.draft; const { files, headerMessageId } = this.props.draft;
const nonImageFiles = files const nonImageFiles = files
.filter(f => !Utils.shouldDisplayAsImage(f)) .filter(f => !Utils.shouldDisplayAsImage(f))
.map((file) => .map(file => (
<AttachmentItem <AttachmentItem
key={file.id} key={file.id}
className="file-upload" className="file-upload"
@ -327,11 +352,11 @@ export default class ComposerView extends React.Component {
fileIconName={`file-${file.extension}.png`} fileIconName={`file-${file.extension}.png`}
onRemoveAttachment={() => Actions.removeAttachment(headerMessageId, file)} onRemoveAttachment={() => Actions.removeAttachment(headerMessageId, file)}
/> />
); ));
const imageFiles = files const imageFiles = files
.filter(f => Utils.shouldDisplayAsImage(f)) .filter(f => Utils.shouldDisplayAsImage(f))
.filter(f => !f.contentId) .filter(f => !f.contentId)
.map((file) => .map(file => (
<ImageAttachmentItem <ImageAttachmentItem
key={file.id} key={file.id}
className="file-upload" className="file-upload"
@ -340,19 +365,15 @@ export default class ComposerView extends React.Component {
displayName={file.filename} displayName={file.filename}
onRemoveAttachment={() => Actions.removeAttachment(headerMessageId, file)} onRemoveAttachment={() => Actions.removeAttachment(headerMessageId, file)}
/> />
); ));
return ( return <div className="attachments-area">{nonImageFiles.concat(imageFiles)}</div>;
<div className="attachments-area">
{nonImageFiles.concat(imageFiles)}
</div>
);
} }
_renderActionsWorkspaceRegion() { _renderActionsWorkspaceRegion() {
return ( return (
<InjectedComponentSet <InjectedComponentSet
matching={{role: "Composer:ActionBarWorkspace"}} matching={{ role: 'Composer:ActionBarWorkspace' }}
exposedProps={{ exposedProps={{
draft: this.props.draft, draft: this.props.draft,
threadId: this.props.draft.threadId, threadId: this.props.draft.threadId,
@ -360,7 +381,7 @@ export default class ComposerView extends React.Component {
session: this.props.session, session: this.props.session,
}} }}
/> />
) );
} }
_renderActionsRegion() { _renderActionsRegion() {
@ -375,7 +396,7 @@ export default class ComposerView extends React.Component {
<button <button
tabIndex={-1} tabIndex={-1}
className="btn btn-toolbar btn-trash" className="btn btn-toolbar btn-trash"
style={{order: 100}} style={{ order: 100 }}
title="Delete draft" title="Delete draft"
onClick={this._onDestroyDraft} onClick={this._onDestroyDraft}
> >
@ -385,25 +406,26 @@ export default class ComposerView extends React.Component {
<button <button
tabIndex={-1} tabIndex={-1}
className="btn btn-toolbar btn-attach" className="btn btn-toolbar btn-attach"
style={{order: 50}} style={{ order: 50 }}
title="Attach file" title="Attach file"
onClick={this._onSelectAttachment} onClick={this._onSelectAttachment}
> >
<RetinaImg name="icon-composer-attachment.png" mode={RetinaImg.Mode.ContentIsMask} /> <RetinaImg name="icon-composer-attachment.png" mode={RetinaImg.Mode.ContentIsMask} />
</button> </button>
<div style={{order: 0, flex: 1}} /> <div style={{ order: 0, flex: 1 }} />
<InjectedComponent <InjectedComponent
ref={(el) => { if (el) { this._els.sendActionButton = el; } }} ref={el => {
if (el) {
this._els.sendActionButton = el;
}
}}
tabIndex={-1} tabIndex={-1}
style={{order: -100}} style={{ order: -100 }}
matching={{role: "Composer:SendActionButton"}} matching={{ role: 'Composer:SendActionButton' }}
fallback={SendActionButton} fallback={SendActionButton}
requiredMethods={[ requiredMethods={['primarySend']}
'primarySend',
]}
exposedProps={{ exposedProps={{
draft: this.props.draft, draft: this.props.draft,
headerMessageId: this.props.draft.headerMessageId, headerMessageId: this.props.draft.headerMessageId,
@ -423,39 +445,39 @@ export default class ComposerView extends React.Component {
// separate mouseDown, mouseUp events because we need to ensure that the // separate mouseDown, mouseUp events because we need to ensure that the
// start and end target are both not in the contenteditable. This ensures // start and end target are both not in the contenteditable. This ensures
// that this behavior doesn't interfear with a click and drag selection. // that this behavior doesn't interfear with a click and drag selection.
_onMouseDownComposerBody = (event) => { _onMouseDownComposerBody = event => {
if (ReactDOM.findDOMNode(this._els[Fields.Body]).contains(event.target)) { if (ReactDOM.findDOMNode(this._els[Fields.Body]).contains(event.target)) {
this._mouseDownTarget = null; this._mouseDownTarget = null;
} else { } else {
this._mouseDownTarget = event.target; this._mouseDownTarget = event.target;
} }
} };
_inFooterRegion(el) { _inFooterRegion(el) {
return el.closest && el.closest(".composer-footer-region, .overlaid-components") return el.closest && el.closest('.composer-footer-region, .overlaid-components');
} }
_onMouseUpComposerBody = (event) => { _onMouseUpComposerBody = event => {
if (event.target === this._mouseDownTarget && !this._inFooterRegion(event.target)) { if (event.target === this._mouseDownTarget && !this._inFooterRegion(event.target)) {
// We don't set state directly here because we want the native // We don't set state directly here because we want the native
// contenteditable focus behavior. When the contenteditable gets focused // contenteditable focus behavior. When the contenteditable gets focused
const bodyRect = ReactDOM.findDOMNode(this._els[Fields.Body]).getBoundingClientRect() const bodyRect = ReactDOM.findDOMNode(this._els[Fields.Body]).getBoundingClientRect();
if (event.pageY < bodyRect.top) { if (event.pageY < bodyRect.top) {
this._els[Fields.Body].focus() this._els[Fields.Body].focus();
} else { } else {
this._els[Fields.Body].focusAbsoluteEnd(); this._els[Fields.Body].focusAbsoluteEnd();
} }
} }
this._mouseDownTarget = null; this._mouseDownTarget = null;
} };
_onMouseMoveComposeBody = () => { _onMouseMoveComposeBody = () => {
if (this._mouseComposeBody === "down") { if (this._mouseComposeBody === 'down') {
this._mouseComposeBody = "move"; this._mouseComposeBody = 'move';
} }
} };
_shouldAcceptDrop = (event) => { _shouldAcceptDrop = event => {
// Ensure that you can't pick up a file and drop it on the same draft // Ensure that you can't pick up a file and drop it on the same draft
const nonNativeFilePath = this._nonNativeFilePathForDrop(event); const nonNativeFilePath = this._nonNativeFilePathForDrop(event);
@ -463,11 +485,11 @@ export default class ComposerView extends React.Component {
const hasNonNativeFilePath = nonNativeFilePath !== null; const hasNonNativeFilePath = nonNativeFilePath !== null;
return hasNativeFile || hasNonNativeFilePath; return hasNativeFile || hasNonNativeFilePath;
} };
_nonNativeFilePathForDrop = (event) => { _nonNativeFilePathForDrop = event => {
if (event.dataTransfer.types.includes("text/nylas-file-url")) { if (event.dataTransfer.types.includes('text/nylas-file-url')) {
const downloadURL = event.dataTransfer.getData("text/nylas-file-url"); const downloadURL = event.dataTransfer.getData('text/nylas-file-url');
const downloadFilePath = downloadURL.split('file://')[1]; const downloadFilePath = downloadURL.split('file://')[1];
if (downloadFilePath) { if (downloadFilePath) {
return downloadFilePath; return downloadFilePath;
@ -475,16 +497,16 @@ export default class ComposerView extends React.Component {
} }
// Accept drops of images from within the app // Accept drops of images from within the app
if (event.dataTransfer.types.includes("text/uri-list")) { if (event.dataTransfer.types.includes('text/uri-list')) {
const uri = event.dataTransfer.getData('text/uri-list') const uri = event.dataTransfer.getData('text/uri-list');
if (uri.indexOf('file://') === 0) { if (uri.indexOf('file://') === 0) {
return decodeURI(uri.split('file://')[1]); return decodeURI(uri.split('file://')[1]);
} }
} }
return null; return null;
} };
_onDrop = (event) => { _onDrop = event => {
// Accept drops of real files from other applications // Accept drops of real files from other applications
for (const file of Array.from(event.dataTransfer.files)) { for (const file of Array.from(event.dataTransfer.files)) {
this._onFileReceived(file.path); this._onFileReceived(file.path);
@ -495,23 +517,25 @@ export default class ComposerView extends React.Component {
if (uri) { if (uri) {
this._onFileReceived(uri); this._onFileReceived(uri);
} }
} };
_onFileReceived = (filePath) => { _onFileReceived = filePath => {
// called from onDrop and onFilePaste - assume images should be inline // called from onDrop and onFilePaste - assume images should be inline
Actions.addAttachment({ Actions.addAttachment({
filePath: filePath, filePath: filePath,
headerMessageId: this.props.draft.headerMessageId, headerMessageId: this.props.draft.headerMessageId,
onCreated: (file) => { onCreated: file => {
if (Utils.shouldDisplayAsImage(file)) { if (Utils.shouldDisplayAsImage(file)) {
const {draft, session} = this.props; const { draft, session } = this.props;
const match = draft.files.find(f => f.id === file.id); const match = draft.files.find(f => f.id === file.id);
if (!match) { return; } if (!match) {
return;
}
match.contentId = Utils.generateTempId(); match.contentId = Utils.generateTempId();
session.changes.add({ session.changes.add({
files: [].concat(draft.files), files: [].concat(draft.files),
}) });
Actions.insertAttachmentIntoDraft({ Actions.insertAttachmentIntoDraft({
headerMessageId: draft.headerMessageId, headerMessageId: draft.headerMessageId,
fileId: match.id, fileId: match.id,
@ -519,12 +543,12 @@ export default class ComposerView extends React.Component {
} }
}, },
}); });
} };
_onBodyChanged = (event) => { _onBodyChanged = event => {
this.props.session.changes.add({body: event.target.value}); this.props.session.changes.add({ body: event.target.value });
return; return;
} };
_isValidDraft = (options = {}) => { _isValidDraft = (options = {}) => {
// We need to check the `DraftStore` because the `DraftStore` is // We need to check the `DraftStore` because the `DraftStore` is
@ -536,8 +560,8 @@ export default class ComposerView extends React.Component {
} }
const dialog = remote.dialog; const dialog = remote.dialog;
const {session} = this.props const { session } = this.props;
const {errors, warnings} = session.validateDraftForSending() const { errors, warnings } = session.validateDraftForSending();
if (errors.length > 0) { if (errors.length > 0) {
dialog.showMessageBox(remote.getCurrentWindow(), { dialog.showMessageBox(remote.getCurrentWindow(), {
@ -549,33 +573,34 @@ export default class ComposerView extends React.Component {
return false; return false;
} }
if ((warnings.length > 0) && (!options.force)) { if (warnings.length > 0 && !options.force) {
const response = dialog.showMessageBox(remote.getCurrentWindow(), { const response = dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning', type: 'warning',
buttons: ['Send Anyway', 'Cancel'], buttons: ['Send Anyway', 'Cancel'],
message: 'Are you sure?', message: 'Are you sure?',
detail: `Send ${warnings.join(' and ')}?`, detail: `Send ${warnings.join(' and ')}?`,
}); });
if (response === 0) { // response is button array index if (response === 0) {
return this._isValidDraft({force: true}); // response is button array index
return this._isValidDraft({ force: true });
} }
return false; return false;
} }
return true; return true;
} };
_onPrimarySend = () => { _onPrimarySend = () => {
this._els.sendActionButton.primarySend(); this._els.sendActionButton.primarySend();
} };
_onDestroyDraft = () => { _onDestroyDraft = () => {
const {draft} = this.props; const { draft } = this.props;
Actions.destroyDraft(draft); Actions.destroyDraft(draft);
} };
_onSelectAttachment = () => { _onSelectAttachment = () => {
Actions.selectAttachment({headerMessageId: this.props.draft.headerMessageId}); Actions.selectAttachment({ headerMessageId: this.props.draft.headerMessageId });
} };
render() { render() {
const dropCoverDisplay = this.state.isDropping ? 'block' : 'none'; const dropCoverDisplay = this.state.isDropping ? 'block' : 'none';
@ -584,18 +609,22 @@ export default class ComposerView extends React.Component {
<div className={this.props.className}> <div className={this.props.className}>
<KeyCommandsRegion <KeyCommandsRegion
localHandlers={this._keymapHandlers()} localHandlers={this._keymapHandlers()}
className={"message-item-white-wrap composer-outer-wrap"} className={'message-item-white-wrap composer-outer-wrap'}
ref={(el) => { if (el) { this._els.composerWrap = el; } }} ref={el => {
if (el) {
this._els.composerWrap = el;
}
}}
tabIndex="-1" tabIndex="-1"
> >
<TabGroupRegion className="composer-inner-wrap"> <TabGroupRegion className="composer-inner-wrap">
<DropZone <DropZone
className="composer-inner-wrap" className="composer-inner-wrap"
shouldAcceptDrop={this._shouldAcceptDrop} shouldAcceptDrop={this._shouldAcceptDrop}
onDragStateChange={({isDropping}) => this.setState({isDropping})} onDragStateChange={({ isDropping }) => this.setState({ isDropping })}
onDrop={this._onDrop} onDrop={this._onDrop}
> >
<div className="composer-drop-cover" style={{display: dropCoverDisplay}}> <div className="composer-drop-cover" style={{ display: dropCoverDisplay }}>
<div className="centered"> <div className="centered">
<RetinaImg <RetinaImg
name="composer-drop-to-attach.png" name="composer-drop-to-attach.png"
@ -605,9 +634,7 @@ export default class ComposerView extends React.Component {
</div> </div>
</div> </div>
<div className="composer-content-wrap"> <div className="composer-content-wrap">{this._renderContentScrollRegion()}</div>
{this._renderContentScrollRegion()}
</div>
<div className="composer-action-bar-workspace-wrap"> <div className="composer-action-bar-workspace-wrap">
{this._renderActionsWorkspaceRegion()} {this._renderActionsWorkspaceRegion()}

View file

@ -1,10 +1,10 @@
const Fields = { const Fields = {
To: "textFieldTo", To: 'textFieldTo',
Cc: "textFieldCc", Cc: 'textFieldCc',
Bcc: "textFieldBcc", Bcc: 'textFieldBcc',
From: "fromField", From: 'fromField',
Subject: "textFieldSubject", Subject: 'textFieldSubject',
Body: "contentBody", Body: 'contentBody',
}; };
Fields.ParticipantFields = [Fields.To, Fields.Cc, Fields.Bcc]; Fields.ParticipantFields = [Fields.To, Fields.Cc, Fields.Bcc];
@ -18,4 +18,4 @@ Fields.Order = {
contentBody: 6, contentBody: 6,
}; };
export default Fields export default Fields;

View file

@ -1,34 +1,35 @@
import { import { Actions, ComposerExtension } from 'nylas-exports';
Actions,
ComposerExtension,
} from 'nylas-exports'
export default class InlineImageComposerExtension extends ComposerExtension { export default class InlineImageComposerExtension extends ComposerExtension {
static editingActions() { static editingActions() {
return [{ return [
action: Actions.insertAttachmentIntoDraft, {
callback: InlineImageComposerExtension._onInsertAttachmentIntoDraft, action: Actions.insertAttachmentIntoDraft,
}, { callback: InlineImageComposerExtension._onInsertAttachmentIntoDraft,
action: Actions.removeAttachment, },
callback: InlineImageComposerExtension._onRemovedAttachment, {
}] action: Actions.removeAttachment,
callback: InlineImageComposerExtension._onRemovedAttachment,
},
];
} }
static _onRemovedAttachment({editor, actionArg}) { static _onRemovedAttachment({ editor, actionArg }) {
const file = actionArg; const file = actionArg;
const el = editor.rootNode.querySelector(`.inline-container-${file.id}`) const el = editor.rootNode.querySelector(`.inline-container-${file.id}`);
if (el) { if (el) {
el.parentNode.removeChild(el); el.parentNode.removeChild(el);
} }
} }
static _onInsertAttachmentIntoDraft({editor, actionArg}) { static _onInsertAttachmentIntoDraft({ editor, actionArg }) {
if (editor.headerMessageId === actionArg.headerMessageId) { return } if (editor.headerMessageId === actionArg.headerMessageId) {
return;
}
editor.insertCustomComponent("InlineImageUploadContainer", { editor.insertCustomComponent('InlineImageUploadContainer', {
className: `inline-container-${actionArg.fileId}`, className: `inline-container-${actionArg.fileId}`,
fileId: actionArg.fileId, fileId: actionArg.fileId,
}) });
} }
} }

View file

@ -1,10 +1,10 @@
import React, {Component} from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types' import PropTypes from 'prop-types';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import {Actions, AttachmentStore} from 'nylas-exports' import { Actions, AttachmentStore } from 'nylas-exports';
import {ImageAttachmentItem} from 'nylas-component-kit' import { ImageAttachmentItem } from 'nylas-component-kit';
export default class InlineImageUploadContainer extends Component { export default class InlineImageUploadContainer extends Component {
static displayName = 'InlineImageUploadContainer'; static displayName = 'InlineImageUploadContainer';
@ -16,11 +16,13 @@ export default class InlineImageUploadContainer extends Component {
fileId: PropTypes.string.isRequired, fileId: PropTypes.string.isRequired,
session: PropTypes.object, session: PropTypes.object,
isPreview: PropTypes.bool, isPreview: PropTypes.bool,
} };
_onGoEdit = () => { _onGoEdit = () => {
if (!this.props.session) { if (!this.props.session) {
console.warn("InlineImage editor cannot be activated, `session` prop not present. (isPreview?)") console.warn(
'InlineImage editor cannot be activated, `session` prop not present. (isPreview?)'
);
return; return;
} }
// This is just a fun temporary hack because I was jealous of Apple Mail. // This is just a fun temporary hack because I was jealous of Apple Mail.
@ -43,30 +45,39 @@ export default class InlineImageUploadContainer extends Component {
editorCanvas.style.height = `${rect.height}px`; editorCanvas.style.height = `${rect.height}px`;
editorEl.appendChild(editorCanvas); editorEl.appendChild(editorCanvas);
const editorCtx = editorCanvas.getContext("2d"); const editorCtx = editorCanvas.getContext('2d');
editorCtx.drawImage(el.querySelector('.file-preview img'), 0, 0, editorCanvas.width, editorCanvas.height); editorCtx.drawImage(
editorCtx.strokeStyle = "#df4b26"; el.querySelector('.file-preview img'),
editorCtx.lineJoin = "round"; 0,
0,
editorCanvas.width,
editorCanvas.height
);
editorCtx.strokeStyle = '#df4b26';
editorCtx.lineJoin = 'round';
editorCtx.lineWidth = 3 * window.devicePixelRatio; editorCtx.lineWidth = 3 * window.devicePixelRatio;
let penDown = false; let penDown = false;
let penXY = null; let penXY = null;
editorCanvas.addEventListener('mousedown', (event) => { editorCanvas.addEventListener('mousedown', event => {
penDown = true; penDown = true;
penXY = { penXY = {
x: event.offsetX, x: event.offsetX,
y: event.offsetY, y: event.offsetY,
} };
}); });
editorCanvas.addEventListener('mousemove', (event) => { editorCanvas.addEventListener('mousemove', event => {
if (penDown) { if (penDown) {
const nextPenXY = { const nextPenXY = {
x: event.offsetX, x: event.offsetX,
y: event.offsetY, y: event.offsetY,
} };
editorCtx.beginPath(); editorCtx.beginPath();
editorCtx.moveTo(penXY.x * window.devicePixelRatio, penXY.y * window.devicePixelRatio); editorCtx.moveTo(penXY.x * window.devicePixelRatio, penXY.y * window.devicePixelRatio);
editorCtx.lineTo(nextPenXY.x * window.devicePixelRatio, nextPenXY.y * window.devicePixelRatio); editorCtx.lineTo(
nextPenXY.x * window.devicePixelRatio,
nextPenXY.y * window.devicePixelRatio
);
editorCtx.closePath(); editorCtx.closePath();
editorCtx.stroke(); editorCtx.stroke();
penXY = nextPenXY; penXY = nextPenXY;
@ -87,22 +98,20 @@ export default class InlineImageUploadContainer extends Component {
backgroundEl.style.bottom = '0px'; backgroundEl.style.bottom = '0px';
backgroundEl.style.zIndex = 1999; backgroundEl.style.zIndex = 1999;
backgroundEl.addEventListener('click', () => { backgroundEl.addEventListener('click', () => {
editorCanvas.toBlob((blob) => { editorCanvas.toBlob(blob => {
const reader = new FileReader(); const reader = new FileReader();
reader.addEventListener('loadend', () => { reader.addEventListener('loadend', () => {
const {draft, session, fileId} = this.props; const { draft, session, fileId } = this.props;
const buffer = new Buffer(new Uint8Array(reader.result)); const buffer = new Buffer(new Uint8Array(reader.result));
const file = draft.files.find(u => const file = draft.files.find(u => u.id === fileId);
u.id === fileId
);
const filepath = AttachmentStore.pathForFile(file); const filepath = AttachmentStore.pathForFile(file);
const nextFileName = `edited-${Date.now()}.png`; const nextFileName = `edited-${Date.now()}.png`;
const nextFilePath = path.join(path.dirname(filepath), nextFileName); const nextFilePath = path.join(path.dirname(filepath), nextFileName);
fs.writeFile(nextFilePath, buffer, (err) => { fs.writeFile(nextFilePath, buffer, err => {
if (err) { if (err) {
NylasEnv.showErrorDialog(err.toString()) NylasEnv.showErrorDialog(err.toString());
return; return;
} }
const img = el.querySelector('.file-preview img'); const img = el.querySelector('.file-preview img');
@ -113,12 +122,12 @@ export default class InlineImageUploadContainer extends Component {
fs.unlink(filepath, () => {}); fs.unlink(filepath, () => {});
const nextFiles = [].concat(draft.files); const nextFiles = [].concat(draft.files);
nextFiles.forEach((f) => { nextFiles.forEach(f => {
if (f.id === file.id) { if (f.id === file.id) {
f.filename = nextFileName; f.filename = nextFileName;
} }
}); });
session.changes.add({files: nextFiles}); session.changes.add({ files: nextFiles });
}); });
}); });
reader.readAsArrayBuffer(blob); reader.readAsArrayBuffer(blob);
@ -128,21 +137,17 @@ export default class InlineImageUploadContainer extends Component {
}); });
document.body.appendChild(backgroundEl); document.body.appendChild(backgroundEl);
document.body.appendChild(editorEl); document.body.appendChild(editorEl);
} };
render() { render() {
const {draft, fileId, isPreview} = this.props; const { draft, fileId, isPreview } = this.props;
const file = draft.files.find(u => fileId === u.id); const file = draft.files.find(u => fileId === u.id);
if (!file) { if (!file) {
return ( return <span />;
<span />
);
} }
if (isPreview) { if (isPreview) {
return ( return <img src={`cid:${file.id}`} alt={file.name} />;
<img src={`cid:${file.id}`} alt={file.name} />
);
} }
return ( return (
@ -159,6 +164,6 @@ export default class InlineImageUploadContainer extends Component {
onRemoveAttachment={() => Actions.removeAttachment(draft.headerMessageId, file)} onRemoveAttachment={() => Actions.removeAttachment(draft.headerMessageId, file)}
/> />
</div> </div>
) );
} }
} }

View file

@ -10,12 +10,11 @@ import {
InflatesDraftClientId, InflatesDraftClientId,
CustomContenteditableComponents, CustomContenteditableComponents,
} from 'nylas-exports'; } from 'nylas-exports';
import {OverlaidComposerExtension} from 'nylas-component-kit' import { OverlaidComposerExtension } from 'nylas-component-kit';
import ComposeButton from './compose-button'; import ComposeButton from './compose-button';
import ComposerView from './composer-view'; import ComposerView from './composer-view';
import InlineImageComposerExtension from './inline-image-composer-extension'; import InlineImageComposerExtension from './inline-image-composer-extension';
import InlineImageUploadContainer from "./inline-image-upload-container"; import InlineImageUploadContainer from './inline-image-upload-container';
const ComposerViewForDraftClientId = InflatesDraftClientId(ComposerView); const ComposerViewForDraftClientId = InflatesDraftClientId(ComposerView);
@ -28,21 +27,23 @@ class ComposerWithWindowProps extends React.Component {
// We'll now always have windowProps by the time we construct this. // We'll now always have windowProps by the time we construct this.
const windowProps = NylasEnv.getWindowProps(); const windowProps = NylasEnv.getWindowProps();
const {draftJSON, headerMessageId} = windowProps; const { draftJSON, headerMessageId } = windowProps;
if (!draftJSON) { if (!draftJSON) {
throw new Error("Initialize popout composer windows with valid draftJSON") throw new Error('Initialize popout composer windows with valid draftJSON');
} }
const draft = new Message().fromJSON(draftJSON); const draft = new Message().fromJSON(draftJSON);
DraftStore._createSession(headerMessageId, draft); DraftStore._createSession(headerMessageId, draft);
this.state = windowProps this.state = windowProps;
} }
componentWillUnmount() { componentWillUnmount() {
if (this._usub) { this._usub() } if (this._usub) {
this._usub();
}
} }
componentDidUpdate() { componentDidUpdate() {
this._composerComponent.focus() this._composerComponent.focus();
} }
_onDraftReady = () => { _onDraftReady = () => {
@ -53,12 +54,14 @@ class ComposerWithWindowProps extends React.Component {
this._showInitialErrorDialog(this.state.errorMessage, this.state.errorDetail); this._showInitialErrorDialog(this.state.errorMessage, this.state.errorDetail);
} }
}); });
} };
render() { render() {
return ( return (
<ComposerViewForDraftClientId <ComposerViewForDraftClientId
ref={(cm) => { this._composerComponent = cm; }} ref={cm => {
this._composerComponent = cm;
}}
onDraftReady={this._onDraftReady} onDraftReady={this._onDraftReady}
headerMessageId={this.state.headerMessageId} headerMessageId={this.state.headerMessageId}
className="composer-full-window" className="composer-full-window"
@ -71,7 +74,7 @@ class ComposerWithWindowProps extends React.Component {
// don't delay the modal may come up in a state where the draft looks // don't delay the modal may come up in a state where the draft looks
// like it hasn't been restored or has been lost. // like it hasn't been restored or has been lost.
_.delay(() => { _.delay(() => {
NylasEnv.showErrorDialog({title: 'Error', message: msg}, {detail: detail}) NylasEnv.showErrorDialog({ title: 'Error', message: msg }, { detail: detail });
}, 100); }, 100);
} }
} }
@ -95,9 +98,12 @@ export function activate() {
}); });
} }
ExtensionRegistry.Composer.register(OverlaidComposerExtension, {priority: 1}) ExtensionRegistry.Composer.register(OverlaidComposerExtension, { priority: 1 });
ExtensionRegistry.Composer.register(InlineImageComposerExtension); ExtensionRegistry.Composer.register(InlineImageComposerExtension);
CustomContenteditableComponents.register("InlineImageUploadContainer", InlineImageUploadContainer); CustomContenteditableComponents.register(
'InlineImageUploadContainer',
InlineImageUploadContainer
);
} }
export function deactivate() { export function deactivate() {
@ -108,9 +114,9 @@ export function deactivate() {
ComponentRegistry.unregister(ComposerWithWindowProps); ComponentRegistry.unregister(ComposerWithWindowProps);
} }
ExtensionRegistry.Composer.unregister(OverlaidComposerExtension) ExtensionRegistry.Composer.unregister(OverlaidComposerExtension);
ExtensionRegistry.Composer.unregister(InlineImageComposerExtension); ExtensionRegistry.Composer.unregister(InlineImageComposerExtension);
CustomContenteditableComponents.unregister("InlineImageUploadContainer"); CustomContenteditableComponents.unregister('InlineImageUploadContainer');
} }
export function serialize() { export function serialize() {

View file

@ -1,18 +1,16 @@
import React from 'react' import { React, PropTypes, Actions, SendActionsStore } from 'nylas-exports';
import {Actions, SendActionsStore} from 'nylas-exports' import { Menu, RetinaImg, ButtonDropdown, ListensToFluxStore } from 'nylas-component-kit';
import {Menu, RetinaImg, ButtonDropdown, ListensToFluxStore} from 'nylas-component-kit'
class SendActionButton extends React.Component { class SendActionButton extends React.Component {
static displayName = "SendActionButton"; static displayName = 'SendActionButton';
static containerRequired = false static containerRequired = false;
static propTypes = { static propTypes = {
draft: React.PropTypes.object, draft: PropTypes.object,
isValidDraft: React.PropTypes.func, isValidDraft: PropTypes.func,
sendActions: React.PropTypes.array, sendActions: PropTypes.array,
orderedSendActions: React.PropTypes.object, orderedSendActions: PropTypes.object,
}; };
primarySend() { primarySend() {
@ -20,20 +18,20 @@ class SendActionButton extends React.Component {
} }
_onPrimaryClick = () => { _onPrimaryClick = () => {
const {orderedSendActions} = this.props const { orderedSendActions } = this.props;
const {preferred} = orderedSendActions const { preferred } = orderedSendActions;
this._onSendWithAction(preferred); this._onSendWithAction(preferred);
} };
_onSendWithAction = (sendAction) => { _onSendWithAction = sendAction => {
const {isValidDraft, draft} = this.props const { isValidDraft, draft } = this.props;
if (isValidDraft()) { if (isValidDraft()) {
Actions.sendDraft(draft.headerMessageId, sendAction.configKey) Actions.sendDraft(draft.headerMessageId, sendAction.configKey);
} }
} };
_renderSendActionItem = ({iconUrl}) => { _renderSendActionItem = ({ iconUrl }) => {
let plusHTML = ""; let plusHTML = '';
let additionalImg = false; let additionalImg = false;
if (iconUrl) { if (iconUrl) {
@ -44,18 +42,19 @@ class SendActionButton extends React.Component {
return ( return (
<span> <span>
<RetinaImg name="icon-composer-send.png" mode={RetinaImg.Mode.ContentIsMask} /> <RetinaImg name="icon-composer-send.png" mode={RetinaImg.Mode.ContentIsMask} />
<span className="text">Send{plusHTML}</span>{additionalImg} <span className="text">Send{plusHTML}</span>
{additionalImg}
</span> </span>
); );
} };
_renderSingleButton() { _renderSingleButton() {
const {sendActions} = this.props const { sendActions } = this.props;
return ( return (
<button <button
tabIndex={-1} tabIndex={-1}
className={"btn btn-toolbar btn-normal btn-emphasis btn-text btn-send"} className={'btn btn-toolbar btn-normal btn-emphasis btn-text btn-send'}
style={{order: -100}} style={{ order: -100 }}
onClick={this._onPrimaryClick} onClick={this._onPrimaryClick}
> >
{this._renderSendActionItem(sendActions[0])} {this._renderSendActionItem(sendActions[0])}
@ -64,22 +63,22 @@ class SendActionButton extends React.Component {
} }
_renderButtonDropdown() { _renderButtonDropdown() {
const {orderedSendActions} = this.props const { orderedSendActions } = this.props;
const {preferred, rest} = orderedSendActions const { preferred, rest } = orderedSendActions;
const menu = ( const menu = (
<Menu <Menu
items={rest} items={rest}
itemKey={(actionConfig) => actionConfig.configKey} itemKey={actionConfig => actionConfig.configKey}
itemContent={this._renderSendActionItem} itemContent={this._renderSendActionItem}
onSelect={this._onSendWithAction} onSelect={this._onSendWithAction}
/> />
); );
return ( return (
<ButtonDropdown <ButtonDropdown
className={"btn-send btn-emphasis btn-text"} className={'btn-send btn-emphasis btn-text'}
style={{order: -100}} style={{ order: -100 }}
primaryItem={this._renderSendActionItem(preferred)} primaryItem={this._renderSendActionItem(preferred)}
primaryTitle={preferred.title} primaryTitle={preferred.title}
primaryClick={this._onPrimaryClick} primaryClick={this._onPrimaryClick}
@ -90,7 +89,7 @@ class SendActionButton extends React.Component {
} }
render() { render() {
const {sendActions} = this.props const { sendActions } = this.props;
if (sendActions.length === 1) { if (sendActions.length === 1) {
return this._renderSingleButton(); return this._renderSingleButton();
} }
@ -101,13 +100,13 @@ class SendActionButton extends React.Component {
const EnhancedSendActionButton = ListensToFluxStore(SendActionButton, { const EnhancedSendActionButton = ListensToFluxStore(SendActionButton, {
stores: [SendActionsStore], stores: [SendActionsStore],
getStateFromStores(props) { getStateFromStores(props) {
const {draft} = props const { draft } = props;
return { return {
sendActions: SendActionsStore.availableSendActionsForDraft(draft), sendActions: SendActionsStore.availableSendActionsForDraft(draft),
orderedSendActions: SendActionsStore.orderedSendActionsForDraft(draft), orderedSendActions: SendActionsStore.orderedSendActionsForDraft(draft),
} };
}, },
}) });
// TODO this is a hack so that the send button can still expose // TODO this is a hack so that the send button can still expose
// the `primarySend` method required by the ComposerView. Ideally, this // the `primarySend` method required by the ComposerView. Ideally, this
// decorator mechanism should expose whatever instance methods are exposed // decorator mechanism should expose whatever instance methods are exposed
@ -118,11 +117,11 @@ const EnhancedSendActionButton = ListensToFluxStore(SendActionButton, {
Object.assign(EnhancedSendActionButton.prototype, { Object.assign(EnhancedSendActionButton.prototype, {
primarySend() { primarySend() {
if (this._composedComponent) { if (this._composedComponent) {
this._composedComponent.primarySend() this._composedComponent.primarySend();
} }
}, },
}) });
EnhancedSendActionButton.UndecoratedSendActionButton = SendActionButton EnhancedSendActionButton.UndecoratedSendActionButton = SendActionButton;
export default EnhancedSendActionButton export default EnhancedSendActionButton;

View file

@ -1,31 +1,33 @@
import React, {Component} from 'react' import React, { Component } from 'react';
import PropTypes from 'prop-types' import PropTypes from 'prop-types';
export default class SubjectTextField extends Component { export default class SubjectTextField extends Component {
static displayName = 'SubjectTextField' static displayName = 'SubjectTextField';
static containerRequired = false static containerRequired = false;
static propTypes = { static propTypes = {
value: PropTypes.string, value: PropTypes.string,
onSubjectChange: PropTypes.func, onSubjectChange: PropTypes.func,
} };
onInputChange = ({target: {value}}) => { onInputChange = ({ target: { value } }) => {
this.props.onSubjectChange(value) this.props.onSubjectChange(value);
} };
focus() { focus() {
this._el.focus() this._el.focus();
} }
render() { render() {
const {value} = this.props const { value } = this.props;
return ( return (
<div className="composer-subject subject-field"> <div className="composer-subject subject-field">
<input <input
ref={el => { this._el = el; }} ref={el => {
this._el = el;
}}
type="text" type="text"
name="subject" name="subject"
placeholder="Subject" placeholder="Subject"

View file

@ -2,7 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import ReactTestUtils from 'react-dom/test-utils'; import ReactTestUtils from 'react-dom/test-utils';
import {Contact, Message} from 'nylas-exports'; import { Contact, Message } from 'nylas-exports';
import ComposerHeader from '../lib/composer-header'; import ComposerHeader from '../lib/composer-header';
import Fields from '../lib/fields'; import Fields from '../lib/fields';
@ -10,24 +10,20 @@ const DRAFT_HEADER_MSG_ID = 'DRAFT_HEADER_MSG_ID';
describe('ComposerHeader', function composerHeader() { describe('ComposerHeader', function composerHeader() {
beforeEach(() => { beforeEach(() => {
this.createWithDraft = (draft) => { this.createWithDraft = draft => {
const session = { const session = {
changes: { changes: {
add: jasmine.createSpy('changes.add'), add: jasmine.createSpy('changes.add'),
}, },
}; };
this.component = ReactTestUtils.renderIntoDocument( this.component = ReactTestUtils.renderIntoDocument(
<ComposerHeader <ComposerHeader draft={draft} initiallyFocused={false} session={session} />
draft={draft} );
initiallyFocused={false}
session={session}
/>
)
}; };
advanceClock() advanceClock();
}); });
describe("showAndFocusField", () => { describe('showAndFocusField', () => {
beforeEach(() => { beforeEach(() => {
const draft = new Message({ const draft = new Message({
draft: true, draft: true,
@ -37,13 +33,22 @@ describe('ComposerHeader', function composerHeader() {
this.createWithDraft(draft); this.createWithDraft(draft);
}); });
it("should ensure the field is in enabledFields", () => { it('should ensure the field is in enabledFields', () => {
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField', 'textFieldSubject']) expect(this.component.state.enabledFields).toEqual([
'textFieldTo',
'fromField',
'textFieldSubject',
]);
this.component.showAndFocusField(Fields.Bcc); this.component.showAndFocusField(Fields.Bcc);
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField', 'textFieldSubject', 'textFieldBcc']) expect(this.component.state.enabledFields).toEqual([
'textFieldTo',
'fromField',
'textFieldSubject',
'textFieldBcc',
]);
}); });
it("should ensure participantsFocused is true if necessary", () => { it('should ensure participantsFocused is true if necessary', () => {
expect(this.component.state.participantsFocused).toEqual(false); expect(this.component.state.participantsFocused).toEqual(false);
this.component.showAndFocusField(Fields.Subject); this.component.showAndFocusField(Fields.Subject);
expect(this.component.state.participantsFocused).toEqual(false); expect(this.component.state.participantsFocused).toEqual(false);
@ -51,7 +56,7 @@ describe('ComposerHeader', function composerHeader() {
expect(this.component.state.participantsFocused).toEqual(true); expect(this.component.state.participantsFocused).toEqual(true);
}); });
it("should wait for the field to become available and then focus it", () => { it('should wait for the field to become available and then focus it', () => {
const $el = ReactDOM.findDOMNode(this.component); const $el = ReactDOM.findDOMNode(this.component);
expect($el.querySelector('.bcc-field')).toBe(null); expect($el.querySelector('.bcc-field')).toBe(null);
this.component.showAndFocusField(Fields.Bcc); this.component.showAndFocusField(Fields.Bcc);
@ -60,13 +65,17 @@ describe('ComposerHeader', function composerHeader() {
}); });
}); });
describe("hideField", () => { describe('hideField', () => {
beforeEach(() => { beforeEach(() => {
const draft = new Message({draft: true, accountId: TEST_ACCOUNT_ID, headerMessageId: DRAFT_HEADER_MSG_ID}); const draft = new Message({
draft: true,
accountId: TEST_ACCOUNT_ID,
headerMessageId: DRAFT_HEADER_MSG_ID,
});
this.createWithDraft(draft); this.createWithDraft(draft);
}); });
it("should remove the field from enabledFields", () => { it('should remove the field from enabledFields', () => {
const $el = ReactDOM.findDOMNode(this.component); const $el = ReactDOM.findDOMNode(this.component);
this.component.showAndFocusField(Fields.Bcc); this.component.showAndFocusField(Fields.Bcc);
@ -78,42 +87,80 @@ describe('ComposerHeader', function composerHeader() {
}); });
}); });
describe("initial state", () => { describe('initial state', () => {
it("should enable any fields that are populated", () => { it('should enable any fields that are populated', () => {
let draft = null; let draft = null;
draft = new Message({draft: true, accountId: TEST_ACCOUNT_ID, headerMessageId: DRAFT_HEADER_MSG_ID});
this.createWithDraft(draft);
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField', 'textFieldSubject'])
draft = new Message({ draft = new Message({
draft: true, draft: true,
cc: [new Contact({id: 'a', email: 'a'})], accountId: TEST_ACCOUNT_ID,
bcc: [new Contact({id: 'b', email: 'b'})], headerMessageId: DRAFT_HEADER_MSG_ID,
});
this.createWithDraft(draft);
expect(this.component.state.enabledFields).toEqual([
'textFieldTo',
'fromField',
'textFieldSubject',
]);
draft = new Message({
draft: true,
cc: [new Contact({ id: 'a', email: 'a' })],
bcc: [new Contact({ id: 'b', email: 'b' })],
headerMessageId: DRAFT_HEADER_MSG_ID, headerMessageId: DRAFT_HEADER_MSG_ID,
accountId: TEST_ACCOUNT_ID, accountId: TEST_ACCOUNT_ID,
}); });
this.createWithDraft(draft); this.createWithDraft(draft);
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'textFieldCc', 'textFieldBcc', 'fromField', 'textFieldSubject']) expect(this.component.state.enabledFields).toEqual([
'textFieldTo',
'textFieldCc',
'textFieldBcc',
'fromField',
'textFieldSubject',
]);
}); });
describe("subject", () => { describe('subject', () => {
it("should be enabled if it is empty", () => { it('should be enabled if it is empty', () => {
const draft = new Message({draft: true, subject: '', accountId: TEST_ACCOUNT_ID, headerMessageId: DRAFT_HEADER_MSG_ID}); const draft = new Message({
draft: true,
subject: '',
accountId: TEST_ACCOUNT_ID,
headerMessageId: DRAFT_HEADER_MSG_ID,
});
this.createWithDraft(draft); this.createWithDraft(draft);
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField', 'textFieldSubject']) expect(this.component.state.enabledFields).toEqual([
'textFieldTo',
'fromField',
'textFieldSubject',
]);
}); });
it("should be enabled if the message is a forward", () => { it('should be enabled if the message is a forward', () => {
const draft = new Message({draft: true, subject: 'Fwd: 1234', accountId: TEST_ACCOUNT_ID, headerMessageId: DRAFT_HEADER_MSG_ID}); const draft = new Message({
draft: true,
subject: 'Fwd: 1234',
accountId: TEST_ACCOUNT_ID,
headerMessageId: DRAFT_HEADER_MSG_ID,
});
this.createWithDraft(draft); this.createWithDraft(draft);
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField', 'textFieldSubject']) expect(this.component.state.enabledFields).toEqual([
'textFieldTo',
'fromField',
'textFieldSubject',
]);
}); });
it("should be hidden if the message is a reply", () => { it('should be hidden if the message is a reply', () => {
const draft = new Message({draft: true, subject: 'Re: 1234', replyToHeaderMessageId: '123', accountId: TEST_ACCOUNT_ID, headerMessageId: DRAFT_HEADER_MSG_ID}); const draft = new Message({
draft: true,
subject: 'Re: 1234',
replyToHeaderMessageId: '123',
accountId: TEST_ACCOUNT_ID,
headerMessageId: DRAFT_HEADER_MSG_ID,
});
this.createWithDraft(draft); this.createWithDraft(draft);
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField']) expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField']);
}); });
}); });
}); });

View file

@ -1,45 +1,45 @@
import React from 'react'; import React from 'react';
import {mount} from 'enzyme'; import { mount } from 'enzyme';
import {ButtonDropdown, RetinaImg} from 'nylas-component-kit'; import { ButtonDropdown, RetinaImg } from 'nylas-component-kit';
import {Actions, Message, SendActionsStore} from 'nylas-exports'; import { Actions, Message, SendActionsStore } from 'nylas-exports';
import SendActionButton from '../lib/send-action-button'; import SendActionButton from '../lib/send-action-button';
const {UndecoratedSendActionButton} = SendActionButton; const { UndecoratedSendActionButton } = SendActionButton;
const {DefaultSendAction} = SendActionsStore const { DefaultSendAction } = SendActionsStore;
const GoodSendAction = { const GoodSendAction = {
title: "Good Send Action", title: 'Good Send Action',
configKey: 'good-send-action', configKey: 'good-send-action',
isAvailableForDraft: () => true, isAvailableForDraft: () => true,
performSendAction: () => {}, performSendAction: () => {},
} };
const SecondSendAction = { const SecondSendAction = {
title: "Second Send Action", title: 'Second Send Action',
configKey: 'second-send-action', configKey: 'second-send-action',
isAvailableForDraft: () => true, isAvailableForDraft: () => true,
performSendAction: () => {}, performSendAction: () => {},
} };
const NoIconUrl = { const NoIconUrl = {
title: "No Icon", title: 'No Icon',
configKey: 'no-icon', configKey: 'no-icon',
iconUrl: null, iconUrl: null,
isAvailableForDraft: () => true, isAvailableForDraft: () => true,
performSendAction() {}, performSendAction() {},
} };
describe('SendActionButton', function describeBlock() { describe('SendActionButton', function describeBlock() {
beforeEach(() => { beforeEach(() => {
spyOn(Actions, 'sendDraft') spyOn(Actions, 'sendDraft');
this.isValidDraft = jasmine.createSpy('isValidDraft') this.isValidDraft = jasmine.createSpy('isValidDraft');
this.id = "client-23" this.id = 'client-23';
this.draft = new Message({id: this.id, draft: true, headerMessageId: 'bla'}) this.draft = new Message({ id: this.id, draft: true, headerMessageId: 'bla' });
}) });
const render = (draft, {isValid = true, sendActions = [], ordered = {}} = {}) => { const render = (draft, { isValid = true, sendActions = [], ordered = {} } = {}) => {
this.isValidDraft.andReturn(isValid) this.isValidDraft.andReturn(isValid);
return mount( return mount(
<UndecoratedSendActionButton <UndecoratedSendActionButton
draft={draft} draft={draft}
@ -50,22 +50,22 @@ describe('SendActionButton', function describeBlock() {
rest: ordered.rest || [], rest: ordered.rest || [],
}} }}
/> />
) );
} };
it("renders without error", () => { it('renders without error', () => {
const sendActionButton = render(this.draft); const sendActionButton = render(this.draft);
expect(sendActionButton.is(UndecoratedSendActionButton)).toBe(true); expect(sendActionButton.is(UndecoratedSendActionButton)).toBe(true);
}); });
it("initializes with the default and shows the standard Send option", () => { it('initializes with the default and shows the standard Send option', () => {
const sendActionButton = render(this.draft); const sendActionButton = render(this.draft);
const button = sendActionButton.find('button').first(); const button = sendActionButton.find('button').first();
expect(button.text()).toEqual('Send'); expect(button.text()).toEqual('Send');
}); });
it("is a single button when there are no send actions", () => { it('is a single button when there are no send actions', () => {
const sendActionButton = render(this.draft, {sendActions: []}); const sendActionButton = render(this.draft, { sendActions: [] });
const dropdowns = sendActionButton.find(ButtonDropdown); const dropdowns = sendActionButton.find(ButtonDropdown);
const buttons = sendActionButton.find('button'); const buttons = sendActionButton.find('button');
expect(buttons.length).toBe(1); expect(buttons.length).toBe(1);
@ -84,51 +84,51 @@ describe('SendActionButton', function describeBlock() {
expect(dropdowns.first().prop('primaryTitle')).toBe('Send'); expect(dropdowns.first().prop('primaryTitle')).toBe('Send');
}); });
it("has the correct primary item", () => { it('has the correct primary item', () => {
const sendActionButton = render(this.draft, { const sendActionButton = render(this.draft, {
sendActions: [GoodSendAction, SecondSendAction], sendActions: [GoodSendAction, SecondSendAction],
ordered: {preferred: SecondSendAction, rest: [DefaultSendAction, GoodSendAction]}, ordered: { preferred: SecondSendAction, rest: [DefaultSendAction, GoodSendAction] },
}); });
const dropdown = sendActionButton.find(ButtonDropdown).first(); const dropdown = sendActionButton.find(ButtonDropdown).first();
expect(dropdown.prop('primaryTitle')).toBe("Second Send Action"); expect(dropdown.prop('primaryTitle')).toBe('Second Send Action');
}); });
it("still renders with a null iconUrl and doesn't show the image", () => { it("still renders with a null iconUrl and doesn't show the image", () => {
const sendActionButton = render(this.draft, { const sendActionButton = render(this.draft, {
sendActions: [NoIconUrl], sendActions: [NoIconUrl],
ordered: {preferred: NoIconUrl, rest: [DefaultSendAction]}, ordered: { preferred: NoIconUrl, rest: [DefaultSendAction] },
}); });
const dropdowns = sendActionButton.find(ButtonDropdown); const dropdowns = sendActionButton.find(ButtonDropdown);
const buttons = sendActionButton.find('button'); const buttons = sendActionButton.find('button');
const icons = sendActionButton.find(RetinaImg) const icons = sendActionButton.find(RetinaImg);
expect(buttons.length).toBe(0); expect(buttons.length).toBe(0);
expect(dropdowns.length).toBe(1); expect(dropdowns.length).toBe(1);
expect(icons.length).toBe(3); expect(icons.length).toBe(3);
}); });
it("sends a draft by default if no extra actions present", () => { it('sends a draft by default if no extra actions present', () => {
const sendActionButton = render(this.draft); const sendActionButton = render(this.draft);
const button = sendActionButton.find('button').first(); const button = sendActionButton.find('button').first();
button.simulate('click') button.simulate('click');
expect(this.isValidDraft).toHaveBeenCalled(); expect(this.isValidDraft).toHaveBeenCalled();
expect(Actions.sendDraft).toHaveBeenCalledWith(this.draft.headerMessageId, 'send'); expect(Actions.sendDraft).toHaveBeenCalledWith(this.draft.headerMessageId, 'send');
}); });
it("doesn't send a draft if the isValidDraft fails", () => { it("doesn't send a draft if the isValidDraft fails", () => {
const sendActionButton = render(this.draft, {isValid: false}); const sendActionButton = render(this.draft, { isValid: false });
const button = sendActionButton.find('button').first(); const button = sendActionButton.find('button').first();
button.simulate('click') button.simulate('click');
expect(this.isValidDraft).toHaveBeenCalled(); expect(this.isValidDraft).toHaveBeenCalled();
expect(Actions.sendDraft).not.toHaveBeenCalled(); expect(Actions.sendDraft).not.toHaveBeenCalled();
}); });
it("does the preferred action when more than one action present", () => { it('does the preferred action when more than one action present', () => {
const sendActionButton = render(this.draft, { const sendActionButton = render(this.draft, {
sendActions: [GoodSendAction], sendActions: [GoodSendAction],
ordered: {preferred: GoodSendAction, rest: [DefaultSendAction]}, ordered: { preferred: GoodSendAction, rest: [DefaultSendAction] },
}); });
const button = sendActionButton.find('.primary-item').first(); const button = sendActionButton.find('.primary-item').first();
button.simulate('click') button.simulate('click');
expect(this.isValidDraft).toHaveBeenCalled(); expect(this.isValidDraft).toHaveBeenCalled();
expect(Actions.sendDraft).toHaveBeenCalledWith(this.draft.headerMessageId, 'good-send-action'); expect(Actions.sendDraft).toHaveBeenCalledWith(this.draft.headerMessageId, 'good-send-action');
}); });

View file

@ -1,4 +1,3 @@
module.exports = { module.exports = {
activate() { activate() {
// //

View file

@ -1,17 +1,17 @@
import {SoundRegistry} from 'nylas-exports'; import { SoundRegistry } from 'nylas-exports';
export function activate() { export function activate() {
// FIXME: Use the mailspring:// protocol handlers once we upgrade Electron past // FIXME: Use the mailspring:// protocol handlers once we upgrade Electron past
// v30.0 // v30.0
// See: https://github.com/atom/electron/issues/1123 // See: https://github.com/atom/electron/issues/1123
SoundRegistry.register({ SoundRegistry.register({
"send": ["internal_packages", "custom-sounds", "CUSTOM_UI_Send_v1.ogg"], send: ['internal_packages', 'custom-sounds', 'CUSTOM_UI_Send_v1.ogg'],
"confirm": ["internal_packages", "custom-sounds", "CUSTOM_UI_Confirm_v1.ogg"], confirm: ['internal_packages', 'custom-sounds', 'CUSTOM_UI_Confirm_v1.ogg'],
"hit-send": ["internal_packages", "custom-sounds", "CUSTOM_UI_HitSend_v1.ogg"], 'hit-send': ['internal_packages', 'custom-sounds', 'CUSTOM_UI_HitSend_v1.ogg'],
"new-mail": ["internal_packages", "custom-sounds", "CUSTOM_UI_NewMail_v1.ogg"], 'new-mail': ['internal_packages', 'custom-sounds', 'CUSTOM_UI_NewMail_v1.ogg'],
}); });
} }
export function deactivate() { export function deactivate() {
SoundRegistry.unregister(["send", "confirm", "hit-send", "new-mail"]); SoundRegistry.unregister(['send', 'confirm', 'hit-send', 'new-mail']);
} }

View file

@ -1,8 +1,8 @@
import React, {Component} from 'react' import React, { Component } from 'react';
import PropTypes from 'prop-types' import PropTypes from 'prop-types';
import {DateUtils} from 'nylas-exports' import { DateUtils } from 'nylas-exports';
import {Flexbox} from 'nylas-component-kit' import { Flexbox } from 'nylas-component-kit';
import SendingProgressBar from './sending-progress-bar' import SendingProgressBar from './sending-progress-bar';
export default class DraftListSendStatus extends Component { export default class DraftListSendStatus extends Component {
static displayName = 'DraftListSendStatus'; static displayName = 'DraftListSendStatus';
@ -14,17 +14,17 @@ export default class DraftListSendStatus extends Component {
static containerRequired = false; static containerRequired = false;
render() { render() {
const {draft} = this.props const { draft } = this.props;
if (draft.uploadTaskId) { if (draft.uploadTaskId) {
return ( return (
<Flexbox style={{width: 150, whiteSpace: 'nowrap'}}> <Flexbox style={{ width: 150, whiteSpace: 'nowrap' }}>
<SendingProgressBar <SendingProgressBar
style={{flex: 1, marginRight: 10}} style={{ flex: 1, marginRight: 10 }}
progress={draft.uploadProgress * 100} progress={draft.uploadProgress * 100}
/> />
</Flexbox> </Flexbox>
) );
} }
return <span className="timestamp">{DateUtils.shortTimeString(draft.date)}</span> return <span className="timestamp">{DateUtils.shortTimeString(draft.date)}</span>;
} }
} }

View file

@ -1,19 +1,18 @@
import React, {Component} from 'react' import React, { Component } from 'react';
import {ListensToObservable, MultiselectToolbar, InjectedComponentSet} from 'nylas-component-kit' import { ListensToObservable, MultiselectToolbar, InjectedComponentSet } from 'nylas-component-kit';
import PropTypes from 'prop-types' import PropTypes from 'prop-types';
import DraftListStore from './draft-list-store'
import DraftListStore from './draft-list-store';
function getObservable() { function getObservable() {
return DraftListStore.selectionObservable() return DraftListStore.selectionObservable();
} }
function getStateFromObservable(items) { function getStateFromObservable(items) {
if (!items) { if (!items) {
return {items: []} return { items: [] };
} }
return {items} return { items };
} }
class DraftListToolbar extends Component { class DraftListToolbar extends Component {
@ -24,20 +23,20 @@ class DraftListToolbar extends Component {
}; };
onClearSelection = () => { onClearSelection = () => {
DraftListStore.dataSource().selection.clear() DraftListStore.dataSource().selection.clear();
}; };
render() { render() {
const {selection} = DraftListStore.dataSource() const { selection } = DraftListStore.dataSource();
const {items} = this.props const { items } = this.props;
// Keep all of the exposed props from deprecated regions that now map to this one // Keep all of the exposed props from deprecated regions that now map to this one
const toolbarElement = ( const toolbarElement = (
<InjectedComponentSet <InjectedComponentSet
matching={{role: "DraftActionsToolbarButton"}} matching={{ role: 'DraftActionsToolbarButton' }}
exposedProps={{selection, items}} exposedProps={{ selection, items }}
/> />
) );
return ( return (
<MultiselectToolbar <MultiselectToolbar
@ -46,8 +45,8 @@ class DraftListToolbar extends Component {
toolbarElement={toolbarElement} toolbarElement={toolbarElement}
onClearSelection={this.onClearSelection} onClearSelection={this.onClearSelection}
/> />
) );
} }
} }
export default ListensToObservable(DraftListToolbar, {getObservable, getStateFromObservable}) export default ListensToObservable(DraftListToolbar, { getObservable, getStateFromObservable });

View file

@ -1,13 +1,12 @@
React = require "react"
{RetinaImg} = require 'nylas-component-kit' {RetinaImg} = require 'nylas-component-kit'
{Actions, FocusedContentStore} = require "nylas-exports" {React, PropTypes, Actions, FocusedContentStore} = require "nylas-exports"
class DraftDeleteButton extends React.Component class DraftDeleteButton extends React.Component
@displayName: 'DraftDeleteButton' @displayName: 'DraftDeleteButton'
@containerRequired: false @containerRequired: false
@propTypes: @propTypes:
selection: React.PropTypes.object.isRequired selection: PropTypes.object.isRequired
render: -> render: ->
<button style={order:-100} <button style={order:-100}

View file

@ -1,31 +1,29 @@
import {WorkspaceStore, ComponentRegistry, Actions} from 'nylas-exports' import { WorkspaceStore, ComponentRegistry, Actions } from 'nylas-exports';
import DraftList from './draft-list' import DraftList from './draft-list';
import DraftListToolbar from './draft-list-toolbar' import DraftListToolbar from './draft-list-toolbar';
import DraftListSendStatus from './draft-list-send-status' import DraftListSendStatus from './draft-list-send-status';
import {DraftDeleteButton} from "./draft-toolbar-buttons" import { DraftDeleteButton } from './draft-toolbar-buttons';
export function activate() { export function activate() {
WorkspaceStore.defineSheet( WorkspaceStore.defineSheet('Drafts', { root: true }, { list: ['RootSidebar', 'DraftList'] });
'Drafts', if (
{root: true}, NylasEnv.savedState.perspective &&
{list: ['RootSidebar', 'DraftList']} NylasEnv.savedState.perspective.type === 'DraftsMailboxPerspective'
); ) {
if (NylasEnv.savedState.perspective &&
NylasEnv.savedState.perspective.type === "DraftsMailboxPerspective") {
Actions.selectRootSheet(WorkspaceStore.Sheet.Drafts); Actions.selectRootSheet(WorkspaceStore.Sheet.Drafts);
} }
ComponentRegistry.register(DraftList, {location: WorkspaceStore.Location.DraftList}) ComponentRegistry.register(DraftList, { location: WorkspaceStore.Location.DraftList });
ComponentRegistry.register(DraftListToolbar, {location: WorkspaceStore.Location.DraftList.Toolbar}) ComponentRegistry.register(DraftListToolbar, {
ComponentRegistry.register(DraftDeleteButton, {role: 'DraftActionsToolbarButton'}) location: WorkspaceStore.Location.DraftList.Toolbar,
ComponentRegistry.register(DraftListSendStatus, {role: 'DraftList:DraftStatus'}) });
ComponentRegistry.register(DraftDeleteButton, { role: 'DraftActionsToolbarButton' });
ComponentRegistry.register(DraftListSendStatus, { role: 'DraftList:DraftStatus' });
} }
export function deactivate() { export function deactivate() {
ComponentRegistry.unregister(DraftList) ComponentRegistry.unregister(DraftList);
ComponentRegistry.unregister(DraftListToolbar) ComponentRegistry.unregister(DraftListToolbar);
ComponentRegistry.unregister(DraftDeleteButton) ComponentRegistry.unregister(DraftDeleteButton);
ComponentRegistry.unregister(DraftListSendStatus) ComponentRegistry.unregister(DraftListSendStatus);
} }

View file

@ -1,12 +1,11 @@
React = require 'react' {React, PropTypes, Actions} = require 'nylas-exports'
{Actions} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit' {RetinaImg} = require 'nylas-component-kit'
class SendingCancelButton extends React.Component class SendingCancelButton extends React.Component
@displayName: 'SendingCancelButton' @displayName: 'SendingCancelButton'
@propTypes: @propTypes:
taskId: React.PropTypes.string.isRequired taskId: PropTypes.string.isRequired
constructor: (@props) -> constructor: (@props) ->
@state = @state =

View file

@ -1,9 +1,8 @@
React = require 'react' {React, PropTypes, Utils} = require 'nylas-exports'
{Utils} = require 'nylas-exports'
class SendingProgressBar extends React.Component class SendingProgressBar extends React.Component
@propTypes: @propTypes:
progress: React.PropTypes.number.isRequired progress: PropTypes.number.isRequired
render: -> render: ->
otherProps = Utils.fastOmit(@props, Object.keys(@constructor.propTypes)) otherProps = Utils.fastOmit(@props, Object.keys(@constructor.propTypes))

View file

@ -1,8 +1,8 @@
_ = require 'underscore' _ = require 'underscore'
path = require 'path' path = require 'path'
React = require 'react'
{RetinaImg} = require 'nylas-component-kit' {RetinaImg} = require 'nylas-component-kit'
{Actions, {Actions,
React, PropTypes,
DateUtils, DateUtils,
Message, Message,
Event, Event,
@ -16,7 +16,7 @@ class EventHeader extends React.Component
@displayName: 'EventHeader' @displayName: 'EventHeader'
@propTypes: @propTypes:
message: React.PropTypes.instanceOf(Message).isRequired message: PropTypes.instanceOf(Message).isRequired
constructor: (@props) -> constructor: (@props) ->
@state = @state =

View file

@ -1,9 +1,9 @@
import {React} from 'nylas-exports'; import { React, PropTypes } from 'nylas-exports';
import GithubUserStore from "./github-user-store"; import GithubUserStore from './github-user-store';
// Small React component that renders a single Github repository // Small React component that renders a single Github repository
const GithubRepo = function GithubRepo(props) { const GithubRepo = function GithubRepo(props) {
const {repo} = props; const { repo } = props;
return ( return (
<div className="repo"> <div className="repo">
@ -11,20 +11,20 @@ const GithubRepo = function GithubRepo(props) {
<a href={repo.html_url}>{repo.full_name}</a> <a href={repo.html_url}>{repo.full_name}</a>
</div> </div>
); );
} };
GithubRepo.propTypes = { GithubRepo.propTypes = {
// This component takes a `repo` object as a prop. Listing props is optional // This component takes a `repo` object as a prop. Listing props is optional
// but enables nice React warnings when our expectations aren't met // but enables nice React warnings when our expectations aren't met
repo: React.PropTypes.object.isRequired, repo: PropTypes.object.isRequired,
}; };
// Small React component that renders the user's Github profile. // Small React component that renders the user's Github profile.
const GithubProfile = function GithubProfile(props) { const GithubProfile = function GithubProfile(props) {
const {profile} = props; const { profile } = props;
// Transform the profile's array of repos into an array of React <GithubRepo> elements // Transform the profile's array of repos into an array of React <GithubRepo> elements
const repoElements = profile.repos.map((repo) => { const repoElements = profile.repos.map(repo => {
return <GithubRepo key={repo.id} repo={repo} /> return <GithubRepo key={repo.id} repo={repo} />;
}); });
// Remember - this looks like HTML, but it's actually CJSX, which is converted into // Remember - this looks like HTML, but it's actually CJSX, which is converted into
@ -32,24 +32,28 @@ const GithubProfile = function GithubProfile(props) {
// objects here that *represent* the DOM we want. // objects here that *represent* the DOM we want.
return ( return (
<div className="profile"> <div className="profile">
<img className="logo" alt="github logo" src="mailspring://github-contact-card/assets/github.png" /> <img
className="logo"
alt="github logo"
src="mailspring://github-contact-card/assets/github.png"
/>
<a href={profile.html_url}>{profile.login}</a> <a href={profile.html_url}>{profile.login}</a>
<div>{repoElements}</div> <div>{repoElements}</div>
</div> </div>
); );
} };
GithubProfile.propTypes = { GithubProfile.propTypes = {
// This component takes a `profile` object as a prop. Listing props is optional // This component takes a `profile` object as a prop. Listing props is optional
// but enables nice React warnings when our expectations aren't met. // but enables nice React warnings when our expectations aren't met.
profile: React.PropTypes.object.isRequired, profile: PropTypes.object.isRequired,
} };
export default class GithubContactCardSection extends React.Component { export default class GithubContactCardSection extends React.Component {
static displayName = 'GithubContactCardSection'; static displayName = 'GithubContactCardSection';
static containerStyles = { static containerStyles = {
order: 10, order: 10,
} };
constructor(props) { constructor(props) {
super(props); super(props);
@ -72,27 +76,25 @@ export default class GithubContactCardSection extends React.Component {
profile: GithubUserStore.profileForFocusedContact(), profile: GithubUserStore.profileForFocusedContact(),
loading: GithubUserStore.loading(), loading: GithubUserStore.loading(),
}; };
} };
// The data vended by the GithubUserStore has changed. Calling `setState:` // The data vended by the GithubUserStore has changed. Calling `setState:`
// will cause React to re-render our view to reflect the new values. // will cause React to re-render our view to reflect the new values.
_onChange = () => { _onChange = () => {
this.setState(this._getStateFromStores()) this.setState(this._getStateFromStores());
} };
_renderInner() { _renderInner() {
// Handle various loading states by returning early // Handle various loading states by returning early
if (this.state.loading) { if (this.state.loading) {
return (<div className="pending">Loading...</div>); return <div className="pending">Loading...</div>;
} }
if (!this.state.profile) { if (!this.state.profile) {
return (<div className="pending">No Matching Profile</div>); return <div className="pending">No Matching Profile</div>;
} }
return ( return <GithubProfile profile={this.state.profile} />;
<GithubProfile profile={this.state.profile} />
);
} }
render() { render() {

View file

@ -1,6 +1,6 @@
import _ from 'underscore'; import _ from 'underscore';
import NylasStore from 'nylas-store'; import NylasStore from 'nylas-store';
import {FocusedContactsStore} from 'nylas-exports'; import { FocusedContactsStore } from 'nylas-exports';
// This package uses the Flux pattern - our Store is a small singleton that // 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 // observes other parts of the application and vends data to our React
@ -55,10 +55,10 @@ class GithubUserStore extends NylasStore {
} }
this.trigger(this); this.trigger(this);
} };
async _githubFetchProfile(email) { async _githubFetchProfile(email) {
this._loading = true this._loading = true;
try { try {
const data = await this._githubRequest(`https://api.github.com/search/users?q=${email}`); const data = await this._githubRequest(`https://api.github.com/search/users?q=${email}`);
@ -78,9 +78,11 @@ class GithubUserStore extends NylasStore {
// repositories. // repositories.
if (profile !== false) { if (profile !== false) {
profile.repos = []; profile.repos = [];
const repos = await this._githubRequest(`https://api.github.com/search/repositories?q=user:${profile.login}&sort=stars&order=desc`) const repos = await this._githubRequest(
`https://api.github.com/search/repositories?q=user:${profile.login}&sort=stars&order=desc`
);
// Sort the repositories by their stars (`-` for descending order) // Sort the repositories by their stars (`-` for descending order)
profile.repos = _.sortBy(repos.items, (repo) => -repo.stargazers_count); profile.repos = _.sortBy(repos.items, repo => -repo.stargazers_count);
// Trigger so that our React components refresh their state and display // Trigger so that our React components refresh their state and display
// the updated data. // the updated data.
this.trigger(this); this.trigger(this);
@ -99,10 +101,10 @@ class GithubUserStore extends NylasStore {
// parsed. // parsed.
async _githubRequest(url) { async _githubRequest(url) {
const headers = new Headers(); const headers = new Headers();
headers.append("User-Agent", "fetch-request"); headers.append('User-Agent', 'fetch-request');
const resp = await fetch(url, {headers}); const resp = await fetch(url, { headers });
if (!resp.ok) { if (!resp.ok) {
throw new Error("Sorry, we were unable to complete the translation request."); throw new Error('Sorry, we were unable to complete the translation request.');
} }
return resp.json(); return resp.json();
} }

View file

@ -1,8 +1,6 @@
import { import { ComponentRegistry } from 'nylas-exports';
ComponentRegistry,
} from "nylas-exports";
import GithubContactCardSection from "./github-contact-card-section"; import GithubContactCardSection from './github-contact-card-section';
/* /*
All packages must export a basic object that has at least the following 3 All packages must export a basic object that has at least the following 3
@ -24,7 +22,7 @@ export function activate() {
// This sidebar is to the right of the Message List in both split pane mode // This sidebar is to the right of the Message List in both split pane mode
// and list mode. // and list mode.
ComponentRegistry.register(GithubContactCardSection, { ComponentRegistry.register(GithubContactCardSection, {
role: "MessageListSidebar:ContactCard", role: 'MessageListSidebar:ContactCard',
}); });
} }

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