mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-20 07:16:08 +08:00
Adopt ✨ prettier ✨, upgrade ESLint
This commit is contained in:
parent
38ecc23188
commit
0f54aa11b5
73
.eslintrc
73
.eslintrc
|
@ -1,6 +1,14 @@
|
|||
{
|
||||
"parser": "babel-eslint",
|
||||
"extends": "airbnb",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 6,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"modules": true,
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"extends": ["react-app", "prettier", "prettier/react"],
|
||||
"globals": {
|
||||
"NylasEnv": false,
|
||||
"$n": false,
|
||||
|
@ -16,59 +24,20 @@
|
|||
"node": true,
|
||||
"jasmine": true
|
||||
},
|
||||
"plugins": ["prettier"],
|
||||
"rules": {
|
||||
"arrow-body-style": "off",
|
||||
"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"
|
||||
"prettier/prettier": "error"
|
||||
},
|
||||
"settings": {
|
||||
"import/core-modules": [ "nylas-exports", "nylas-component-kit", "electron", "nylas-store", "nylas-observables" ],
|
||||
"import/resolver": {"node": {"extensions": [".es6", ".jsx", ".coffee", ".json", ".cjsx", ".js"]}}
|
||||
"import/core-modules": [
|
||||
"nylas-exports",
|
||||
"nylas-component-kit",
|
||||
"electron",
|
||||
"nylas-store",
|
||||
"nylas-observables"
|
||||
],
|
||||
"import/resolver": {
|
||||
"node": { "extensions": [".es6", ".jsx", ".coffee", ".json", ".cjsx", ".js"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
5
.prettierrc
Normal file
5
.prettierrc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
/* eslint import/no-dynamic-require: 0 */
|
||||
const path = require('path');
|
||||
|
||||
module.exports = (grunt) => {
|
||||
module.exports = grunt => {
|
||||
if (!grunt.option('platform')) {
|
||||
grunt.option('platform', process.platform);
|
||||
}
|
||||
|
@ -15,17 +15,17 @@ module.exports = (grunt) => {
|
|||
const appDir = path.resolve(path.join('app'));
|
||||
const buildDir = path.join(appDir, 'build');
|
||||
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
|
||||
grunt.config.init({
|
||||
'taskHelpers': taskHelpers,
|
||||
'rootDir': path.resolve('./'),
|
||||
'buildDir': buildDir,
|
||||
'appDir': appDir,
|
||||
'classDocsOutputDir': path.join(buildDir, 'docs_src', 'classes'),
|
||||
'outputDir': path.join(appDir, 'dist'),
|
||||
'appJSON': grunt.file.readJSON(path.join(appDir, 'package.json')),
|
||||
taskHelpers: taskHelpers,
|
||||
rootDir: path.resolve('./'),
|
||||
buildDir: buildDir,
|
||||
appDir: appDir,
|
||||
classDocsOutputDir: path.join(buildDir, 'docs_src', 'classes'),
|
||||
outputDir: path.join(appDir, 'dist'),
|
||||
appJSON: grunt.file.readJSON(path.join(appDir, 'package.json')),
|
||||
'source:coffeescript': [
|
||||
'internal_packages/**/*.cjsx',
|
||||
'internal_packages/**/*.coffee',
|
||||
|
@ -59,35 +59,18 @@ module.exports = (grunt) => {
|
|||
grunt.loadTasks(tasksDir);
|
||||
grunt.file.setBase(appDir);
|
||||
|
||||
grunt.registerTask('docs', [
|
||||
'docs-build',
|
||||
'docs-render',
|
||||
]);
|
||||
grunt.registerTask('docs', ['docs-build', 'docs-render']);
|
||||
|
||||
grunt.registerTask('lint', [
|
||||
'eslint',
|
||||
'lesslint',
|
||||
'nylaslint',
|
||||
'coffeelint',
|
||||
'csslint',
|
||||
]);
|
||||
grunt.registerTask('lint', ['eslint', 'lesslint', 'nylaslint', 'coffeelint', 'csslint']);
|
||||
|
||||
if (grunt.option('platform') === 'win32') {
|
||||
grunt.registerTask("build-client", [
|
||||
"package",
|
||||
grunt.registerTask('build-client', [
|
||||
'package',
|
||||
// The Windows electron-winstaller task must be run outside of grunt
|
||||
]);
|
||||
} else if (grunt.option('platform') === 'darwin') {
|
||||
grunt.registerTask("build-client", [
|
||||
"package",
|
||||
"create-mac-zip",
|
||||
"create-mac-dmg",
|
||||
]);
|
||||
grunt.registerTask('build-client', ['package', 'create-mac-zip', 'create-mac-dmg']);
|
||||
} else if (grunt.option('platform') === 'linux') {
|
||||
grunt.registerTask("build-client", [
|
||||
"package",
|
||||
"create-deb-installer",
|
||||
"create-rpm-installer",
|
||||
]);
|
||||
grunt.registerTask('build-client', ['package', 'create-deb-installer', 'create-rpm-installer']);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
* directly from a powershell command.
|
||||
*/
|
||||
const path = require('path');
|
||||
const {createWindowsInstaller} = require('electron-winstaller');
|
||||
const { createWindowsInstaller } = require('electron-winstaller');
|
||||
|
||||
const appDir = path.join(__dirname, "..");
|
||||
const {version} = require(path.join(appDir, 'package.json'));
|
||||
const appDir = path.join(__dirname, '..');
|
||||
const { version } = require(path.join(appDir, 'package.json'));
|
||||
|
||||
const config = {
|
||||
usePackageJson: false,
|
||||
|
@ -17,23 +17,25 @@ const config = {
|
|||
iconUrl: 'http://edgehill.s3.amazonaws.com/static/mailspring.ico',
|
||||
certificateFile: process.env.CERTIFICATE_FILE,
|
||||
certificatePassword: process.env.WINDOWS_CODESIGN_KEY_PASSWORD,
|
||||
description: "Mailspring",
|
||||
description: 'Mailspring',
|
||||
version: version,
|
||||
title: "mailspring",
|
||||
title: 'mailspring',
|
||||
authors: 'Foundry 376, LLC',
|
||||
setupIcon: path.join(appDir, 'build', 'resources', 'win', 'mailspring.ico'),
|
||||
setupExe: 'MailspringSetup.exe',
|
||||
exe: 'mailspring.exe',
|
||||
name: 'Mailspring',
|
||||
}
|
||||
};
|
||||
|
||||
console.log(config);
|
||||
console.log("---> Starting")
|
||||
console.log('---> Starting');
|
||||
|
||||
createWindowsInstaller(config).then(() => {
|
||||
console.log("createWindowsInstaller succeeded.")
|
||||
process.exit(0);
|
||||
}).catch((e) => {
|
||||
console.error(`createWindowsInstaller failed: ${e.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
createWindowsInstaller(config)
|
||||
.then(() => {
|
||||
console.log('createWindowsInstaller succeeded.');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(`createWindowsInstaller failed: ${e.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
@ -1,25 +1,17 @@
|
|||
module.exports = (grunt) => {
|
||||
module.exports = grunt => {
|
||||
grunt.config.merge({
|
||||
coffeelint: {
|
||||
'options': {
|
||||
options: {
|
||||
configFile: 'build/config/coffeelint.json',
|
||||
},
|
||||
'src': grunt.config('source:coffeescript'),
|
||||
'build': [
|
||||
'build/tasks/**/*.coffee',
|
||||
],
|
||||
'test': [
|
||||
'spec/**/*.cjsx',
|
||||
'spec/**/*.coffee',
|
||||
],
|
||||
'static': [
|
||||
'static/**/*.coffee',
|
||||
'static/**/*.cjsx',
|
||||
],
|
||||
'target': (grunt.option("target") ? grunt.option("target").split(" ") : []),
|
||||
src: grunt.config('source:coffeescript'),
|
||||
build: ['build/tasks/**/*.coffee'],
|
||||
test: ['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-coffeelint-cjsx');
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,25 +1,34 @@
|
|||
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() {
|
||||
const done = this.async();
|
||||
const dmgPath = path.join(grunt.config('outputDir'), "Mailspring.dmg");
|
||||
createDMG({
|
||||
appPath: path.join(grunt.config('outputDir'), "Mailspring-darwin-x64", "Mailspring.app"),
|
||||
name: "Mailspring",
|
||||
background: path.resolve(grunt.config('appDir'), 'build', 'resources', 'mac', 'DMG-background.png'),
|
||||
icon: path.resolve(grunt.config('appDir'), 'build', 'resources', 'mac', 'mailspring.icns'),
|
||||
overwrite: true,
|
||||
out: grunt.config('outputDir'),
|
||||
}, (err) => {
|
||||
if (err) {
|
||||
done(err);
|
||||
return
|
||||
}
|
||||
const dmgPath = path.join(grunt.config('outputDir'), 'Mailspring.dmg');
|
||||
createDMG(
|
||||
{
|
||||
appPath: path.join(grunt.config('outputDir'), 'Mailspring-darwin-x64', 'Mailspring.app'),
|
||||
name: 'Mailspring',
|
||||
background: path.resolve(
|
||||
grunt.config('appDir'),
|
||||
'build',
|
||||
'resources',
|
||||
'mac',
|
||||
'DMG-background.png'
|
||||
),
|
||||
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}`);
|
||||
done(null);
|
||||
})
|
||||
grunt.log.writeln(`>> Created ${dmgPath}`);
|
||||
done(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -3,33 +3,36 @@
|
|||
/* eslint quote-props: 0 */
|
||||
const path = require('path');
|
||||
|
||||
module.exports = (grunt) => {
|
||||
const {spawn} = grunt.config('taskHelpers')
|
||||
module.exports = grunt => {
|
||||
const { spawn } = grunt.config('taskHelpers');
|
||||
|
||||
grunt.registerTask('create-mac-zip', 'Zip up Mailspring', function pack() {
|
||||
const done = this.async();
|
||||
const zipPath = path.join(grunt.config('outputDir'), 'Mailspring.zip');
|
||||
|
||||
if (grunt.file.exists(zipPath)) {
|
||||
grunt.file.delete(zipPath, {force: true});
|
||||
grunt.file.delete(zipPath, { force: true });
|
||||
}
|
||||
|
||||
const orig = process.cwd();
|
||||
process.chdir(path.join(grunt.config('outputDir'), 'Mailspring-darwin-x64'));
|
||||
|
||||
spawn({
|
||||
cmd: "zip",
|
||||
args: ["-9", "-y", "-r", "-9", "-X", zipPath, 'Mailspring.app'],
|
||||
}, (error) => {
|
||||
process.chdir(orig);
|
||||
spawn(
|
||||
{
|
||||
cmd: 'zip',
|
||||
args: ['-9', '-y', '-r', '-9', '-X', zipPath, 'Mailspring.app'],
|
||||
},
|
||||
error => {
|
||||
process.chdir(orig);
|
||||
|
||||
if (error) {
|
||||
done(error);
|
||||
return;
|
||||
if (error) {
|
||||
done(error);
|
||||
return;
|
||||
}
|
||||
|
||||
grunt.log.writeln(`>> Created ${zipPath}`);
|
||||
done(null);
|
||||
}
|
||||
|
||||
grunt.log.writeln(`>> Created ${zipPath}`);
|
||||
done(null);
|
||||
});
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
module.exports = (grunt) => {
|
||||
module.exports = grunt => {
|
||||
grunt.config.merge({
|
||||
csslint: {
|
||||
options: {
|
||||
|
@ -11,9 +11,9 @@ module.exports = (grunt) => {
|
|||
'display-property-grouping': false,
|
||||
'fallback-colors': false,
|
||||
'font-sizes': false,
|
||||
'gradients': false,
|
||||
'ids': false,
|
||||
'important': false,
|
||||
gradients: false,
|
||||
ids: false,
|
||||
important: false,
|
||||
'known-properties': false,
|
||||
'outline-none': false,
|
||||
'overqualified-elements': false,
|
||||
|
@ -23,11 +23,9 @@ module.exports = (grunt) => {
|
|||
'vendor-prefix': false,
|
||||
'duplicate-properties': false, // doesn't place nice with mixins
|
||||
},
|
||||
src: [
|
||||
'static/**/*.css',
|
||||
],
|
||||
src: ['static/**/*.css'],
|
||||
},
|
||||
});
|
||||
|
||||
grunt.loadNpmTasks('grunt-contrib-csslint');
|
||||
}
|
||||
};
|
||||
|
|
|
@ -10,19 +10,24 @@ const joanna = require('joanna');
|
|||
const tello = require('tello');
|
||||
|
||||
module.exports = function(grunt) {
|
||||
|
||||
let {cp, mkdir, rm} = grunt.config('taskHelpers');
|
||||
let { cp, mkdir, rm } = grunt.config('taskHelpers');
|
||||
|
||||
let getClassesToInclude = function() {
|
||||
let modulesPath = path.resolve(__dirname, '..', '..', 'internal_packages');
|
||||
let classes = {};
|
||||
fs.traverseTreeSync(modulesPath, function(modulePath) {
|
||||
// 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)
|
||||
if (path.basename(modulePath) !== 'package.json') { return true; }
|
||||
if (!fs.isFileSync(modulePath)) { return true; }
|
||||
if (path.basename(modulePath) !== 'package.json') {
|
||||
return true;
|
||||
}
|
||||
if (!fs.isFileSync(modulePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let apiPath = path.join(path.dirname(modulePath), 'api.json');
|
||||
if (fs.isFileSync(apiPath)) {
|
||||
|
@ -42,12 +47,10 @@ module.exports = function(grunt) {
|
|||
};
|
||||
|
||||
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 classDocsOutputDir = grunt.config.get('classDocsOutputDir');
|
||||
|
||||
let cjsxOutputDir = path.join(classDocsOutputDir, 'temp-cjsx');
|
||||
|
@ -58,9 +61,7 @@ module.exports = function(grunt) {
|
|||
|
||||
let srcPath = path.resolve(__dirname, '..', '..', 'src');
|
||||
|
||||
const blacklist = ['/K2/',
|
||||
'legacy-edgehill-api',
|
||||
'edgehill-api'];
|
||||
const blacklist = ['/K2/', 'legacy-edgehill-api', 'edgehill-api'];
|
||||
|
||||
let in_blacklist = function(file) {
|
||||
for (var i = 0; i < blacklist.length; i++) {
|
||||
|
@ -72,90 +73,90 @@ module.exports = function(grunt) {
|
|||
};
|
||||
|
||||
fs.traverseTreeSync(srcPath, function(file) {
|
||||
|
||||
if (in_blacklist(file)) {
|
||||
console.log("Skipping " + file);
|
||||
console.log('Skipping ' + file);
|
||||
// Skip K2
|
||||
}
|
||||
|
||||
// Convert CJSX into coffeescript that can be read by Donna
|
||||
else if (path.extname(file) === '.cjsx') {
|
||||
} else if (path.extname(file) === '.cjsx') {
|
||||
// Convert CJSX into coffeescript that can be read by Donna
|
||||
let transformed = cjsxtransform(grunt.file.read(file));
|
||||
|
||||
// Only attempt to parse this file as documentation if it contains
|
||||
// real Coffeescript classes.
|
||||
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(cjsxOutputDir, path.basename(file).slice(0, -5 + 1 || undefined)+'coffee'), transformed);
|
||||
grunt.file.write(
|
||||
path.join(
|
||||
cjsxOutputDir,
|
||||
path.basename(file).slice(0, -5 + 1 || undefined) + 'coffee'
|
||||
),
|
||||
transformed
|
||||
);
|
||||
}
|
||||
}
|
||||
else if (path.extname(file) === '.jsx') {
|
||||
console.log('Transforming ' + file)
|
||||
} else if (path.extname(file) === '.jsx') {
|
||||
console.log('Transforming ' + file);
|
||||
|
||||
let fileStr = grunt.file.read(file);
|
||||
|
||||
let transformed = require("babel-core").transform(fileStr, {
|
||||
plugins: ["transform-react-jsx",
|
||||
"transform-class-properties"],
|
||||
presets: ['react', 'electron']
|
||||
let transformed = require('babel-core').transform(fileStr, {
|
||||
plugins: ['transform-react-jsx', 'transform-class-properties'],
|
||||
presets: ['react', 'electron'],
|
||||
});
|
||||
|
||||
grunt.file.write(path.join(cjsxOutputDir, path.basename(file).slice(0, -3 || undefined)+'js'), transformed.code);
|
||||
}
|
||||
else if (path.extname(file) == '.es6') {
|
||||
console.log(file);
|
||||
grunt.file.write(
|
||||
path.join(cjsxOutputDir, path.basename(file).slice(0, -3 || undefined) + 'js'),
|
||||
transformed.code
|
||||
);
|
||||
} else if (path.extname(file) == '.es6') {
|
||||
console.log(file);
|
||||
|
||||
let fileStr = grunt.file.read(file);
|
||||
|
||||
let transformed = require("babel-core").transform(fileStr, {
|
||||
plugins: ["transform-class-properties",
|
||||
"transform-function-bind"],
|
||||
presets: ['react', 'electron']
|
||||
|
||||
let transformed = require('babel-core').transform(fileStr, {
|
||||
plugins: ['transform-class-properties', 'transform-function-bind'],
|
||||
presets: ['react', 'electron'],
|
||||
});
|
||||
|
||||
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));
|
||||
console.log("Copying " + file + " to " + dest_path);
|
||||
console.log('Copying ' + file + ' to ' + dest_path);
|
||||
fs_extra.copySync(file, dest_path);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
grunt.log.ok('Done transforming, starting donna extraction')
|
||||
grunt.log.writeln('cjsxOutputDir: ' + cjsxOutputDir)
|
||||
grunt.log.ok('Done transforming, starting donna extraction');
|
||||
grunt.log.writeln('cjsxOutputDir: ' + cjsxOutputDir);
|
||||
|
||||
// Process coffeescript source
|
||||
let metadata = donna.generateMetadata([cjsxOutputDir]);
|
||||
grunt.log.ok('---- Done with Donna (cjsx metadata)----');
|
||||
|
||||
|
||||
// DEBUG
|
||||
// Use to check individual files
|
||||
var js_files = []
|
||||
var js_files = [];
|
||||
fs.traverseTreeSync(cjsxOutputDir, function(file) {
|
||||
if (path.extname(file) === '.js') {
|
||||
console.log('testing joanna on ' + file)
|
||||
let meta = joanna([file])
|
||||
console.log('testing tello on ' + file)
|
||||
tello.digest(meta)
|
||||
console.log('passed')
|
||||
console.log('testing joanna on ' + file);
|
||||
let meta = joanna([file]);
|
||||
console.log('testing tello on ' + file);
|
||||
tello.digest(meta);
|
||||
console.log('passed');
|
||||
}
|
||||
});
|
||||
|
||||
var js_files = []
|
||||
var js_files = [];
|
||||
fs.traverseTreeSync(cjsxOutputDir, function(file) {
|
||||
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);
|
||||
grunt.log.ok('---- Done with Joanna (jsx metadata)----');
|
||||
|
||||
|
||||
Object.assign(metadata[0].files, jsx_metadata.files);
|
||||
console.log(metadata[0]);
|
||||
|
||||
grunt.file.write('/tmp/metadata.json', JSON.stringify(metadata, null, 2));
|
||||
|
||||
|
||||
|
||||
try {
|
||||
api = tello.digest(metadata);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
console.log(e);
|
||||
console.log(e.stack);
|
||||
|
||||
console.log(metadata)
|
||||
console.log(metadata);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('---- Done with Tello ----');
|
||||
Object.assign(api.classes, getClassesToInclude());
|
||||
|
||||
console.log(api.classes)
|
||||
|
||||
console.log(api.classes);
|
||||
|
||||
api.classes = sortClasses(api.classes);
|
||||
console.log(api.classes)
|
||||
console.log(api.classes);
|
||||
|
||||
let apiJson = JSON.stringify(api, null, 2);
|
||||
let apiJsonPath = path.join(classDocsOutputDir, 'api.json');
|
||||
|
@ -197,6 +194,4 @@ module.exports = function(grunt) {
|
|||
return done();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
};
|
||||
|
|
|
@ -7,10 +7,11 @@ const _ = require('underscore');
|
|||
marked.setOptions({
|
||||
highlight(code) {
|
||||
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 = [
|
||||
'string',
|
||||
|
@ -29,22 +30,21 @@ let standardClasses = [
|
|||
'typeerror',
|
||||
'syntaxerror',
|
||||
'referenceerror',
|
||||
'rangeerror'
|
||||
'rangeerror',
|
||||
];
|
||||
|
||||
let thirdPartyClasses = {
|
||||
'react.component': 'https://facebook.github.io/react/docs/component-api.html',
|
||||
'promise': 'https://github.com/petkaantonov/bluebird/blob/master/API.md',
|
||||
'range': 'https://developer.mozilla.org/en-US/docs/Web/API/Range',
|
||||
'selection': 'https://developer.mozilla.org/en-US/docs/Web/API/Selection',
|
||||
'node': 'https://developer.mozilla.org/en-US/docs/Web/API/Node',
|
||||
promise: 'https://github.com/petkaantonov/bluebird/blob/master/API.md',
|
||||
range: 'https://developer.mozilla.org/en-US/docs/Web/API/Range',
|
||||
selection: 'https://developer.mozilla.org/en-US/docs/Web/API/Selection',
|
||||
node: 'https://developer.mozilla.org/en-US/docs/Web/API/Node',
|
||||
};
|
||||
|
||||
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 classDocsOutputDir = grunt.config.get('classDocsOutputDir');
|
||||
|
@ -53,8 +53,12 @@ module.exports = function(grunt) {
|
|||
|
||||
var processFields = function(json, fields, tasks) {
|
||||
let val;
|
||||
if (fields == null) { fields = []; }
|
||||
if (tasks == null) { tasks = []; }
|
||||
if (fields == null) {
|
||||
fields = [];
|
||||
}
|
||||
if (tasks == null) {
|
||||
tasks = [];
|
||||
}
|
||||
if (json instanceof Array) {
|
||||
return (() => {
|
||||
let result = [];
|
||||
|
@ -86,7 +90,6 @@ module.exports = function(grunt) {
|
|||
};
|
||||
|
||||
return grunt.registerTask('docs-render', 'Builds html from the API docs', function() {
|
||||
|
||||
let documentation, filename, html, match, meta, name, result, section, val;
|
||||
let classDocsOutputDir = grunt.config.get('classDocsOutputDir');
|
||||
|
||||
|
@ -96,7 +99,6 @@ module.exports = function(grunt) {
|
|||
let apiJsonPath = path.join(classDocsOutputDir, 'api.json');
|
||||
let apiJSON = JSON.parse(grunt.file.read(apiJsonPath));
|
||||
|
||||
|
||||
for (var classname in apiJSON.classes) {
|
||||
// Parse a "@Section" out of the description if one is present
|
||||
let contents = apiJSON.classes[classname];
|
||||
|
@ -111,36 +113,34 @@ module.exports = function(grunt) {
|
|||
|
||||
// Replace superClass "React" with "React.Component". The Coffeescript Lexer
|
||||
// is so bad.
|
||||
if (contents.superClass === "React") {
|
||||
contents.superClass = "React.Component";
|
||||
if (contents.superClass === 'React') {
|
||||
contents.superClass = 'React.Component';
|
||||
}
|
||||
|
||||
classes.push({
|
||||
name: classname,
|
||||
documentation: contents,
|
||||
section
|
||||
section,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Build Sidebar metadata we can hand off to each of the templates to
|
||||
// generate the sidebar
|
||||
let sidebar = {};
|
||||
for (var i = 0; i < classes.length; i++) {
|
||||
var current_class = classes[i];
|
||||
console.log(current_class.name + ' ' + current_class.section)
|
||||
var current_class = classes[i];
|
||||
console.log(current_class.name + ' ' + current_class.section);
|
||||
|
||||
if (!(current_class.section in sidebar)) {
|
||||
sidebar[current_class.section] = []
|
||||
}
|
||||
sidebar[current_class.section].push(current_class.name)
|
||||
if (!(current_class.section in sidebar)) {
|
||||
sidebar[current_class.section] = [];
|
||||
}
|
||||
sidebar[current_class.section].push(current_class.name);
|
||||
}
|
||||
|
||||
|
||||
// Prepare to render by loading handlebars partials
|
||||
let templatesPath = path.resolve(grunt.config('buildDir'), 'docs_templates');
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
@ -153,7 +153,6 @@ module.exports = function(grunt) {
|
|||
knownClassnames[classname.toLowerCase()] = val;
|
||||
}
|
||||
|
||||
|
||||
let expandTypeReferences = function(val) {
|
||||
let refRegex = /{([\w.]*)}/g;
|
||||
while ((match = refRegex.exec(val)) !== null) {
|
||||
|
@ -161,12 +160,12 @@ module.exports = function(grunt) {
|
|||
let label = match[1];
|
||||
let url = false;
|
||||
if (Array.from(standardClasses).includes(term)) {
|
||||
url = standardClassURLRoot+term;
|
||||
url = standardClassURLRoot + term;
|
||||
} else if (thirdPartyClasses[term]) {
|
||||
url = thirdPartyClasses[term];
|
||||
} else if (knownClassnames[term]) {
|
||||
url = relativePathForClass(knownClassnames[term].name);
|
||||
grunt.log.ok("Found: " + term)
|
||||
grunt.log.ok('Found: ' + term);
|
||||
} else {
|
||||
console.warn(`Cannot find class named ${term}`);
|
||||
}
|
||||
|
@ -205,28 +204,26 @@ module.exports = function(grunt) {
|
|||
let classTemplatePath = path.join(templatesPath, 'class.md');
|
||||
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,
|
||||
// expand references to types, functions and other files.
|
||||
processFields(documentation, ['description'], [expandFuncReferences]);
|
||||
processFields(documentation, ['type'], [expandTypeReferences]);
|
||||
|
||||
result = classTemplate({name, documentation, section});
|
||||
result = classTemplate({ name, documentation, section });
|
||||
grunt.file.write(outputPathFor(name + '.md'), result);
|
||||
}
|
||||
|
||||
let sidebarTemplatePath = path.join(templatesPath, 'sidebar.md');
|
||||
let sidebarTemplate = Handlebars.compile(grunt.file.read(sidebarTemplatePath));
|
||||
|
||||
grunt.file.write(outputPathFor('Sidebar.md'),
|
||||
sidebarTemplate({sidebar}));
|
||||
|
||||
grunt.file.write(outputPathFor('Sidebar.md'), sidebarTemplate({ sidebar }));
|
||||
|
||||
// Remove temp cjsx output
|
||||
return fs.removeSync(outputPathFor("temp-cjsx"));
|
||||
return fs.removeSync(outputPathFor('temp-cjsx'));
|
||||
});
|
||||
};
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
return typeof value !== 'undefined' && value !== null ? transform(value) : undefined;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const chalk = require('chalk');
|
||||
const eslint = require('eslint');
|
||||
|
||||
module.exports = (grunt) => {
|
||||
module.exports = grunt => {
|
||||
grunt.config.merge({
|
||||
eslint: {
|
||||
options: {
|
||||
|
|
|
@ -3,8 +3,8 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const _ = require('underscore');
|
||||
|
||||
module.exports = (grunt) => {
|
||||
const {spawn} = grunt.config('taskHelpers');
|
||||
module.exports = grunt => {
|
||||
const { spawn } = grunt.config('taskHelpers');
|
||||
|
||||
const outputDir = grunt.config.get('outputDir');
|
||||
const contentsDir = path.join(grunt.config('outputDir'), `mailspring-linux-${process.arch}`);
|
||||
|
@ -17,23 +17,23 @@ module.exports = (grunt) => {
|
|||
// a few helpers
|
||||
|
||||
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', ''));
|
||||
grunt.file.write(finishedPath, template(data));
|
||||
return finishedPath;
|
||||
}
|
||||
};
|
||||
|
||||
const getInstalledSize = (dir, callback) => {
|
||||
const cmd = 'du';
|
||||
const args = ['-sk', dir];
|
||||
spawn({cmd, args}, (error, {stdout}) => {
|
||||
spawn({ cmd, args }, (error, { stdout }) => {
|
||||
const installedSize = stdout.split(/\s+/).shift() || '200000'; // default to 200MB
|
||||
callback(null, installedSize);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
grunt.registerTask('create-rpm-installer', 'Create rpm package', function mkrpmf() {
|
||||
const done = this.async()
|
||||
const done = this.async();
|
||||
if (!arch) {
|
||||
done(new Error(`Unsupported arch ${process.arch}`));
|
||||
return;
|
||||
|
@ -41,7 +41,7 @@ module.exports = (grunt) => {
|
|||
|
||||
const rpmDir = path.join(grunt.config('outputDir'), 'rpm');
|
||||
if (grunt.file.exists(rpmDir)) {
|
||||
grunt.file.delete(rpmDir, {force: true});
|
||||
grunt.file.delete(rpmDir, { force: true });
|
||||
}
|
||||
|
||||
const templateData = {
|
||||
|
@ -52,19 +52,19 @@ module.exports = (grunt) => {
|
|||
linuxShareDir: '/usr/local/share/mailspring',
|
||||
linuxAssetsDir: linuxAssetsDir,
|
||||
contentsDir: contentsDir,
|
||||
}
|
||||
};
|
||||
|
||||
// This populates mailspring.spec
|
||||
const specInFilePath = path.join(linuxAssetsDir, 'redhat', 'mailspring.spec.in')
|
||||
writeFromTemplate(specInFilePath, templateData)
|
||||
const specInFilePath = path.join(linuxAssetsDir, 'redhat', 'mailspring.spec.in');
|
||||
writeFromTemplate(specInFilePath, templateData);
|
||||
|
||||
// This populates mailspring.desktop
|
||||
const desktopInFilePath = path.join(linuxAssetsDir, 'mailspring.desktop.in')
|
||||
writeFromTemplate(desktopInFilePath, templateData)
|
||||
const desktopInFilePath = path.join(linuxAssetsDir, 'mailspring.desktop.in');
|
||||
writeFromTemplate(desktopInFilePath, templateData);
|
||||
|
||||
const cmd = path.join(grunt.config('appDir'), 'script', 'mkrpm')
|
||||
const args = [outputDir, contentsDir, linuxAssetsDir]
|
||||
spawn({cmd, args}, (error) => {
|
||||
const cmd = path.join(grunt.config('appDir'), 'script', 'mkrpm');
|
||||
const args = [outputDir, contentsDir, linuxAssetsDir];
|
||||
spawn({ cmd, args }, error => {
|
||||
if (error) {
|
||||
return done(error);
|
||||
}
|
||||
|
@ -74,7 +74,7 @@ module.exports = (grunt) => {
|
|||
});
|
||||
|
||||
grunt.registerTask('create-deb-installer', 'Create debian package', function mkdebf() {
|
||||
const done = this.async()
|
||||
const done = this.async();
|
||||
if (!arch) {
|
||||
done(`Unsupported arch ${process.arch}`);
|
||||
return;
|
||||
|
@ -97,20 +97,20 @@ module.exports = (grunt) => {
|
|||
section: 'devel',
|
||||
maintainer: 'Mailspring Team <support@getmailspring.com>',
|
||||
installedSize: installedSize,
|
||||
}
|
||||
writeFromTemplate(path.join(linuxAssetsDir, 'debian', 'control.in'), data)
|
||||
writeFromTemplate(path.join(linuxAssetsDir, 'mailspring.desktop.in'), data)
|
||||
};
|
||||
writeFromTemplate(path.join(linuxAssetsDir, 'debian', 'control.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 args = [version, arch, icon, linuxAssetsDir, contentsDir, outputDir];
|
||||
spawn({cmd, args}, (spawnError) => {
|
||||
spawn({ cmd, args }, spawnError => {
|
||||
if (spawnError) {
|
||||
return done(spawnError);
|
||||
}
|
||||
grunt.log.ok(`Created ${outputDir}/mailspring-${version}-${arch}.deb`);
|
||||
return done()
|
||||
return done();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
module.exports = (grunt) => {
|
||||
module.exports = grunt => {
|
||||
grunt.config.merge({
|
||||
lesslint: {
|
||||
src: [
|
||||
'internal_packages/**/*.less',
|
||||
'dot-nylas/**/*.less',
|
||||
'static/**/*.less',
|
||||
],
|
||||
src: ['internal_packages/**/*.less', 'dot-nylas/**/*.less', 'static/**/*.less'],
|
||||
options: {
|
||||
less: {
|
||||
paths: ['static', 'static/variables/'],
|
||||
|
@ -17,4 +13,4 @@ module.exports = (grunt) => {
|
|||
|
||||
grunt.loadNpmTasks('grunt-contrib-less');
|
||||
grunt.loadNpmTasks('grunt-lesslint');
|
||||
}
|
||||
};
|
||||
|
|
|
@ -3,159 +3,175 @@ const path = require('path');
|
|||
const fs = require('fs-plus');
|
||||
|
||||
function normalizeRequirePath(requirePath, fPath) {
|
||||
if (requirePath[0] === ".") {
|
||||
if (requirePath[0] === '.') {
|
||||
return path.normalize(path.join(path.dirname(fPath), requirePath));
|
||||
}
|
||||
return requirePath;
|
||||
}
|
||||
|
||||
|
||||
module.exports = (grunt) => {
|
||||
module.exports = grunt => {
|
||||
grunt.config.merge({
|
||||
nylaslint: {
|
||||
src: grunt.config('source:coffeescript').concat(grunt.config('source:es6')),
|
||||
},
|
||||
});
|
||||
|
||||
grunt.registerMultiTask('nylaslint', 'Check requires for file extensions compiled away', function nylaslint() {
|
||||
const done = this.async();
|
||||
grunt.registerMultiTask(
|
||||
'nylaslint',
|
||||
'Check requires for file extensions compiled away',
|
||||
function nylaslint() {
|
||||
const done = this.async();
|
||||
|
||||
// Enable once path errors are fixed.
|
||||
if (process.platform === 'win32') {
|
||||
done();
|
||||
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`);
|
||||
}
|
||||
// Enable once path errors are fixed.
|
||||
if (process.platform === 'win32') {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE: Comment me in if you want to fix these files.
|
||||
// _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')
|
||||
const extensionRegex = /require ['"].*\.(coffee|cjsx|jsx|es6|es)['"]/i;
|
||||
|
||||
// Build the list of ES6 files that export things and categorize
|
||||
for (const f of fileset.src) {
|
||||
if (!esExtensions[path.extname(f)]) { continue; }
|
||||
const lookupPath = `${path.dirname(f)}/${path.basename(f, path.extname(f))}`;
|
||||
const content = fs.readFileSync(f, {encoding: 'utf8'});
|
||||
for (const fileset of this.files) {
|
||||
grunt.log.writeln(`Nylinting ${fileset.src.length} files.`);
|
||||
|
||||
if (/module.exports\s?=\s?.+/gmi.test(content)) {
|
||||
if (!f.endsWith('nylas-exports.es6')) {
|
||||
errors.push(`${f}: Don't use module.exports in ES6`);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
if (/^export/gmi.test(content)) {
|
||||
if (/^export default/gmi.test(content)) {
|
||||
esExportDefault[lookupPath] = true;
|
||||
} else {
|
||||
esExport[lookupPath] = true;
|
||||
// NOTE: Comment me in if you want to fix these files.
|
||||
// _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 f of fileset.src) {
|
||||
if (!esExtensions[path.extname(f)]) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
esNoExport[lookupPath] = true;
|
||||
}
|
||||
}
|
||||
const lookupPath = `${path.dirname(f)}/${path.basename(f, path.extname(f))}`;
|
||||
const content = fs.readFileSync(f, { encoding: 'utf8' });
|
||||
|
||||
// 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 ['"](.*?)['"]/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}`);
|
||||
if (/module.exports\s?=\s?.+/gim.test(content)) {
|
||||
if (!f.endsWith('nylas-exports.es6')) {
|
||||
errors.push(`${f}: Don't use module.exports in ES6`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now look through all coffeescript files
|
||||
// If they require things from ES6 files, ensure they're using the
|
||||
// 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_./-]*?)['"]/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}`);
|
||||
}
|
||||
if (/^export/gim.test(content)) {
|
||||
if (/^export default/gim.test(content)) {
|
||||
esExportDefault[lookupPath] = true;
|
||||
} else {
|
||||
// must be a coffeescript or core file
|
||||
if (defaultRequireRe.test(content)) {
|
||||
errors.push(`${f}: Don't ask for \`default\` from ${requirePath}`);
|
||||
esExport[lookupPath] = true;
|
||||
}
|
||||
} 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) {
|
||||
for (const err of errors) { grunt.log.error(err); }
|
||||
const error = `
|
||||
// Now look through all coffeescript files
|
||||
// If they require things from ES6 files, ensure they're using the
|
||||
// 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:
|
||||
|
||||
ISSUES WITH COFFEESCRIPT FILES:
|
||||
|
@ -180,10 +196,11 @@ module.exports = (grunt) => {
|
|||
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
|
||||
`;
|
||||
done(new Error(error));
|
||||
done(new Error(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
done(null);
|
||||
});
|
||||
}
|
||||
done(null);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/* eslint global-require: 0 *//* eslint prefer-template: 0 */
|
||||
/* eslint global-require: 0 */ /* eslint prefer-template: 0 */
|
||||
/* eslint quote-props: 0 */
|
||||
const packager = require('electron-packager');
|
||||
const path = require('path');
|
||||
|
@ -8,13 +8,13 @@ const fs = require('fs-plus');
|
|||
const coffeereact = require('coffee-react');
|
||||
const glob = require('glob');
|
||||
const babel = require('babel-core');
|
||||
const {execSync} = require('child_process');
|
||||
const symlinkedPackages = []
|
||||
const { execSync } = require('child_process');
|
||||
const symlinkedPackages = [];
|
||||
|
||||
module.exports = (grunt) => {
|
||||
module.exports = grunt => {
|
||||
const packageJSON = grunt.config('appJSON');
|
||||
const babelPath = path.join(grunt.config('rootDir'), '.babelrc')
|
||||
const babelOptions = JSON.parse(fs.readFileSync(babelPath))
|
||||
const babelPath = path.join(grunt.config('rootDir'), '.babelrc');
|
||||
const babelOptions = JSON.parse(fs.readFileSync(babelPath));
|
||||
|
||||
function runCopyPlatformSpecificResources(buildPath, electronVersion, platform, arch, callback) {
|
||||
// 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.
|
||||
*/
|
||||
function resolveRealSymlinkPaths(appDir) {
|
||||
console.log("---> Resolving symlinks");
|
||||
const dirs = [
|
||||
'internal_packages',
|
||||
'src',
|
||||
'spec',
|
||||
'node_modules',
|
||||
];
|
||||
console.log('---> Resolving symlinks');
|
||||
const dirs = ['internal_packages', 'src', 'spec', 'node_modules'];
|
||||
|
||||
dirs.forEach((dir) => {
|
||||
dirs.forEach(dir => {
|
||||
const absoluteDir = path.join(appDir, dir);
|
||||
fs.readdirSync(absoluteDir).forEach((packageName) => {
|
||||
const relativePackageDir = path.join(dir, packageName)
|
||||
const absolutePackageDir = path.join(absoluteDir, packageName)
|
||||
const realPackagePath = fs.realpathSync(absolutePackageDir).replace('/private/', '/')
|
||||
fs.readdirSync(absoluteDir).forEach(packageName => {
|
||||
const relativePackageDir = path.join(dir, packageName);
|
||||
const absolutePackageDir = path.join(absoluteDir, packageName);
|
||||
const realPackagePath = fs.realpathSync(absolutePackageDir).replace('/private/', '/');
|
||||
if (realPackagePath !== absolutePackageDir) {
|
||||
console.log(` ---> Resolving '${relativePackageDir}' to '${realPackagePath}'`)
|
||||
symlinkedPackages.push({realPackagePath, relativePackageDir})
|
||||
console.log(` ---> Resolving '${relativePackageDir}' to '${realPackagePath}'`);
|
||||
symlinkedPackages.push({ realPackagePath, relativePackageDir });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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}) => {
|
||||
const packagePath = path.join(buildPath, relativePackageDir)
|
||||
symlinkedPackages.forEach(({ realPackagePath, relativePackageDir }) => {
|
||||
const packagePath = path.join(buildPath, relativePackageDir);
|
||||
console.log(` ---> Copying ${realPackagePath} to ${packagePath}`);
|
||||
fs.removeSync(packagePath);
|
||||
fs.copySync(realPackagePath, packagePath);
|
||||
|
@ -77,13 +72,13 @@ module.exports = (grunt) => {
|
|||
}
|
||||
|
||||
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 => {
|
||||
glob.sync(pattern, {cwd: buildPath}).forEach((relPath) => {
|
||||
const coffeepath = path.join(buildPath, relPath)
|
||||
if (/(node_modules|\.js$)/.test(coffeepath)) return
|
||||
console.log(` ---> Compiling ${coffeepath.slice(coffeepath.indexOf("/app") + 4)}`)
|
||||
glob.sync(pattern, { cwd: buildPath }).forEach(relPath => {
|
||||
const coffeepath = path.join(buildPath, relPath);
|
||||
if (/(node_modules|\.js$)/.test(coffeepath)) return;
|
||||
console.log(` ---> Compiling ${coffeepath.slice(coffeepath.indexOf('/app') + 4)}`);
|
||||
const outPath = coffeepath.replace(path.extname(coffeepath), '.js');
|
||||
const res = coffeereact.compile(grunt.file.read(coffeepath), {
|
||||
bare: false,
|
||||
|
@ -95,25 +90,34 @@ module.exports = (grunt) => {
|
|||
generatedFile: path.basename(outPath),
|
||||
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);
|
||||
fs.unlinkSync(coffeepath);
|
||||
});
|
||||
});
|
||||
|
||||
grunt.config('source:es6').forEach(pattern => {
|
||||
glob.sync(pattern, {cwd: buildPath}).forEach((relPath) => {
|
||||
const es6Path = path.join(buildPath, relPath)
|
||||
if (/(node_modules|\.js$)/.test(es6Path)) return
|
||||
glob.sync(pattern, { cwd: buildPath }).forEach(relPath => {
|
||||
const es6Path = path.join(buildPath, relPath);
|
||||
if (/(node_modules|\.js$)/.test(es6Path)) return;
|
||||
const outPath = es6Path.replace(path.extname(es6Path), '.js');
|
||||
console.log(` ---> Compiling ${es6Path.slice(es6Path.indexOf("/app") + 4)}`)
|
||||
const res = babel.transformFileSync(es6Path, Object.assign(babelOptions, {
|
||||
sourceMaps: true,
|
||||
sourceRoot: '/',
|
||||
sourceMapTarget: path.relative(buildPath, outPath),
|
||||
sourceFileName: path.relative(buildPath, es6Path),
|
||||
}));
|
||||
grunt.file.write(outPath, `${res.code}\n//# sourceMappingURL=${path.basename(outPath)}.map\n`);
|
||||
console.log(` ---> Compiling ${es6Path.slice(es6Path.indexOf('/app') + 4)}`);
|
||||
const res = babel.transformFileSync(
|
||||
es6Path,
|
||||
Object.assign(babelOptions, {
|
||||
sourceMaps: true,
|
||||
sourceRoot: '/',
|
||||
sourceMapTarget: path.relative(buildPath, outPath),
|
||||
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));
|
||||
fs.unlinkSync(es6Path);
|
||||
});
|
||||
|
@ -129,21 +133,30 @@ module.exports = (grunt) => {
|
|||
packager: {
|
||||
appVersion: packageJSON.version,
|
||||
platform: platform,
|
||||
protocols: [{
|
||||
name: "Mailspring Protocol",
|
||||
schemes: ["mailspring"],
|
||||
}, {
|
||||
name: "Mailto Protocol",
|
||||
schemes: ["mailto"],
|
||||
}],
|
||||
protocols: [
|
||||
{
|
||||
name: 'Mailspring Protocol',
|
||||
schemes: ['mailspring'],
|
||||
},
|
||||
{
|
||||
name: 'Mailto Protocol',
|
||||
schemes: ['mailto'],
|
||||
},
|
||||
],
|
||||
dir: grunt.config('appDir'),
|
||||
appCategoryType: "public.app-category.business",
|
||||
appCategoryType: 'public.app-category.business',
|
||||
tmpdir: tmpdir,
|
||||
arch: {
|
||||
'win32': 'ia32',
|
||||
win32: 'ia32',
|
||||
}[platform],
|
||||
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'),
|
||||
linux: undefined,
|
||||
}[platform],
|
||||
|
@ -155,19 +168,23 @@ module.exports = (grunt) => {
|
|||
appCopyright: `Copyright (C) 2014-${new Date().getFullYear()} Foundry 376, LLC. All rights reserved.`,
|
||||
derefSymlinks: false,
|
||||
asar: {
|
||||
'unpack': "{" + [
|
||||
'mailsync',
|
||||
'mailsync.exe',
|
||||
'*.dll',
|
||||
'*.node',
|
||||
'**/vendor/**',
|
||||
'examples/**',
|
||||
'**/src/tasks/**',
|
||||
'**/node_modules/spellchecker/**',
|
||||
'**/node_modules/windows-shortcuts/**',
|
||||
].join(',') + "}",
|
||||
unpack:
|
||||
'{' +
|
||||
[
|
||||
'mailsync',
|
||||
'mailsync.exe',
|
||||
'*.dll',
|
||||
'*.node',
|
||||
'**/vendor/**',
|
||||
'examples/**',
|
||||
'**/src/tasks/**',
|
||||
'**/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
|
||||
/^\/build.*/,
|
||||
/^\/dist.*/,
|
||||
|
@ -235,7 +252,7 @@ module.exports = (grunt) => {
|
|||
// Electron.app/Contents/Info.plist. A majority of the defaults are
|
||||
// left in the Electron Info.plist file
|
||||
extendInfo: path.resolve(grunt.config('appDir'), 'build', 'resources', 'mac', 'extra.plist'),
|
||||
appBundleId: "com.mailspring.mailspring",
|
||||
appBundleId: 'com.mailspring.mailspring',
|
||||
afterCopy: [
|
||||
runCopyPlatformSpecificResources,
|
||||
runWriteCommitHashIntoPackage,
|
||||
|
@ -243,7 +260,7 @@ module.exports = (grunt) => {
|
|||
runTranspilers,
|
||||
],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
grunt.registerTask('package', 'Package Mailspring', function pack() {
|
||||
const done = this.async();
|
||||
|
@ -253,14 +270,14 @@ module.exports = (grunt) => {
|
|||
console.log(util.inspect(grunt.config.get('packager'), true, 7, true));
|
||||
|
||||
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`);
|
||||
}, 1000)
|
||||
}, 1000);
|
||||
|
||||
resolveRealSymlinkPaths(grunt.config('appDir'))
|
||||
resolveRealSymlinkPaths(grunt.config('appDir'));
|
||||
|
||||
packager(grunt.config.get('packager'), (err, appPaths) => {
|
||||
clearInterval(ongoing)
|
||||
clearInterval(ongoing);
|
||||
if (err) {
|
||||
grunt.fail.fatal(err);
|
||||
return done(err);
|
||||
|
|
|
@ -23,58 +23,86 @@ const fs = require('fs-plus');
|
|||
// codesign -dvvv /path/to/N1.app
|
||||
//
|
||||
// Which should return "accepted"
|
||||
module.exports = (grunt) => {
|
||||
module.exports = grunt => {
|
||||
let getCertData;
|
||||
const {spawnP} = grunt.config('taskHelpers')
|
||||
const tmpKeychain = "n1-build.keychain";
|
||||
const { spawnP } = grunt.config('taskHelpers');
|
||||
const tmpKeychain = 'n1-build.keychain';
|
||||
|
||||
const unlockKeychain = (keychain, keychainPass) => {
|
||||
const args = ['unlock-keychain', '-p', keychainPass, keychain];
|
||||
return spawnP({cmd: "security", args});
|
||||
return spawnP({ cmd: 'security', args });
|
||||
};
|
||||
|
||||
const cleanupKeychain = () => {
|
||||
if (fs.existsSync(path.join(process.env.HOME, "Library", "Keychains", tmpKeychain))) {
|
||||
return spawnP({cmd: "security", args: ["delete-keychain", tmpKeychain]});
|
||||
if (fs.existsSync(path.join(process.env.HOME, 'Library', 'Keychains', tmpKeychain))) {
|
||||
return spawnP({ cmd: 'security', args: ['delete-keychain', tmpKeychain] });
|
||||
}
|
||||
return Promise.resolve()
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const buildMacKeychain = () => {
|
||||
const crypto = require('crypto');
|
||||
const tmpPass = crypto.randomBytes(32).toString('hex');
|
||||
const {appleCert, nylasCert, nylasPrivateKey, keyPass} = getCertData();
|
||||
const codesignBin = path.join("/", "usr", "bin", "codesign");
|
||||
const { appleCert, nylasCert, nylasPrivateKey, keyPass } = getCertData();
|
||||
const codesignBin = path.join('/', 'usr', 'bin', 'codesign');
|
||||
|
||||
// Create a custom, temporary keychain
|
||||
return cleanupKeychain()
|
||||
.then(() => spawnP({cmd: "security", args: ["create-keychain", '-p', tmpPass, 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
|
||||
.then(() => spawnP({cmd: "security", args: ["list-keychains", "-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
|
||||
.then(() => unlockKeychain(tmpKeychain, tmpPass))
|
||||
|
||||
// Set keychain timeout to 1 hour for long builds
|
||||
.then(() => spawnP({cmd: "security", args: ["set-keychain-settings", "-t", "3600", "-l", tmpKeychain]}))
|
||||
|
||||
// Add certificates to keychain and allow codesign to access them
|
||||
.then(() => spawnP({cmd: "security", args: ["import", appleCert, "-k", tmpKeychain, "-T", codesignBin]}))
|
||||
|
||||
.then(() => 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]}))
|
||||
return (
|
||||
cleanupKeychain()
|
||||
.then(() =>
|
||||
spawnP({ cmd: 'security', args: ['create-keychain', '-p', tmpPass, 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
|
||||
.then(() => spawnP({ cmd: 'security', args: ['list-keychains', '-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
|
||||
.then(() => unlockKeychain(tmpKeychain, tmpPass))
|
||||
// Set keychain timeout to 1 hour for long builds
|
||||
.then(() =>
|
||||
spawnP({
|
||||
cmd: 'security',
|
||||
args: ['set-keychain-settings', '-t', '3600', '-l', tmpKeychain],
|
||||
})
|
||||
)
|
||||
// Add certificates to keychain and allow codesign to access them
|
||||
.then(() =>
|
||||
spawnP({
|
||||
cmd: 'security',
|
||||
args: ['import', appleCert, '-k', tmpKeychain, '-T', codesignBin],
|
||||
})
|
||||
)
|
||||
.then(() =>
|
||||
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 = () => {
|
||||
|
@ -86,7 +114,7 @@ module.exports = (grunt) => {
|
|||
const keyPass = process.env.APPLE_CODESIGN_KEY_PASSWORD;
|
||||
|
||||
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)) {
|
||||
throw new Error(`${appleCert} doesn't exist`);
|
||||
|
@ -98,21 +126,27 @@ module.exports = (grunt) => {
|
|||
throw new Error(`${nylasPrivateKey} doesn't exist`);
|
||||
}
|
||||
|
||||
return {appleCert, nylasCert, nylasPrivateKey, keyPass};
|
||||
return { appleCert, nylasCert, nylasPrivateKey, keyPass };
|
||||
};
|
||||
|
||||
const shouldRun = () => {
|
||||
if (process.platform !== '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() {
|
||||
const done = this.async();
|
||||
if (!shouldRun()) return done();
|
||||
grunt.registerTask(
|
||||
'setup-mac-keychain',
|
||||
'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);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const childProcess = require('child_process');
|
||||
|
||||
module.exports = (grunt) => {
|
||||
module.exports = grunt => {
|
||||
function spawn(options, callback) {
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
|
@ -8,25 +8,31 @@ module.exports = (grunt) => {
|
|||
const proc = childProcess.spawn(options.cmd, options.args, options.opts);
|
||||
proc.stdout.on('data', data => stdout.push(data.toString()));
|
||||
proc.stderr.on('data', data => stderr.push(data.toString()));
|
||||
proc.on('error', (processError) => {
|
||||
return error != null ? error : (error = processError)
|
||||
proc.on('error', processError => {
|
||||
return error != null ? error : (error = processError);
|
||||
});
|
||||
proc.on('close', (exitCode, signal) => {
|
||||
if (exitCode !== 0) { if (typeof error === 'undefined' || error === null) { error = new Error(signal); } }
|
||||
const results = {stderr: stderr.join(''), stdout: stdout.join(''), code: exitCode};
|
||||
if (exitCode !== 0) { grunt.log.error(results.stderr); }
|
||||
if (exitCode !== 0) {
|
||||
if (typeof error === 'undefined' || error === null) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function spawnP(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
spawn(options, (error) => {
|
||||
spawn(options, error => {
|
||||
if (error) return reject(error);
|
||||
return resolve()
|
||||
})
|
||||
})
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {spawn, spawnP};
|
||||
}
|
||||
return { spawn, spawnP };
|
||||
};
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
React = require 'react'
|
||||
{Actions} = require 'nylas-exports'
|
||||
{Actions, React, PropTypes} = require 'nylas-exports'
|
||||
{RetinaImg} = require 'nylas-component-kit'
|
||||
AccountCommands = require '../account-commands'
|
||||
|
||||
|
@ -8,8 +7,8 @@ class AccountSwitcher extends React.Component
|
|||
@displayName: 'AccountSwitcher'
|
||||
|
||||
@propTypes:
|
||||
accounts: React.PropTypes.array.isRequired
|
||||
sidebarAccountIds: React.PropTypes.array.isRequired
|
||||
accounts: PropTypes.array.isRequired
|
||||
sidebarAccountIds: PropTypes.array.isRequired
|
||||
|
||||
|
||||
_makeMenuTemplate: =>
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
import {Folder, Actions} from "nylas-exports"
|
||||
import SidebarItem from "../lib/sidebar-item"
|
||||
import { Folder, Actions } from 'nylas-exports';
|
||||
import SidebarItem from '../lib/sidebar-item';
|
||||
|
||||
describe("sidebar-item", function sidebarItemSpec() {
|
||||
it("preserves nested labels on rename", () => {
|
||||
spyOn(Actions, "queueTask")
|
||||
const categories = [new Folder({path: 'a.b/c', accountId: window.TEST_ACCOUNT_ID})]
|
||||
NylasEnv.savedState.sidebarKeysCollapsed = {}
|
||||
const item = SidebarItem.forCategories(categories)
|
||||
item.onEdited(item, 'd')
|
||||
describe('sidebar-item', function sidebarItemSpec() {
|
||||
it('preserves nested labels on rename', () => {
|
||||
spyOn(Actions, 'queueTask');
|
||||
const categories = [new Folder({ path: 'a.b/c', accountId: window.TEST_ACCOUNT_ID })];
|
||||
NylasEnv.savedState.sidebarKeysCollapsed = {};
|
||||
const item = SidebarItem.forCategories(categories);
|
||||
item.onEdited(item, 'd');
|
||||
|
||||
const task = Actions.queueTask.calls[0].args[0]
|
||||
const {existingPath, path} = task;
|
||||
expect(existingPath).toBe("a.b/c")
|
||||
expect(path).toBe("a.b/d")
|
||||
})
|
||||
it("preserves labels on rename", () => {
|
||||
spyOn(Actions, "queueTask")
|
||||
const categories = [new Folder({path: 'a', accountId: window.TEST_ACCOUNT_ID})]
|
||||
NylasEnv.savedState.sidebarKeysCollapsed = {}
|
||||
const item = SidebarItem.forCategories(categories)
|
||||
item.onEdited(item, 'b')
|
||||
const task = Actions.queueTask.calls[0].args[0];
|
||||
const { existingPath, path } = task;
|
||||
expect(existingPath).toBe('a.b/c');
|
||||
expect(path).toBe('a.b/d');
|
||||
});
|
||||
it('preserves labels on rename', () => {
|
||||
spyOn(Actions, 'queueTask');
|
||||
const categories = [new Folder({ path: 'a', accountId: window.TEST_ACCOUNT_ID })];
|
||||
NylasEnv.savedState.sidebarKeysCollapsed = {};
|
||||
const item = SidebarItem.forCategories(categories);
|
||||
item.onEdited(item, 'b');
|
||||
|
||||
const task = Actions.queueTask.calls[0].args[0]
|
||||
const {existingPath, path} = task;
|
||||
expect(existingPath).toBe("a")
|
||||
expect(path).toBe("b")
|
||||
})
|
||||
})
|
||||
const task = Actions.queueTask.calls[0].args[0];
|
||||
const { existingPath, path } = task;
|
||||
expect(existingPath).toBe('a');
|
||||
expect(path).toBe('b');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import {Rx, Message, DatabaseStore} from 'nylas-exports';
|
||||
import { Rx, Message, DatabaseStore } from 'nylas-exports';
|
||||
|
||||
export default class ActivityDataSource {
|
||||
buildObservable({openTrackingId, linkTrackingId, messageLimit}) {
|
||||
const query = DatabaseStore
|
||||
.findAll(Message)
|
||||
buildObservable({ openTrackingId, linkTrackingId, messageLimit }) {
|
||||
const query = DatabaseStore.findAll(Message)
|
||||
.order(Message.attributes.date.descending())
|
||||
.where(Message.attributes.pluginMetadata.contains(openTrackingId, linkTrackingId))
|
||||
.limit(messageLimit);
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import Reflux from 'reflux';
|
||||
|
||||
const ActivityListActions = Reflux.createActions([
|
||||
"resetSeen",
|
||||
]);
|
||||
const ActivityListActions = Reflux.createActions(['resetSeen']);
|
||||
|
||||
for (const key of Object.keys(ActivityListActions)) {
|
||||
ActivityListActions[key].sync = true;
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import React from 'react';
|
||||
import {Actions, ReactDOM} from 'nylas-exports';
|
||||
import {RetinaImg} from 'nylas-component-kit';
|
||||
import { Actions, ReactDOM } from 'nylas-exports';
|
||||
import { RetinaImg } from 'nylas-component-kit';
|
||||
|
||||
import ActivityList from './activity-list';
|
||||
import ActivityListStore from './activity-list-store';
|
||||
|
||||
|
||||
class ActivityListButton extends React.Component {
|
||||
static displayName = 'ActivityListButton';
|
||||
|
||||
|
@ -24,39 +23,29 @@ class ActivityListButton extends React.Component {
|
|||
|
||||
onClick = () => {
|
||||
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
Actions.openPopover(
|
||||
<ActivityList />,
|
||||
{originRect: buttonRect, direction: 'down'}
|
||||
);
|
||||
}
|
||||
Actions.openPopover(<ActivityList />, { originRect: buttonRect, direction: 'down' });
|
||||
};
|
||||
|
||||
_onDataChanged = () => {
|
||||
this.setState(this._getStateFromStores());
|
||||
}
|
||||
};
|
||||
|
||||
_getStateFromStores() {
|
||||
return {
|
||||
unreadCount: ActivityListStore.unreadCount(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
let unreadCountClass = "unread-count";
|
||||
let iconClass = "activity-toolbar-icon";
|
||||
let unreadCountClass = 'unread-count';
|
||||
let iconClass = 'activity-toolbar-icon';
|
||||
if (this.state.unreadCount) {
|
||||
unreadCountClass += " active";
|
||||
iconClass += " unread";
|
||||
unreadCountClass += ' active';
|
||||
iconClass += ' unread';
|
||||
}
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className="toolbar-activity"
|
||||
title="View activity"
|
||||
onClick={this.onClick}
|
||||
>
|
||||
<div className={unreadCountClass}>
|
||||
{this.state.unreadCount}
|
||||
</div>
|
||||
<div tabIndex={-1} className="toolbar-activity" title="View activity" onClick={this.onClick}>
|
||||
<div className={unreadCountClass}>{this.state.unreadCount}</div>
|
||||
<RetinaImg
|
||||
name="icon-toolbar-activity.png"
|
||||
className={iconClass}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import {RetinaImg} from 'nylas-component-kit';
|
||||
import { RetinaImg } from 'nylas-component-kit';
|
||||
|
||||
const ActivityListEmptyState = function ActivityListEmptyState() {
|
||||
return (
|
||||
|
@ -10,12 +10,16 @@ const ActivityListEmptyState = function ActivityListEmptyState() {
|
|||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
<div className="text">
|
||||
Enable read receipts <RetinaImg name="icon-activity-mailopen.png" mode={RetinaImg.Mode.ContentDark} /> or
|
||||
link tracking <RetinaImg name="icon-activity-linkopen.png" mode={RetinaImg.Mode.ContentDark} /> to
|
||||
see notifications here.
|
||||
Enable read receipts{' '}
|
||||
<RetinaImg name="icon-activity-mailopen.png" mode={RetinaImg.Mode.ContentDark} /> or link
|
||||
tracking <RetinaImg
|
||||
name="icon-activity-linkopen.png"
|
||||
mode={RetinaImg.Mode.ContentDark}
|
||||
/>{' '}
|
||||
to see notifications here.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ActivityListEmptyState;
|
||||
|
|
|
@ -1,19 +1,16 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import {DisclosureTriangle,
|
||||
Flexbox,
|
||||
RetinaImg} from 'nylas-component-kit';
|
||||
import {DateUtils} from 'nylas-exports';
|
||||
import { DisclosureTriangle, Flexbox, RetinaImg } from 'nylas-component-kit';
|
||||
import { DateUtils } from 'nylas-exports';
|
||||
import ActivityListStore from './activity-list-store';
|
||||
import {pluginFor} from './plugin-helpers';
|
||||
|
||||
import { pluginFor } from './plugin-helpers';
|
||||
|
||||
class ActivityListItemContainer extends React.Component {
|
||||
|
||||
static displayName = 'ActivityListItemContainer';
|
||||
|
||||
static propTypes = {
|
||||
group: React.PropTypes.array,
|
||||
group: PropTypes.array,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -27,15 +24,15 @@ class ActivityListItemContainer extends React.Component {
|
|||
ActivityListStore.focusThread(threadId);
|
||||
}
|
||||
|
||||
_onCollapseToggled = (event) => {
|
||||
_onCollapseToggled = event => {
|
||||
event.stopPropagation();
|
||||
this.setState({collapsed: !this.state.collapsed});
|
||||
}
|
||||
this.setState({ collapsed: !this.state.collapsed });
|
||||
};
|
||||
|
||||
_getText() {
|
||||
const text = {
|
||||
recipient: "Someone",
|
||||
title: "(No Subject)",
|
||||
recipient: 'Someone',
|
||||
title: '(No Subject)',
|
||||
date: new Date(0),
|
||||
};
|
||||
const lastAction = this.props.group[0];
|
||||
|
@ -64,18 +61,13 @@ class ActivityListItemContainer extends React.Component {
|
|||
const date = new Date(0);
|
||||
date.setUTCSeconds(action.timestamp);
|
||||
actions.push(
|
||||
<div
|
||||
key={`${action.messageId}-${action.timestamp}`}
|
||||
className="activity-list-toggle-item"
|
||||
>
|
||||
<div key={`${action.messageId}-${action.timestamp}`} className="activity-list-toggle-item">
|
||||
<Flexbox direction="row">
|
||||
<div className="action-message">
|
||||
{action.recipient ? action.recipient.displayName() : "Someone"}
|
||||
{action.recipient ? action.recipient.displayName() : 'Someone'}
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<div className="timestamp">
|
||||
{DateUtils.shortTimeString(date)}
|
||||
</div>
|
||||
<div className="timestamp">{DateUtils.shortTimeString(date)}</div>
|
||||
</Flexbox>
|
||||
</div>
|
||||
);
|
||||
|
@ -83,7 +75,7 @@ class ActivityListItemContainer extends React.Component {
|
|||
return (
|
||||
<div
|
||||
key={`activity-toggle-container`}
|
||||
className={`activity-toggle-container ${this.state.collapsed ? "hidden" : ""}`}
|
||||
className={`activity-toggle-container ${this.state.collapsed ? 'hidden' : ''}`}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
|
@ -92,10 +84,10 @@ class ActivityListItemContainer extends React.Component {
|
|||
|
||||
render() {
|
||||
const lastAction = this.props.group[0];
|
||||
let className = "activity-list-item";
|
||||
if (!ActivityListStore.hasBeenViewed(lastAction)) className += " unread";
|
||||
let className = 'activity-list-item';
|
||||
if (!ActivityListStore.hasBeenViewed(lastAction)) className += ' unread';
|
||||
const text = this._getText();
|
||||
let disclosureTriangle = (<div style={{width: "7px"}} />);
|
||||
let disclosureTriangle = <div style={{ width: '7px' }} />;
|
||||
if (this.props.group.length > 1) {
|
||||
disclosureTriangle = (
|
||||
<DisclosureTriangle
|
||||
|
@ -106,11 +98,13 @@ class ActivityListItemContainer extends React.Component {
|
|||
);
|
||||
}
|
||||
return (
|
||||
<div onClick={() => { this._onClick(lastAction.threadId) }}>
|
||||
<div
|
||||
onClick={() => {
|
||||
this._onClick(lastAction.threadId);
|
||||
}}
|
||||
>
|
||||
<Flexbox direction="column" className={className}>
|
||||
<Flexbox
|
||||
direction="row"
|
||||
>
|
||||
<Flexbox direction="row">
|
||||
<div className="activity-icon-container">
|
||||
<RetinaImg
|
||||
className="activity-icon"
|
||||
|
@ -123,19 +117,14 @@ class ActivityListItemContainer extends React.Component {
|
|||
{text.recipient} {pluginFor(lastAction.pluginId).predicate}:
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<div className="timestamp">
|
||||
{DateUtils.shortTimeString(text.date)}
|
||||
</div>
|
||||
<div className="timestamp">{DateUtils.shortTimeString(text.date)}</div>
|
||||
</Flexbox>
|
||||
<div className="title">
|
||||
{text.title}
|
||||
</div>
|
||||
<div className="title">{text.title}</div>
|
||||
</Flexbox>
|
||||
{this.renderActivityContainer()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ActivityListItemContainer;
|
||||
|
|
|
@ -8,8 +8,7 @@ import {
|
|||
} from 'nylas-exports';
|
||||
import ActivityListActions from './activity-list-actions';
|
||||
import ActivityDataSource from './activity-data-source';
|
||||
import {pluginFor} from './plugin-helpers';
|
||||
|
||||
import { pluginFor } from './plugin-helpers';
|
||||
|
||||
class ActivityListStore extends NylasStore {
|
||||
activate() {
|
||||
|
@ -38,7 +37,7 @@ class ActivityListStore extends NylasStore {
|
|||
} else if (!this._unreadCount) {
|
||||
return null;
|
||||
}
|
||||
return "999+";
|
||||
return '999+';
|
||||
}
|
||||
|
||||
hasBeenViewed(action) {
|
||||
|
@ -47,16 +46,18 @@ class ActivityListStore extends NylasStore {
|
|||
}
|
||||
|
||||
focusThread(threadId) {
|
||||
NylasEnv.displayWindow()
|
||||
Actions.closePopover()
|
||||
DatabaseStore.find(Thread, threadId).then((thread) => {
|
||||
NylasEnv.displayWindow();
|
||||
Actions.closePopover();
|
||||
DatabaseStore.find(Thread, threadId).then(thread => {
|
||||
if (!thread) {
|
||||
NylasEnv.reportError(new Error(`ActivityListStore::focusThread: Can't find thread`, {threadId}))
|
||||
NylasEnv.showErrorDialog(`Can't find the selected thread in your mailbox`)
|
||||
NylasEnv.reportError(
|
||||
new Error(`ActivityListStore::focusThread: Can't find thread`, { threadId })
|
||||
);
|
||||
NylasEnv.showErrorDialog(`Can't find the selected thread in your mailbox`);
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
const dataSource = this._dataSource();
|
||||
this._subscription = dataSource.buildObservable({
|
||||
openTrackingId: NylasEnv.packages.pluginIdFor('open-tracking'),
|
||||
linkTrackingId: NylasEnv.packages.pluginIdFor('link-tracking'),
|
||||
messageLimit: 500,
|
||||
}).subscribe((messages) => {
|
||||
this._messages = messages;
|
||||
this._updateActivity();
|
||||
});
|
||||
this._subscription = dataSource
|
||||
.buildObservable({
|
||||
openTrackingId: NylasEnv.packages.pluginIdFor('open-tracking'),
|
||||
linkTrackingId: NylasEnv.packages.pluginIdFor('link-tracking'),
|
||||
messageLimit: 500,
|
||||
})
|
||||
.subscribe(messages => {
|
||||
this._messages = messages;
|
||||
this._updateActivity();
|
||||
});
|
||||
}
|
||||
|
||||
_updateActivity() {
|
||||
|
@ -107,10 +110,12 @@ class ActivityListStore extends NylasStore {
|
|||
const sidebarAccountIds = FocusedPerspectiveStore.sidebarAccountIds();
|
||||
for (const message of messages) {
|
||||
if (sidebarAccountIds.length > 1 || message.accountId === sidebarAccountIds[0]) {
|
||||
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking')
|
||||
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking')
|
||||
if (message.metadataForPluginId(openTrackingId) ||
|
||||
message.metadataForPluginId(linkTrackingId)) {
|
||||
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking');
|
||||
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking');
|
||||
if (
|
||||
message.metadataForPluginId(openTrackingId) ||
|
||||
message.metadataForPluginId(linkTrackingId)
|
||||
) {
|
||||
actions = actions.concat(this._openActionsForMessage(message));
|
||||
actions = actions.concat(this._linkActionsForMessage(message));
|
||||
}
|
||||
|
@ -119,7 +124,7 @@ class ActivityListStore extends NylasStore {
|
|||
if (!this._lastNotified) this._lastNotified = {};
|
||||
for (const notification of this._notifications) {
|
||||
const lastNotified = this._lastNotified[notification.threadId];
|
||||
const {notificationInterval} = pluginFor(notification.pluginId);
|
||||
const { notificationInterval } = pluginFor(notification.pluginId);
|
||||
if (!lastNotified || lastNotified < Date.now() - notificationInterval) {
|
||||
NativeNotifications.displayNotification(notification.data);
|
||||
this._lastNotified[notification.threadId] = Date.now();
|
||||
|
@ -137,7 +142,7 @@ class ActivityListStore extends NylasStore {
|
|||
}
|
||||
|
||||
_openActionsForMessage(message) {
|
||||
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking')
|
||||
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking');
|
||||
const openMetadata = message.metadataForPluginId(openTrackingId);
|
||||
const recipients = message.to.concat(message.cc, message.bcc);
|
||||
const actions = [];
|
||||
|
@ -150,10 +155,12 @@ class ActivityListStore extends NylasStore {
|
|||
pluginId: openTrackingId,
|
||||
threadId: message.threadId,
|
||||
data: {
|
||||
title: "New open",
|
||||
subtitle: `${recipient ? recipient.displayName() : "Someone"} just opened ${message.subject}`,
|
||||
title: 'New open',
|
||||
subtitle: `${recipient
|
||||
? recipient.displayName()
|
||||
: 'Someone'} just opened ${message.subject}`,
|
||||
canReply: false,
|
||||
tag: "message-open",
|
||||
tag: 'message-open',
|
||||
onActivate: () => {
|
||||
this.focusThread(message.threadId);
|
||||
},
|
||||
|
@ -176,8 +183,8 @@ class ActivityListStore extends NylasStore {
|
|||
}
|
||||
|
||||
_linkActionsForMessage(message) {
|
||||
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking')
|
||||
const linkMetadata = message.metadataForPluginId(linkTrackingId)
|
||||
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking');
|
||||
const linkMetadata = message.metadataForPluginId(linkTrackingId);
|
||||
const recipients = message.to.concat(message.cc, message.bcc);
|
||||
const actions = [];
|
||||
if (linkMetadata && linkMetadata.links) {
|
||||
|
@ -189,10 +196,12 @@ class ActivityListStore extends NylasStore {
|
|||
pluginId: linkTrackingId,
|
||||
threadId: message.threadId,
|
||||
data: {
|
||||
title: "New click",
|
||||
subtitle: `${recipient ? recipient.displayName() : "Someone"} just clicked ${link.url}.`,
|
||||
title: 'New click',
|
||||
subtitle: `${recipient
|
||||
? recipient.displayName()
|
||||
: 'Someone'} just clicked ${link.url}.`,
|
||||
canReply: false,
|
||||
tag: "link-open",
|
||||
tag: 'link-open',
|
||||
onActivate: () => {
|
||||
this.focusThread(message.threadId);
|
||||
},
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import {Flexbox,
|
||||
ScrollRegion} from 'nylas-component-kit';
|
||||
import { Flexbox, ScrollRegion } from 'nylas-component-kit';
|
||||
import ActivityListStore from './activity-list-store';
|
||||
import ActivityListActions from './activity-list-actions';
|
||||
import ActivityListItemContainer from './activity-list-item-container';
|
||||
import ActivityListEmptyState from './activity-list-empty-state';
|
||||
|
||||
class ActivityList extends React.Component {
|
||||
|
||||
static displayName = 'ActivityList';
|
||||
|
||||
constructor() {
|
||||
|
@ -28,7 +26,7 @@ class ActivityList extends React.Component {
|
|||
|
||||
_onDataChanged = () => {
|
||||
this.setState(this._getStateFromStores());
|
||||
}
|
||||
};
|
||||
|
||||
_getStateFromStores() {
|
||||
const actions = ActivityListStore.actions();
|
||||
|
@ -36,7 +34,7 @@ class ActivityList extends React.Component {
|
|||
actions: actions,
|
||||
empty: actions instanceof Array && actions.length === 0,
|
||||
collapsedToggles: this.state ? this.state.collapsedToggles : {},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_groupActions(actions) {
|
||||
|
@ -44,8 +42,10 @@ class ActivityList extends React.Component {
|
|||
for (const action of actions) {
|
||||
if (groupedActions.length > 0) {
|
||||
const currentGroup = groupedActions[groupedActions.length - 1];
|
||||
if (action.messageId === currentGroup[0].messageId &&
|
||||
action.pluginId === currentGroup[0].pluginId) {
|
||||
if (
|
||||
action.messageId === currentGroup[0].messageId &&
|
||||
action.pluginId === currentGroup[0].pluginId
|
||||
) {
|
||||
groupedActions[groupedActions.length - 1].push(action);
|
||||
} else {
|
||||
groupedActions.push([action]);
|
||||
|
@ -59,13 +59,11 @@ class ActivityList extends React.Component {
|
|||
|
||||
renderActions() {
|
||||
if (this.state.empty) {
|
||||
return (
|
||||
<ActivityListEmptyState />
|
||||
)
|
||||
return <ActivityListEmptyState />;
|
||||
}
|
||||
|
||||
const groupedActions = this._groupActions(this.state.actions);
|
||||
return groupedActions.map((group) => {
|
||||
return groupedActions.map(group => {
|
||||
return (
|
||||
<ActivityListItemContainer
|
||||
key={`${group[0].messageId}-${group[0].timestamp}`}
|
||||
|
@ -79,19 +77,12 @@ class ActivityList extends React.Component {
|
|||
if (!this.state.actions) return null;
|
||||
|
||||
const classes = classnames({
|
||||
"activity-list-container": true,
|
||||
"empty": this.state.empty,
|
||||
})
|
||||
'activity-list-container': true,
|
||||
empty: this.state.empty,
|
||||
});
|
||||
return (
|
||||
<Flexbox
|
||||
direction="column"
|
||||
height="none"
|
||||
className={classes}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<ScrollRegion style={{height: "100%"}}>
|
||||
{this.renderActions()}
|
||||
</ScrollRegion>
|
||||
<Flexbox direction="column" height="none" className={classes} tabIndex="-1">
|
||||
<ScrollRegion style={{ height: '100%' }}>{this.renderActions()}</ScrollRegion>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import {ComponentRegistry, WorkspaceStore} from 'nylas-exports';
|
||||
import {HasTutorialTip} from 'nylas-component-kit';
|
||||
import { ComponentRegistry, WorkspaceStore } from 'nylas-exports';
|
||||
import { HasTutorialTip } from 'nylas-component-kit';
|
||||
import ActivityListButton from './activity-list-button';
|
||||
import ActivityListStore from './activity-list-store';
|
||||
|
||||
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!",
|
||||
});
|
||||
|
||||
|
@ -15,7 +15,6 @@ export function activate() {
|
|||
ActivityListStore.activate();
|
||||
}
|
||||
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(ActivityListButtonWithTutorialTip);
|
||||
}
|
||||
|
|
|
@ -1,22 +1,21 @@
|
|||
|
||||
export function pluginFor(id) {
|
||||
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking')
|
||||
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking')
|
||||
const openTrackingId = NylasEnv.packages.pluginIdFor('open-tracking');
|
||||
const linkTrackingId = NylasEnv.packages.pluginIdFor('link-tracking');
|
||||
if (id === openTrackingId) {
|
||||
return {
|
||||
name: "open",
|
||||
predicate: "opened",
|
||||
iconName: "icon-activity-mailopen.png",
|
||||
name: 'open',
|
||||
predicate: 'opened',
|
||||
iconName: 'icon-activity-mailopen.png',
|
||||
notificationInterval: 600000, // 10 minutes in ms
|
||||
}
|
||||
};
|
||||
}
|
||||
if (id === linkTrackingId) {
|
||||
return {
|
||||
name: "link",
|
||||
predicate: "clicked",
|
||||
iconName: "icon-activity-linkopen.png",
|
||||
name: 'link',
|
||||
predicate: 'clicked',
|
||||
iconName: 'icon-activity-linkopen.png',
|
||||
notificationInterval: 10000, // 10 seconds in ms
|
||||
}
|
||||
};
|
||||
}
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -5,14 +5,14 @@ export default class TestDataSource {
|
|||
|
||||
manuallyTrigger = (messages = []) => {
|
||||
this.onNext(messages);
|
||||
}
|
||||
};
|
||||
|
||||
subscribe(onNext) {
|
||||
this.onNext = onNext;
|
||||
this.manuallyTrigger();
|
||||
const dispose = () => {
|
||||
this._unsub();
|
||||
}
|
||||
return {dispose};
|
||||
};
|
||||
return { dispose };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,79 +12,97 @@ import ActivityList from '../lib/activity-list';
|
|||
import ActivityListStore from '../lib/activity-list-store';
|
||||
import TestDataSource from '../lib/test-data-source';
|
||||
|
||||
const OPEN_TRACKING_ID = 'open-tracking-id'
|
||||
const LINK_TRACKING_ID = 'link-tracking-id'
|
||||
const OPEN_TRACKING_ID = 'open-tracking-id';
|
||||
const LINK_TRACKING_ID = 'link-tracking-id';
|
||||
|
||||
const messages = [
|
||||
new Message({
|
||||
id: 'a',
|
||||
accountId: "0000000000000000000000000",
|
||||
accountId: '0000000000000000000000000',
|
||||
bcc: [],
|
||||
cc: [],
|
||||
snippet: "Testing.",
|
||||
subject: "Open me!",
|
||||
threadId: "0000000000000000000000000",
|
||||
to: [new Contact({
|
||||
name: "Jackie Luo",
|
||||
email: "jackie@nylas.com",
|
||||
})],
|
||||
snippet: 'Testing.',
|
||||
subject: 'Open me!',
|
||||
threadId: '0000000000000000000000000',
|
||||
to: [
|
||||
new Contact({
|
||||
name: 'Jackie Luo',
|
||||
email: 'jackie@nylas.com',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Message({
|
||||
id: 'b',
|
||||
accountId: "0000000000000000000000000",
|
||||
bcc: [new Contact({
|
||||
name: "Ben Gotow",
|
||||
email: "ben@nylas.com",
|
||||
})],
|
||||
accountId: '0000000000000000000000000',
|
||||
bcc: [
|
||||
new Contact({
|
||||
name: 'Ben Gotow',
|
||||
email: 'ben@nylas.com',
|
||||
}),
|
||||
],
|
||||
cc: [],
|
||||
snippet: "Hey! I am in town for the week...",
|
||||
subject: "Coffee?",
|
||||
threadId: "0000000000000000000000000",
|
||||
to: [new Contact({
|
||||
name: "Jackie Luo",
|
||||
email: "jackie@nylas.com",
|
||||
})],
|
||||
snippet: 'Hey! I am in town for the week...',
|
||||
subject: 'Coffee?',
|
||||
threadId: '0000000000000000000000000',
|
||||
to: [
|
||||
new Contact({
|
||||
name: 'Jackie Luo',
|
||||
email: 'jackie@nylas.com',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Message({
|
||||
id: 'c',
|
||||
accountId: "0000000000000000000000000",
|
||||
accountId: '0000000000000000000000000',
|
||||
bcc: [],
|
||||
cc: [new Contact({
|
||||
name: "Evan Morikawa",
|
||||
email: "evan@nylas.com",
|
||||
})],
|
||||
cc: [
|
||||
new Contact({
|
||||
name: 'Evan Morikawa',
|
||||
email: 'evan@nylas.com',
|
||||
}),
|
||||
],
|
||||
snippet: "Here's the latest deals!",
|
||||
subject: "Newsletter",
|
||||
threadId: "0000000000000000000000000",
|
||||
to: [new Contact({
|
||||
name: "Juan Tejada",
|
||||
email: "juan@nylas.com",
|
||||
})],
|
||||
subject: 'Newsletter',
|
||||
threadId: '0000000000000000000000000',
|
||||
to: [
|
||||
new Contact({
|
||||
name: 'Juan Tejada',
|
||||
email: 'juan@nylas.com',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
let pluginValue = {
|
||||
open_count: 1,
|
||||
open_data: [{
|
||||
timestamp: 1461361759.351055,
|
||||
}],
|
||||
open_data: [
|
||||
{
|
||||
timestamp: 1461361759.351055,
|
||||
},
|
||||
],
|
||||
};
|
||||
messages[0].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue);
|
||||
pluginValue = {
|
||||
links: [{
|
||||
click_count: 1,
|
||||
click_data: [{
|
||||
timestamp: 1461349232.495837,
|
||||
}],
|
||||
}],
|
||||
links: [
|
||||
{
|
||||
click_count: 1,
|
||||
click_data: [
|
||||
{
|
||||
timestamp: 1461349232.495837,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tracked: true,
|
||||
};
|
||||
messages[0].directlyAttachMetadata(LINK_TRACKING_ID, pluginValue);
|
||||
pluginValue = {
|
||||
open_count: 1,
|
||||
open_data: [{
|
||||
timestamp: 1461361763.283720,
|
||||
}],
|
||||
open_data: [
|
||||
{
|
||||
timestamp: 1461361763.28372,
|
||||
},
|
||||
],
|
||||
};
|
||||
messages[1].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue);
|
||||
pluginValue = {
|
||||
|
@ -98,51 +116,55 @@ pluginValue = {
|
|||
};
|
||||
messages[2].directlyAttachMetadata(OPEN_TRACKING_ID, pluginValue);
|
||||
pluginValue = {
|
||||
links: [{
|
||||
click_count: 0,
|
||||
click_data: [],
|
||||
}],
|
||||
links: [
|
||||
{
|
||||
click_count: 0,
|
||||
click_data: [],
|
||||
},
|
||||
],
|
||||
tracked: true,
|
||||
};
|
||||
messages[2].directlyAttachMetadata(LINK_TRACKING_ID, pluginValue);
|
||||
|
||||
|
||||
describe('ActivityList', function activityList() {
|
||||
beforeEach(() => {
|
||||
this.testSource = new TestDataSource();
|
||||
spyOn(NylasEnv.packages, 'pluginIdFor').andCallFake((pluginName) => {
|
||||
spyOn(NylasEnv.packages, 'pluginIdFor').andCallFake(pluginName => {
|
||||
if (pluginName === 'open-tracking') {
|
||||
return OPEN_TRACKING_ID
|
||||
return OPEN_TRACKING_ID;
|
||||
}
|
||||
if (pluginName === 'link-tracking') {
|
||||
return LINK_TRACKING_ID
|
||||
return LINK_TRACKING_ID;
|
||||
}
|
||||
return null
|
||||
})
|
||||
spyOn(ActivityListStore, "_dataSource").andReturn(this.testSource);
|
||||
spyOn(FocusedPerspectiveStore, "sidebarAccountIds").andReturn(["0000000000000000000000000"]);
|
||||
spyOn(DatabaseStore, "run").andCallFake((query) => {
|
||||
return null;
|
||||
});
|
||||
spyOn(ActivityListStore, '_dataSource').andReturn(this.testSource);
|
||||
spyOn(FocusedPerspectiveStore, 'sidebarAccountIds').andReturn(['0000000000000000000000000']);
|
||||
spyOn(DatabaseStore, 'run').andCallFake(query => {
|
||||
if (query._klass === Thread) {
|
||||
const thread = new Thread({
|
||||
id: "0000000000000000000000000",
|
||||
id: '0000000000000000000000000',
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
});
|
||||
return Promise.resolve(thread);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
spyOn(ActivityListStore, "focusThread").andCallThrough();
|
||||
spyOn(NylasEnv, "displayWindow");
|
||||
spyOn(Actions, "closePopover");
|
||||
spyOn(Actions, "setFocus");
|
||||
spyOn(Actions, "ensureCategoryIsFocused");
|
||||
spyOn(ActivityListStore, 'focusThread').andCallThrough();
|
||||
spyOn(NylasEnv, 'displayWindow');
|
||||
spyOn(Actions, 'closePopover');
|
||||
spyOn(Actions, 'setFocus');
|
||||
spyOn(Actions, 'ensureCategoryIsFocused');
|
||||
ActivityListStore.activate();
|
||||
this.component = ReactTestUtils.renderIntoDocument(<ActivityList />);
|
||||
});
|
||||
|
||||
describe('when no actions are found', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -151,37 +173,61 @@ describe('ActivityList', function activityList() {
|
|||
it('should show activity list items', () => {
|
||||
this.testSource.manuallyTrigger(messages);
|
||||
waitsFor(() => {
|
||||
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
|
||||
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(
|
||||
this.component,
|
||||
'activity-list-item'
|
||||
);
|
||||
return items.length > 0;
|
||||
});
|
||||
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', () => {
|
||||
this.testSource.manuallyTrigger(messages);
|
||||
waitsFor(() => {
|
||||
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
|
||||
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(
|
||||
this.component,
|
||||
'activity-list-item'
|
||||
);
|
||||
return items.length > 0;
|
||||
});
|
||||
runs(() => {
|
||||
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item")[0].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)");
|
||||
expect(
|
||||
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'activity-list-item')[0]
|
||||
.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', () => {
|
||||
runs(() => {
|
||||
return this.testSource.manuallyTrigger(messages);
|
||||
})
|
||||
});
|
||||
waitsFor(() => {
|
||||
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, "activity-list-item");
|
||||
const items = ReactTestUtils.scryRenderedDOMComponentsWithClass(
|
||||
this.component,
|
||||
'activity-list-item'
|
||||
);
|
||||
return items.length > 0;
|
||||
});
|
||||
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);
|
||||
});
|
||||
waitsFor(() => {
|
||||
|
|
|
@ -3,7 +3,7 @@ const assert = require('assert');
|
|||
const crypto = require('crypto');
|
||||
const validate = require('@segment/loosely-validate-event');
|
||||
const debug = require('debug')('analytics-node');
|
||||
const version = `3.0.0`
|
||||
const version = `3.0.0`;
|
||||
|
||||
// BG: Dependencies of analytics-node I lifted in
|
||||
|
||||
|
@ -11,13 +11,13 @@ const version = `3.0.0`
|
|||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
function uid(length, fn) {
|
||||
const str = (bytes) => {
|
||||
const str = bytes => {
|
||||
const res = [];
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
res.push(chars[bytes[i] % chars.length]);
|
||||
}
|
||||
return res.join('');
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof length === 'function') {
|
||||
fn = length;
|
||||
|
@ -45,9 +45,8 @@ function removeSlash(str) {
|
|||
return String(str).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
|
||||
const setImmediate = global.setImmediate || process.nextTick.bind(process)
|
||||
const noop = () => {}
|
||||
const setImmediate = global.setImmediate || process.nextTick.bind(process);
|
||||
const noop = () => {};
|
||||
|
||||
export default class Analytics {
|
||||
/**
|
||||
|
@ -62,16 +61,16 @@ export default class Analytics {
|
|||
*/
|
||||
|
||||
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.writeKey = writeKey
|
||||
this.host = removeSlash(options.host || 'https://api.segment.io')
|
||||
this.flushAt = Math.max(options.flushAt, 1) || 20
|
||||
this.flushInterval = options.flushInterval || 10000
|
||||
this.flushed = false
|
||||
this.queue = [];
|
||||
this.writeKey = writeKey;
|
||||
this.host = removeSlash(options.host || 'https://api.segment.io');
|
||||
this.flushAt = Math.max(options.flushAt, 1) || 20;
|
||||
this.flushInterval = options.flushInterval || 10000;
|
||||
this.flushed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -83,9 +82,9 @@ export default class Analytics {
|
|||
*/
|
||||
|
||||
identify(message, callback) {
|
||||
validate(message, 'identify')
|
||||
this.enqueue('identify', message, callback)
|
||||
return this
|
||||
validate(message, 'identify');
|
||||
this.enqueue('identify', message, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -97,9 +96,9 @@ export default class Analytics {
|
|||
*/
|
||||
|
||||
group(message, callback) {
|
||||
validate(message, 'group')
|
||||
this.enqueue('group', message, callback)
|
||||
return this
|
||||
validate(message, 'group');
|
||||
this.enqueue('group', message, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -111,9 +110,9 @@ export default class Analytics {
|
|||
*/
|
||||
|
||||
track(message, callback) {
|
||||
validate(message, 'track')
|
||||
this.enqueue('track', message, callback)
|
||||
return this
|
||||
validate(message, 'track');
|
||||
this.enqueue('track', message, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -125,9 +124,9 @@ export default class Analytics {
|
|||
*/
|
||||
|
||||
page(message, callback) {
|
||||
validate(message, 'page')
|
||||
this.enqueue('page', message, callback)
|
||||
return this
|
||||
validate(message, 'page');
|
||||
this.enqueue('page', message, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -139,9 +138,9 @@ export default class Analytics {
|
|||
*/
|
||||
|
||||
screen(message, callback) {
|
||||
validate(message, 'screen')
|
||||
this.enqueue('screen', message, callback)
|
||||
return this
|
||||
validate(message, 'screen');
|
||||
this.enqueue('screen', message, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -153,9 +152,9 @@ export default class Analytics {
|
|||
*/
|
||||
|
||||
alias(message, callback) {
|
||||
validate(message, 'alias')
|
||||
this.enqueue('alias', message, callback)
|
||||
return this
|
||||
validate(message, 'alias');
|
||||
this.enqueue('alias', message, callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -169,45 +168,51 @@ export default class Analytics {
|
|||
*/
|
||||
|
||||
enqueue(type, message, callback) {
|
||||
callback = callback || noop
|
||||
callback = callback || noop;
|
||||
|
||||
message = Object.assign({}, message)
|
||||
message.type = type
|
||||
message.context = Object.assign({
|
||||
library: {
|
||||
name: 'analytics-node',
|
||||
version,
|
||||
message = Object.assign({}, message);
|
||||
message.type = type;
|
||||
message.context = Object.assign(
|
||||
{
|
||||
library: {
|
||||
name: 'analytics-node',
|
||||
version,
|
||||
},
|
||||
},
|
||||
}, message.context)
|
||||
message.context
|
||||
);
|
||||
|
||||
message._metadata = Object.assign({
|
||||
nodeVersion: process.versions.node,
|
||||
}, message._metadata)
|
||||
message._metadata = Object.assign(
|
||||
{
|
||||
nodeVersion: process.versions.node,
|
||||
},
|
||||
message._metadata
|
||||
);
|
||||
|
||||
if (!message.timestamp) {
|
||||
message.timestamp = new Date()
|
||||
message.timestamp = new Date();
|
||||
}
|
||||
|
||||
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) {
|
||||
this.flushed = true
|
||||
this.flush()
|
||||
return
|
||||
this.flushed = true;
|
||||
this.flush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.queue.length >= this.flushAt) {
|
||||
this.flush()
|
||||
this.flush();
|
||||
}
|
||||
|
||||
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) {
|
||||
callback = callback || noop
|
||||
callback = callback || noop;
|
||||
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
if (!this.queue.length) {
|
||||
setImmediate(callback)
|
||||
setImmediate(callback);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = this.queue.splice(0, this.flushAt)
|
||||
const callbacks = items.map(item => item.callback)
|
||||
const messages = items.map(item => item.message)
|
||||
const items = this.queue.splice(0, this.flushAt);
|
||||
const callbacks = items.map(item => item.callback);
|
||||
const messages = items.map(item => item.message);
|
||||
|
||||
const data = {
|
||||
batch: messages,
|
||||
timestamp: new Date(),
|
||||
sentAt: new Date(),
|
||||
}
|
||||
};
|
||||
|
||||
debug('flush: %o', data)
|
||||
debug('flush: %o', data);
|
||||
|
||||
const options = {
|
||||
body: JSON.stringify(data),
|
||||
|
@ -250,11 +255,11 @@ export default class Analytics {
|
|||
method: 'POST',
|
||||
};
|
||||
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');
|
||||
|
||||
const runCallbacks = (err) => {
|
||||
callbacks.forEach((cb) => cb(err))
|
||||
const runCallbacks = err => {
|
||||
callbacks.forEach(cb => cb(err));
|
||||
callback(err, data);
|
||||
debug('flushed: %o', data);
|
||||
};
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import _ from 'underscore'
|
||||
import NylasStore from 'nylas-store'
|
||||
import _ from 'underscore';
|
||||
import NylasStore from 'nylas-store';
|
||||
import {
|
||||
IdentityStore,
|
||||
Actions,
|
||||
AccountStore,
|
||||
FocusedPerspectiveStore,
|
||||
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
|
||||
|
@ -17,16 +17,15 @@ import AnalyticsSink from '../analytics-electron'
|
|||
const DEBOUNCE_TIME = 5 * 1000;
|
||||
|
||||
class AnalyticsStore extends NylasStore {
|
||||
|
||||
activate() {
|
||||
// Allow requests to be grouped together if they're fired back-to-back,
|
||||
// but generally report each event as it happens. This segment library
|
||||
// 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`,
|
||||
flushInterval: 500,
|
||||
flushAt: 5,
|
||||
})
|
||||
});
|
||||
this.launchTime = Date.now();
|
||||
|
||||
const identifySoon = _.debounce(this.identify, DEBOUNCE_TIME);
|
||||
|
@ -39,7 +38,7 @@ class AnalyticsStore extends NylasStore {
|
|||
this.listenTo(IdentityStore, identifySoon);
|
||||
this.listenTo(Actions.recordUserEvent, (eventName, eventArgs) => {
|
||||
this.track(eventName, eventArgs);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Properties applied to all events (only).
|
||||
|
@ -53,7 +52,9 @@ class AnalyticsStore extends NylasStore {
|
|||
currentProvider = 'Unified';
|
||||
} else {
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
@ -67,10 +68,10 @@ class AnalyticsStore extends NylasStore {
|
|||
const theme = NylasEnv.themes ? NylasEnv.themes.getActiveTheme() : null;
|
||||
|
||||
return {
|
||||
version: NylasEnv.getVersion().split("-")[0],
|
||||
version: NylasEnv.getVersion().split('-')[0],
|
||||
platform: process.platform,
|
||||
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(),
|
||||
timeSinceLaunch: (Date.now() - this.launchTime) / 1000,
|
||||
accountCount: AccountStore.accounts().length,
|
||||
providers: AccountStore.accounts().map((a) => a.displayProvider()),
|
||||
providers: AccountStore.accounts().map(a => a.displayProvider()),
|
||||
});
|
||||
}
|
||||
|
||||
personalTraits() {
|
||||
const identity = IdentityStore.identity();
|
||||
if (!(identity && identity.id)) { return {}; }
|
||||
if (!(identity && identity.id)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
email: identity.emailAddress,
|
||||
|
@ -97,26 +100,24 @@ class AnalyticsStore extends NylasStore {
|
|||
track(eventName, eventArgs = {}) {
|
||||
// if (NylasEnv.inDevMode()) { return }
|
||||
|
||||
const identity = IdentityStore.identity()
|
||||
if (!(identity && identity.id)) { return; }
|
||||
const identity = IdentityStore.identity();
|
||||
if (!(identity && identity.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.track({
|
||||
event: eventName,
|
||||
userId: identity.id,
|
||||
properties: Object.assign({},
|
||||
eventArgs,
|
||||
this.eventState(),
|
||||
this.superTraits(),
|
||||
),
|
||||
})
|
||||
properties: Object.assign({}, eventArgs, this.eventState(), this.superTraits()),
|
||||
});
|
||||
}
|
||||
|
||||
firstDaySeen() {
|
||||
let firstDaySeen = NylasEnv.config.get("firstDaySeen");
|
||||
let firstDaySeen = NylasEnv.config.get('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}`;
|
||||
NylasEnv.config.set("firstDaySeen", firstDaySeen);
|
||||
NylasEnv.config.set('firstDaySeen', firstDaySeen);
|
||||
}
|
||||
return firstDaySeen;
|
||||
}
|
||||
|
@ -127,12 +128,14 @@ class AnalyticsStore extends NylasStore {
|
|||
}
|
||||
|
||||
const identity = IdentityStore.identity();
|
||||
if (!(identity && identity.id)) { return; }
|
||||
if (!(identity && identity.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.identify({
|
||||
userId: identity.id,
|
||||
traits: this.baseTraits(),
|
||||
integrations: {All: true},
|
||||
integrations: { All: true },
|
||||
});
|
||||
|
||||
// Ensure we never send PI anywhere but Mixpanel
|
||||
|
@ -145,7 +148,7 @@ class AnalyticsStore extends NylasStore {
|
|||
Mixpanel: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default new AnalyticsStore()
|
||||
export default new AnalyticsStore();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import AnalyticsStore from './analytics-store'
|
||||
import AnalyticsStore from './analytics-store';
|
||||
|
||||
export function activate() {
|
||||
AnalyticsStore.activate()
|
||||
AnalyticsStore.activate();
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import {ComponentRegistry} from 'nylas-exports';
|
||||
import MessageAttachments from './message-attachments'
|
||||
import { ComponentRegistry } from 'nylas-exports';
|
||||
import MessageAttachments from './message-attachments';
|
||||
|
||||
export function activate() {
|
||||
ComponentRegistry.register(MessageAttachments, {role: 'MessageAttachments'})
|
||||
ComponentRegistry.register(MessageAttachments, { role: 'MessageAttachments' });
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
import React, {Component} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {Actions, Utils, AttachmentStore} from 'nylas-exports'
|
||||
import {AttachmentItem, ImageAttachmentItem} from 'nylas-component-kit'
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Actions, Utils, AttachmentStore } from 'nylas-exports';
|
||||
import { AttachmentItem, ImageAttachmentItem } from 'nylas-component-kit';
|
||||
|
||||
class MessageAttachments extends Component {
|
||||
static displayName = 'MessageAttachments'
|
||||
static displayName = 'MessageAttachments';
|
||||
|
||||
static containerRequired = false
|
||||
static containerRequired = false;
|
||||
|
||||
static propTypes = {
|
||||
files: PropTypes.array,
|
||||
|
@ -15,42 +14,42 @@ class MessageAttachments extends Component {
|
|||
headerMessageId: PropTypes.string,
|
||||
filePreviewPaths: PropTypes.object,
|
||||
canRemoveAttachments: PropTypes.bool,
|
||||
}
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
downloads: {},
|
||||
filePreviewPaths: {},
|
||||
}
|
||||
};
|
||||
|
||||
onOpenAttachment = (file) => {
|
||||
Actions.fetchAndOpenFile(file)
|
||||
}
|
||||
onOpenAttachment = file => {
|
||||
Actions.fetchAndOpenFile(file);
|
||||
};
|
||||
|
||||
onRemoveAttachment = (file) => {
|
||||
const {headerMessageId} = this.props
|
||||
onRemoveAttachment = file => {
|
||||
const { headerMessageId } = this.props;
|
||||
Actions.removeAttachment({
|
||||
headerMessageId: headerMessageId,
|
||||
file: file,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onDownloadAttachment = (file) => {
|
||||
Actions.fetchAndSaveFile(file)
|
||||
}
|
||||
onDownloadAttachment = file => {
|
||||
Actions.fetchAndSaveFile(file);
|
||||
};
|
||||
|
||||
onAbortDownload = (file) => {
|
||||
Actions.abortFetchFile(file)
|
||||
}
|
||||
onAbortDownload = file => {
|
||||
Actions.abortFetchFile(file);
|
||||
};
|
||||
|
||||
renderAttachment(AttachmentRenderer, file) {
|
||||
const {canRemoveAttachments, downloads, filePreviewPaths, headerMessageId} = this.props
|
||||
const download = downloads[file.id]
|
||||
const filePath = AttachmentStore.pathForFile(file)
|
||||
const fileIconName = `file-${file.displayExtension()}.png`
|
||||
const displayName = file.displayName()
|
||||
const displaySize = file.displayFileSize()
|
||||
const contentType = file.contentType
|
||||
const displayFilePreview = NylasEnv.config.get('core.attachments.displayFilePreview')
|
||||
const { canRemoveAttachments, downloads, filePreviewPaths, headerMessageId } = this.props;
|
||||
const download = downloads[file.id];
|
||||
const filePath = AttachmentStore.pathForFile(file);
|
||||
const fileIconName = `file-${file.displayExtension()}.png`;
|
||||
const displayName = file.displayName();
|
||||
const displaySize = file.displayFileSize();
|
||||
const contentType = file.contentType;
|
||||
const displayFilePreview = NylasEnv.config.get('core.attachments.displayFilePreview');
|
||||
const filePreviewPath = displayFilePreview ? filePreviewPaths[file.id] : null;
|
||||
|
||||
return (
|
||||
|
@ -68,26 +67,24 @@ class MessageAttachments extends Component {
|
|||
onOpenAttachment={() => this.onOpenAttachment(file)}
|
||||
onDownloadAttachment={() => this.onDownloadAttachment(file)}
|
||||
onAbortDownload={() => this.onAbortDownload(file)}
|
||||
onRemoveAttachment={canRemoveAttachments ? () => this.onRemoveAttachment(headerMessageId, file) : null}
|
||||
onRemoveAttachment={
|
||||
canRemoveAttachments ? () => this.onRemoveAttachment(headerMessageId, file) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {files} = this.props;
|
||||
const nonImageFiles = files.filter((f) => !Utils.shouldDisplayAsImage(f));
|
||||
const imageFiles = files.filter((f) => Utils.shouldDisplayAsImage(f));
|
||||
const { files } = this.props;
|
||||
const nonImageFiles = files.filter(f => !Utils.shouldDisplayAsImage(f));
|
||||
const imageFiles = files.filter(f => Utils.shouldDisplayAsImage(f));
|
||||
return (
|
||||
<div>
|
||||
{nonImageFiles.map((file) =>
|
||||
this.renderAttachment(AttachmentItem, file)
|
||||
)}
|
||||
{imageFiles.map((file) =>
|
||||
this.renderAttachment(ImageAttachmentItem, file)
|
||||
)}
|
||||
{nonImageFiles.map(file => this.renderAttachment(AttachmentItem, file))}
|
||||
{imageFiles.map(file => this.renderAttachment(ImageAttachmentItem, file))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageAttachments
|
||||
export default MessageAttachments;
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
/* eslint jsx-a11y/tabindex-no-positive: 0 */
|
||||
import React, {Component} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Menu,
|
||||
RetinaImg,
|
||||
LabelColorizer,
|
||||
BoldedSearchResult,
|
||||
} from 'nylas-component-kit'
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Menu, RetinaImg, LabelColorizer, BoldedSearchResult } from 'nylas-component-kit';
|
||||
import {
|
||||
Utils,
|
||||
Actions,
|
||||
|
@ -14,92 +9,92 @@ import {
|
|||
Label,
|
||||
SyncbackCategoryTask,
|
||||
ChangeLabelsTask,
|
||||
} from 'nylas-exports'
|
||||
import {Categories} from 'nylas-observables'
|
||||
|
||||
} from 'nylas-exports';
|
||||
import { Categories } from 'nylas-observables';
|
||||
|
||||
export default class LabelPickerPopover extends Component {
|
||||
|
||||
static propTypes = {
|
||||
threads: PropTypes.array.isRequired,
|
||||
account: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this._labels = []
|
||||
this.state = this._recalculateState(this.props, {searchValue: ''})
|
||||
super(props);
|
||||
this._labels = [];
|
||||
this.state = this._recalculateState(this.props, { searchValue: '' });
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._registerObservables()
|
||||
this._registerObservables();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this._registerObservables(nextProps)
|
||||
this.setState(this._recalculateState(nextProps))
|
||||
this._registerObservables(nextProps);
|
||||
this.setState(this._recalculateState(nextProps));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unregisterObservables()
|
||||
this._unregisterObservables();
|
||||
}
|
||||
|
||||
_registerObservables = (props = this.props) => {
|
||||
this._unregisterObservables()
|
||||
this._unregisterObservables();
|
||||
this.disposables = [
|
||||
Categories.forAccount(props.account).sort().subscribe(this._onLabelsChanged),
|
||||
]
|
||||
Categories.forAccount(props.account)
|
||||
.sort()
|
||||
.subscribe(this._onLabelsChanged),
|
||||
];
|
||||
};
|
||||
|
||||
_unregisterObservables = () => {
|
||||
if (this.disposables) {
|
||||
this.disposables.forEach(disp => disp.dispose())
|
||||
this.disposables.forEach(disp => disp.dispose());
|
||||
}
|
||||
};
|
||||
|
||||
_recalculateState = (props = this.props, {searchValue = (this.state.searchValue || "")} = {}) => {
|
||||
const {threads} = props
|
||||
_recalculateState = (props = this.props, { searchValue = this.state.searchValue || '' } = {}) => {
|
||||
const { threads } = props;
|
||||
|
||||
if (threads.length === 0) {
|
||||
return {categoryData: [], searchValue}
|
||||
return { categoryData: [], searchValue };
|
||||
}
|
||||
|
||||
const categoryData = this._labels.filter(label =>
|
||||
Utils.wordSearchRegExp(searchValue).test(label.displayName)
|
||||
).map((label) => {
|
||||
return {
|
||||
id: label.id,
|
||||
category: label,
|
||||
displayName: label.displayName,
|
||||
backgroundColor: LabelColorizer.backgroundColorDark(label),
|
||||
usage: threads.filter(t => t.categories.find(c => c.id === label.id)).length,
|
||||
numThreads: threads.length,
|
||||
};
|
||||
});
|
||||
const categoryData = this._labels
|
||||
.filter(label => Utils.wordSearchRegExp(searchValue).test(label.displayName))
|
||||
.map(label => {
|
||||
return {
|
||||
id: label.id,
|
||||
category: label,
|
||||
displayName: label.displayName,
|
||||
backgroundColor: LabelColorizer.backgroundColorDark(label),
|
||||
usage: threads.filter(t => t.categories.find(c => c.id === label.id)).length,
|
||||
numThreads: threads.length,
|
||||
};
|
||||
});
|
||||
|
||||
if (searchValue.length > 0) {
|
||||
categoryData.push({
|
||||
searchValue: searchValue,
|
||||
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 => {
|
||||
return (c instanceof Label) && (!c.role);
|
||||
return c instanceof Label && !c.role;
|
||||
});
|
||||
this.setState(this._recalculateState())
|
||||
this.setState(this._recalculateState());
|
||||
};
|
||||
|
||||
_onEscape = () => {
|
||||
Actions.closePopover()
|
||||
Actions.closePopover();
|
||||
};
|
||||
|
||||
_onSelectLabel = (item) => {
|
||||
const {account, threads} = this.props
|
||||
_onSelectLabel = item => {
|
||||
const { account, threads } = this.props;
|
||||
|
||||
if (threads.length === 0) return;
|
||||
|
||||
|
@ -107,52 +102,56 @@ export default class LabelPickerPopover extends Component {
|
|||
const syncbackTask = new SyncbackCategoryTask({
|
||||
path: this.state.searchValue,
|
||||
accountId: account.id,
|
||||
})
|
||||
});
|
||||
|
||||
TaskQueue.waitForPerformRemote(syncbackTask).then((finishedTask) => {
|
||||
TaskQueue.waitForPerformRemote(syncbackTask).then(finishedTask => {
|
||||
if (!finishedTask.created) {
|
||||
NylasEnv.showErrorDialog({title: "Error", message: `Could not create label.`})
|
||||
NylasEnv.showErrorDialog({ title: 'Error', message: `Could not create label.` });
|
||||
return;
|
||||
}
|
||||
Actions.queueTask(new ChangeLabelsTask({
|
||||
source: "Category Picker: New Category",
|
||||
threads: threads,
|
||||
labelsToRemove: [],
|
||||
labelsToAdd: [finishedTask.created],
|
||||
}));
|
||||
})
|
||||
Actions.queueTask(
|
||||
new ChangeLabelsTask({
|
||||
source: 'Category Picker: New Category',
|
||||
threads: threads,
|
||||
labelsToRemove: [],
|
||||
labelsToAdd: [finishedTask.created],
|
||||
})
|
||||
);
|
||||
});
|
||||
Actions.queueTask(syncbackTask);
|
||||
} else if (item.usage === threads.length) {
|
||||
Actions.queueTask(new ChangeLabelsTask({
|
||||
source: "Category Picker: Existing Category",
|
||||
threads: threads,
|
||||
labelsToRemove: [item.category],
|
||||
labelsToAdd: [],
|
||||
}));
|
||||
Actions.queueTask(
|
||||
new ChangeLabelsTask({
|
||||
source: 'Category Picker: Existing Category',
|
||||
threads: threads,
|
||||
labelsToRemove: [item.category],
|
||||
labelsToAdd: [],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
Actions.queueTask(new ChangeLabelsTask({
|
||||
source: "Category Picker: Existing Category",
|
||||
threads: threads,
|
||||
labelsToRemove: [],
|
||||
labelsToAdd: [item.category],
|
||||
}));
|
||||
Actions.queueTask(
|
||||
new ChangeLabelsTask({
|
||||
source: 'Category Picker: Existing Category',
|
||||
threads: threads,
|
||||
labelsToRemove: [],
|
||||
labelsToAdd: [item.category],
|
||||
})
|
||||
);
|
||||
}
|
||||
Actions.closePopover()
|
||||
Actions.closePopover();
|
||||
};
|
||||
|
||||
_onSearchValueChange = (event) => {
|
||||
this.setState(
|
||||
this._recalculateState(this.props, {searchValue: event.target.value})
|
||||
)
|
||||
_onSearchValueChange = event => {
|
||||
this.setState(this._recalculateState(this.props, { searchValue: event.target.value }));
|
||||
};
|
||||
|
||||
_renderCheckbox = (item) => {
|
||||
const styles = {}
|
||||
_renderCheckbox = item => {
|
||||
const styles = {};
|
||||
let checkStatus;
|
||||
styles.backgroundColor = item.backgroundColor
|
||||
styles.backgroundColor = item.backgroundColor;
|
||||
|
||||
if (item.usage === 0) {
|
||||
checkStatus = <span />
|
||||
checkStatus = <span />;
|
||||
} else if (item.usage < item.numThreads) {
|
||||
checkStatus = (
|
||||
<RetinaImg
|
||||
|
@ -161,7 +160,7 @@ export default class LabelPickerPopover extends Component {
|
|||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
onClick={() => this._onSelectLabel(item)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
checkStatus = (
|
||||
<RetinaImg
|
||||
|
@ -170,7 +169,7 @@ export default class LabelPickerPopover extends Component {
|
|||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
onClick={() => this._onSelectLabel(item)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -183,10 +182,10 @@ export default class LabelPickerPopover extends Component {
|
|||
/>
|
||||
{checkStatus}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
_renderCreateNewItem = ({searchValue}) => {
|
||||
_renderCreateNewItem = ({ searchValue }) => {
|
||||
return (
|
||||
<div className="category-item category-create-new">
|
||||
<RetinaImg
|
||||
|
@ -198,24 +197,24 @@ export default class LabelPickerPopover extends Component {
|
|||
<strong>“{searchValue}”</strong> (create new)
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
_renderItem = (item) => {
|
||||
_renderItem = item => {
|
||||
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) {
|
||||
return this._renderCreateNewItem(item)
|
||||
return this._renderCreateNewItem(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="category-item">
|
||||
{this._renderCheckbox(item)}
|
||||
<div className="category-display-name">
|
||||
<BoldedSearchResult value={item.displayName} query={this.state.searchValue || ""} />
|
||||
<BoldedSearchResult value={item.displayName} query={this.state.searchValue || ''} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -229,7 +228,7 @@ export default class LabelPickerPopover extends Component {
|
|||
value={this.state.searchValue}
|
||||
onChange={this._onSearchValueChange}
|
||||
/>,
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="category-picker-popover">
|
||||
|
@ -241,9 +240,9 @@ export default class LabelPickerPopover extends Component {
|
|||
itemContent={this._renderItem}
|
||||
onSelect={this._onSelectLabel}
|
||||
onEscape={this._onEscape}
|
||||
defaultSelectedIndex={this.state.searchValue === "" ? -1 : 0}
|
||||
defaultSelectedIndex={this.state.searchValue === '' ? -1 : 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
ReactDOM = require 'react-dom'
|
||||
|
||||
{Actions,
|
||||
AccountStore,
|
||||
React, ReactDOM, PropTypes,
|
||||
WorkspaceStore} = require 'nylas-exports'
|
||||
|
||||
{RetinaImg,
|
||||
|
@ -19,10 +18,10 @@ class LabelPicker extends React.Component
|
|||
@containerRequired: false
|
||||
|
||||
@propTypes:
|
||||
items: React.PropTypes.array
|
||||
items: PropTypes.array
|
||||
|
||||
@contextTypes:
|
||||
sheetDepth: React.PropTypes.number
|
||||
sheetDepth: PropTypes.number
|
||||
|
||||
constructor: (@props) ->
|
||||
@_account = AccountStore.accountForItems(@props.items)
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
/* eslint jsx-a11y/tabindex-no-positive: 0 */
|
||||
import React, {Component} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {
|
||||
Menu,
|
||||
RetinaImg,
|
||||
LabelColorizer,
|
||||
BoldedSearchResult,
|
||||
} from 'nylas-component-kit'
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Menu, RetinaImg, LabelColorizer, BoldedSearchResult } from 'nylas-component-kit';
|
||||
import {
|
||||
Utils,
|
||||
Actions,
|
||||
|
@ -17,12 +12,10 @@ import {
|
|||
ChangeFolderTask,
|
||||
ChangeLabelsTask,
|
||||
FocusedPerspectiveStore,
|
||||
} from 'nylas-exports'
|
||||
import {Categories} from 'nylas-observables'
|
||||
|
||||
} from 'nylas-exports';
|
||||
import { Categories } from 'nylas-observables';
|
||||
|
||||
export default class MovePickerPopover extends Component {
|
||||
|
||||
static propTypes = {
|
||||
threads: PropTypes.array.isRequired,
|
||||
account: PropTypes.object.isRequired,
|
||||
|
@ -32,7 +25,7 @@ export default class MovePickerPopover extends Component {
|
|||
super(props);
|
||||
this._standardFolders = [];
|
||||
this._userCategories = [];
|
||||
this.state = this._recalculateState(this.props, {searchValue: ''});
|
||||
this.state = this._recalculateState(this.props, { searchValue: '' });
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -51,7 +44,9 @@ export default class MovePickerPopover extends Component {
|
|||
_registerObservables = (props = this.props) => {
|
||||
this._unregisterObservables();
|
||||
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 || "")} = {}) => {
|
||||
const {threads, account} = props
|
||||
_recalculateState = (props = this.props, { searchValue = this.state.searchValue || '' } = {}) => {
|
||||
const { threads, account } = props;
|
||||
if (threads.length === 0) {
|
||||
return {categoryData: [], searchValue}
|
||||
return { categoryData: [], searchValue };
|
||||
}
|
||||
|
||||
const currentCategories = FocusedPerspectiveStore.current().categories() || [];
|
||||
const currentCategoryIds = currentCategories.map(c => c.id);
|
||||
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) {
|
||||
hidden.push('all');
|
||||
|
@ -78,18 +73,17 @@ export default class MovePickerPopover extends Component {
|
|||
|
||||
const categoryData = []
|
||||
.concat(this._standardFolders)
|
||||
.concat([{divider: true, id: "category-divider"}])
|
||||
.concat([{ divider: true, id: 'category-divider' }])
|
||||
.concat(this._userCategories)
|
||||
.filter((cat) =>
|
||||
// remove categories that are part of the current perspective or locked
|
||||
!hidden.includes(cat.role) && !currentCategoryIds.includes(cat.id)
|
||||
.filter(
|
||||
cat =>
|
||||
// remove categories that are part of the current perspective or locked
|
||||
!hidden.includes(cat.role) && !currentCategoryIds.includes(cat.id)
|
||||
)
|
||||
.filter((cat) =>
|
||||
Utils.wordSearchRegExp(searchValue).test(cat.displayName)
|
||||
)
|
||||
.map((cat) => {
|
||||
.filter(cat => Utils.wordSearchRegExp(searchValue).test(cat.displayName))
|
||||
.map(cat => {
|
||||
if (cat.divider) {
|
||||
return cat
|
||||
return cat;
|
||||
}
|
||||
return {
|
||||
id: cat.id,
|
||||
|
@ -103,24 +97,24 @@ export default class MovePickerPopover extends Component {
|
|||
const newItemData = {
|
||||
searchValue: searchValue,
|
||||
newCategoryItem: true,
|
||||
id: "category-create-new",
|
||||
}
|
||||
categoryData.push(newItemData)
|
||||
id: 'category-create-new',
|
||||
};
|
||||
categoryData.push(newItemData);
|
||||
}
|
||||
return {categoryData, searchValue}
|
||||
return { categoryData, searchValue };
|
||||
};
|
||||
|
||||
_onCategoriesChanged = (categories) => {
|
||||
_onCategoriesChanged = categories => {
|
||||
this._standardFolders = 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 = () => {
|
||||
Actions.closePopover()
|
||||
Actions.closePopover();
|
||||
};
|
||||
|
||||
_onSelectCategory = (item) => {
|
||||
_onSelectCategory = item => {
|
||||
if (this.props.threads.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
@ -132,7 +126,7 @@ export default class MovePickerPopover extends Component {
|
|||
}
|
||||
Actions.popSheet();
|
||||
Actions.closePopover();
|
||||
}
|
||||
};
|
||||
|
||||
_onCreateCategory = () => {
|
||||
const syncbackTask = new SyncbackCategoryTask({
|
||||
|
@ -140,46 +134,49 @@ export default class MovePickerPopover extends Component {
|
|||
accountId: this.props.account.id,
|
||||
});
|
||||
|
||||
TaskQueue.waitForPerformRemote(syncbackTask).then((finishedTask) => {
|
||||
TaskQueue.waitForPerformRemote(syncbackTask).then(finishedTask => {
|
||||
if (!finishedTask.created) {
|
||||
NylasEnv.showErrorDialog({title: "Error", message: `Could not create folder.`})
|
||||
NylasEnv.showErrorDialog({ title: 'Error', message: `Could not create folder.` });
|
||||
return;
|
||||
}
|
||||
this._onMoveToCategory({category: finishedTask.created});
|
||||
this._onMoveToCategory({ category: finishedTask.created });
|
||||
});
|
||||
Actions.queueTask(syncbackTask);
|
||||
}
|
||||
};
|
||||
|
||||
_onMoveToCategory = ({category}) => {
|
||||
const {threads} = this.props
|
||||
_onMoveToCategory = ({ category }) => {
|
||||
const { threads } = this.props;
|
||||
|
||||
if (category instanceof Folder) {
|
||||
Actions.queueTask(new ChangeFolderTask({
|
||||
source: "Category Picker: New Category",
|
||||
threads: threads,
|
||||
folder: category,
|
||||
}));
|
||||
Actions.queueTask(
|
||||
new ChangeFolderTask({
|
||||
source: 'Category Picker: New Category',
|
||||
threads: threads,
|
||||
folder: category,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const all = [];
|
||||
threads.forEach(({labels}) => all.push(...labels));
|
||||
threads.forEach(({ labels }) => all.push(...labels));
|
||||
|
||||
Actions.queueTask(new ChangeLabelsTask({
|
||||
source: "Category Picker: New Category",
|
||||
labelsToRemove: all,
|
||||
labelsToAdd: [category],
|
||||
threads: threads,
|
||||
}));
|
||||
Actions.queueTask(
|
||||
new ChangeLabelsTask({
|
||||
source: 'Category Picker: New Category',
|
||||
labelsToRemove: all,
|
||||
labelsToAdd: [category],
|
||||
threads: threads,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_onSearchValueChange = (event) => {
|
||||
this.setState(
|
||||
this._recalculateState(this.props, {searchValue: event.target.value})
|
||||
)
|
||||
_onSearchValueChange = event => {
|
||||
this.setState(this._recalculateState(this.props, { searchValue: event.target.value }));
|
||||
};
|
||||
|
||||
_renderCreateNewItem = ({searchValue}) => {
|
||||
const icon = CategoryStore.getInboxCategory(this.props.account) instanceof Folder ? 'folder' : 'tag';
|
||||
_renderCreateNewItem = ({ searchValue }) => {
|
||||
const icon =
|
||||
CategoryStore.getInboxCategory(this.props.account) instanceof Folder ? 'folder' : 'tag';
|
||||
|
||||
return (
|
||||
<div className="category-item category-create-new">
|
||||
|
@ -192,37 +189,35 @@ export default class MovePickerPopover extends Component {
|
|||
<strong>“{searchValue}”</strong> (create new)
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
_renderItem = (item) => {
|
||||
_renderItem = item => {
|
||||
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) {
|
||||
return this._renderCreateNewItem(item)
|
||||
return this._renderCreateNewItem(item);
|
||||
}
|
||||
|
||||
const icon = (item.category instanceof Folder) ? (
|
||||
<RetinaImg
|
||||
name={`${item.name}.png`}
|
||||
fallback={'folder.png'}
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
) : (
|
||||
<RetinaImg
|
||||
name={`tag.png`}
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
);
|
||||
const icon =
|
||||
item.category instanceof Folder ? (
|
||||
<RetinaImg
|
||||
name={`${item.name}.png`}
|
||||
fallback={'folder.png'}
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
) : (
|
||||
<RetinaImg name={`tag.png`} mode={RetinaImg.Mode.ContentIsMask} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="category-item">
|
||||
{icon}
|
||||
<div className="category-display-name">
|
||||
<BoldedSearchResult value={item.displayName} query={this.state.searchValue || ""} />
|
||||
<BoldedSearchResult value={item.displayName} query={this.state.searchValue || ''} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -236,7 +231,7 @@ export default class MovePickerPopover extends Component {
|
|||
value={this.state.searchValue}
|
||||
onChange={this._onSearchValueChange}
|
||||
/>,
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="category-picker-popover">
|
||||
|
@ -248,9 +243,9 @@ export default class MovePickerPopover extends Component {
|
|||
itemContent={this._renderItem}
|
||||
onSelect={this._onSelectCategory}
|
||||
onEscape={this._onEscape}
|
||||
defaultSelectedIndex={this.state.searchValue === "" ? -1 : 0}
|
||||
defaultSelectedIndex={this.state.searchValue === '' ? -1 : 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
ReactDOM = require 'react-dom'
|
||||
|
||||
{Actions,
|
||||
React, ReactDOM, PropTypes,
|
||||
AccountStore,
|
||||
WorkspaceStore} = require 'nylas-exports'
|
||||
|
||||
|
@ -19,10 +18,10 @@ class MovePicker extends React.Component
|
|||
@containerRequired: false
|
||||
|
||||
@propTypes:
|
||||
items: React.PropTypes.array
|
||||
items: PropTypes.array
|
||||
|
||||
@contextTypes:
|
||||
sheetDepth: React.PropTypes.number
|
||||
sheetDepth: PropTypes.number
|
||||
|
||||
constructor: (@props) ->
|
||||
@_account = AccountStore.accountForItems(@props.items)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const categorizedEmojiList = {
|
||||
'People': [
|
||||
People: [
|
||||
'grinning',
|
||||
'grimacing',
|
||||
'grin',
|
||||
|
@ -205,7 +205,7 @@ const categorizedEmojiList = {
|
|||
'ring',
|
||||
'closed_umbrella',
|
||||
],
|
||||
'Nature': [
|
||||
Nature: [
|
||||
'dog',
|
||||
'cat',
|
||||
'mouse',
|
||||
|
@ -422,7 +422,7 @@ const categorizedEmojiList = {
|
|||
'fork_and_knife',
|
||||
'knife_fork_plate',
|
||||
],
|
||||
'Activity': [
|
||||
Activity: [
|
||||
'soccer',
|
||||
'basketball',
|
||||
'football',
|
||||
|
@ -598,7 +598,7 @@ const categorizedEmojiList = {
|
|||
'kaaba',
|
||||
'shinto_shrine',
|
||||
],
|
||||
'Objects': [
|
||||
Objects: [
|
||||
'watch',
|
||||
'iphone',
|
||||
'calling',
|
||||
|
@ -777,7 +777,7 @@ const categorizedEmojiList = {
|
|||
'mag',
|
||||
'mag_right',
|
||||
],
|
||||
'Symbols': [
|
||||
Symbols: [
|
||||
'heart',
|
||||
'yellow_heart',
|
||||
'green_heart',
|
||||
|
@ -1048,7 +1048,7 @@ const categorizedEmojiList = {
|
|||
'clock1130',
|
||||
'clock1230',
|
||||
],
|
||||
'Flags': [
|
||||
Flags: [
|
||||
'flag-ac',
|
||||
'flag-ad',
|
||||
'flag-ae',
|
||||
|
@ -1307,5 +1307,5 @@ const categorizedEmojiList = {
|
|||
'flag-zm',
|
||||
'flag-zw',
|
||||
],
|
||||
}
|
||||
export default categorizedEmojiList
|
||||
};
|
||||
export default categorizedEmojiList;
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import Reflux from 'reflux';
|
||||
|
||||
const EmojiActions = Reflux.createActions([
|
||||
"selectEmoji",
|
||||
"useEmoji",
|
||||
]);
|
||||
const EmojiActions = Reflux.createActions(['selectEmoji', 'useEmoji']);
|
||||
|
||||
for (const key of Object.keys(EmojiActions)) {
|
||||
EmojiActions[key].sync = true;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import {Actions} from 'nylas-exports';
|
||||
import {RetinaImg, ScrollRegion} from 'nylas-component-kit';
|
||||
import { Actions } from 'nylas-exports';
|
||||
import { RetinaImg, ScrollRegion } from 'nylas-component-kit';
|
||||
|
||||
import EmojiStore from './emoji-store';
|
||||
import EmojiActions from './emoji-actions';
|
||||
|
@ -11,15 +11,13 @@ class EmojiButtonPopover extends React.Component {
|
|||
|
||||
constructor() {
|
||||
super();
|
||||
const {categoryNames,
|
||||
categorizedEmoji,
|
||||
categoryPositions} = this.getStateFromStore();
|
||||
const { categoryNames, categorizedEmoji, categoryPositions } = this.getStateFromStore();
|
||||
this.state = {
|
||||
emojiName: "Emoji Picker",
|
||||
emojiName: 'Emoji Picker',
|
||||
categoryNames: categoryNames,
|
||||
categorizedEmoji: categorizedEmoji,
|
||||
categoryPositions: categoryPositions,
|
||||
searchValue: "",
|
||||
searchValue: '',
|
||||
activeTab: Object.keys(categorizedEmoji)[0],
|
||||
};
|
||||
}
|
||||
|
@ -36,69 +34,77 @@ class EmojiButtonPopover extends React.Component {
|
|||
this._mounted = false;
|
||||
}
|
||||
|
||||
onMouseDown = (event) => {
|
||||
onMouseDown = event => {
|
||||
const emojiName = this.calcEmojiByPosition(this.calcPosition(event));
|
||||
if (!emojiName) return null;
|
||||
EmojiActions.selectEmoji({emojiName: emojiName, replaceSelection: false});
|
||||
EmojiActions.selectEmoji({ emojiName: emojiName, replaceSelection: false });
|
||||
Actions.closePopover();
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
onScroll = () => {
|
||||
const emojiContainer = document.querySelector(".emoji-finder-container .scroll-region-content");
|
||||
const tabContainer = document.querySelector(".emoji-tabs");
|
||||
tabContainer.className = emojiContainer.scrollTop ? "emoji-tabs shadow" : "emoji-tabs";
|
||||
const emojiContainer = document.querySelector('.emoji-finder-container .scroll-region-content');
|
||||
const tabContainer = document.querySelector('.emoji-tabs');
|
||||
tabContainer.className = emojiContainer.scrollTop ? 'emoji-tabs shadow' : 'emoji-tabs';
|
||||
if (emojiContainer.scrollTop === 0) {
|
||||
this.setState({activeTab: Object.keys(this.state.categorizedEmoji)[0]});
|
||||
this.setState({ activeTab: Object.keys(this.state.categorizedEmoji)[0] });
|
||||
} else {
|
||||
for (const category of Object.keys(this.state.categoryPositions)) {
|
||||
if (emojiContainer.scrollTop >= this.state.categoryPositions[category].top &&
|
||||
emojiContainer.scrollTop <= this.state.categoryPositions[category].bottom) {
|
||||
this.setState({activeTab: category});
|
||||
if (
|
||||
emojiContainer.scrollTop >= this.state.categoryPositions[category].top &&
|
||||
emojiContainer.scrollTop <= this.state.categoryPositions[category].bottom
|
||||
) {
|
||||
this.setState({ activeTab: category });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onHover = (event) => {
|
||||
onHover = event => {
|
||||
const emojiName = this.calcEmojiByPosition(this.calcPosition(event));
|
||||
if (emojiName) {
|
||||
this.setState({emojiName: emojiName});
|
||||
this.setState({ emojiName: emojiName });
|
||||
} else {
|
||||
this.setState({emojiName: "Emoji Picker"});
|
||||
this.setState({ emojiName: 'Emoji Picker' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMouseOut = () => {
|
||||
this.setState({emojiName: "Emoji Picker"});
|
||||
}
|
||||
this.setState({ emojiName: 'Emoji Picker' });
|
||||
};
|
||||
|
||||
onChange = (event) => {
|
||||
onChange = event => {
|
||||
const searchValue = event.target.value;
|
||||
if (searchValue.length > 0) {
|
||||
const searchMatches = this.findSearchMatches(searchValue);
|
||||
this.setState({
|
||||
categorizedEmoji: {
|
||||
'Search Results': searchMatches,
|
||||
},
|
||||
categoryPositions: {
|
||||
'Search Results': {
|
||||
top: 25,
|
||||
bottom: 25 + Math.ceil(searchMatches.length / 8) * 24,
|
||||
this.setState(
|
||||
{
|
||||
categorizedEmoji: {
|
||||
'Search Results': searchMatches,
|
||||
},
|
||||
categoryPositions: {
|
||||
'Search Results': {
|
||||
top: 25,
|
||||
bottom: 25 + Math.ceil(searchMatches.length / 8) * 24,
|
||||
},
|
||||
},
|
||||
searchValue: searchValue,
|
||||
activeTab: null,
|
||||
},
|
||||
searchValue: searchValue,
|
||||
activeTab: null,
|
||||
}, this.renderCanvas);
|
||||
this.renderCanvas
|
||||
);
|
||||
} else {
|
||||
this.setState(this.getStateFromStore, () => {
|
||||
this.setState({
|
||||
searchValue: searchValue,
|
||||
activeTab: Object.keys(this.state.categorizedEmoji)[0],
|
||||
}, this.renderCanvas);
|
||||
this.setState(
|
||||
{
|
||||
searchValue: searchValue,
|
||||
activeTab: Object.keys(this.state.categorizedEmoji)[0],
|
||||
},
|
||||
this.renderCanvas
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
getStateFromStore = () => {
|
||||
let categorizedEmoji = categorizedEmojiList;
|
||||
|
@ -115,16 +121,16 @@ class EmojiButtonPopover extends React.Component {
|
|||
];
|
||||
const frequentlyUsedEmoji = EmojiStore.frequentlyUsedEmoji();
|
||||
if (frequentlyUsedEmoji.length > 0) {
|
||||
categorizedEmoji = {'Frequently Used': frequentlyUsedEmoji};
|
||||
categorizedEmoji = { 'Frequently Used': frequentlyUsedEmoji };
|
||||
for (const category of Object.keys(categorizedEmojiList)) {
|
||||
categorizedEmoji[category] = categorizedEmojiList[category];
|
||||
}
|
||||
categoryNames = ["Frequently Used"].concat(categoryNames);
|
||||
categoryNames = ['Frequently Used'].concat(categoryNames);
|
||||
}
|
||||
// Calculates where each category should be (variable because Frequently
|
||||
// Used may or may not be present)
|
||||
for (const name of categoryNames) {
|
||||
categoryPositions[name] = {top: 0, bottom: 0};
|
||||
categoryPositions[name] = { top: 0, bottom: 0 };
|
||||
}
|
||||
let verticalPos = 25;
|
||||
for (const category of Object.keys(categoryPositions)) {
|
||||
|
@ -139,12 +145,12 @@ class EmojiButtonPopover extends React.Component {
|
|||
categorizedEmoji: categorizedEmoji,
|
||||
categoryPositions: categoryPositions,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
this.setState({searchValue: ""});
|
||||
this.setState({ searchValue: '' });
|
||||
this.setState(this.getStateFromStore, () => {
|
||||
this.renderCanvas();
|
||||
container.scrollTop = this.state.categoryPositions[category].top + 16;
|
||||
|
@ -152,14 +158,14 @@ class EmojiButtonPopover extends React.Component {
|
|||
} else {
|
||||
container.scrollTop = this.state.categoryPositions[category].top + 16;
|
||||
}
|
||||
this.setState({activeTab: category})
|
||||
this.setState({ activeTab: category });
|
||||
}
|
||||
|
||||
findSearchMatches(searchValue) {
|
||||
// TODO: Find matches for aliases, too.
|
||||
const searchMatches = [];
|
||||
for (const category of Object.keys(categorizedEmojiList)) {
|
||||
categorizedEmojiList[category].forEach((emojiName) => {
|
||||
categorizedEmojiList[category].forEach(emojiName => {
|
||||
if (emojiName.indexOf(searchValue) !== -1) {
|
||||
searchMatches.push(emojiName);
|
||||
}
|
||||
|
@ -177,39 +183,43 @@ class EmojiButtonPopover extends React.Component {
|
|||
return position;
|
||||
}
|
||||
|
||||
calcEmojiByPosition = (position) => {
|
||||
calcEmojiByPosition = position => {
|
||||
for (const category of Object.keys(this.state.categoryPositions)) {
|
||||
const LEFT_BOUNDARY = 8;
|
||||
const RIGHT_BOUNDARY = 204;
|
||||
const EMOJI_WIDTH = 24.5;
|
||||
const EMOJI_HEIGHT = 24;
|
||||
const EMOJI_PER_ROW = 8;
|
||||
if (position.x >= LEFT_BOUNDARY &&
|
||||
position.x <= RIGHT_BOUNDARY &&
|
||||
position.y >= this.state.categoryPositions[category].top &&
|
||||
position.y <= this.state.categoryPositions[category].bottom) {
|
||||
if (
|
||||
position.x >= LEFT_BOUNDARY &&
|
||||
position.x <= RIGHT_BOUNDARY &&
|
||||
position.y >= this.state.categoryPositions[category].top &&
|
||||
position.y <= this.state.categoryPositions[category].bottom
|
||||
) {
|
||||
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;
|
||||
return this.state.categorizedEmoji[category][index];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
renderTabs() {
|
||||
const tabs = [];
|
||||
this.state.categoryNames.forEach((category) => {
|
||||
let className = `emoji-tab ${(category.replace(/ /g, '-')).toLowerCase()}`
|
||||
this.state.categoryNames.forEach(category => {
|
||||
let className = `emoji-tab ${category.replace(/ /g, '-').toLowerCase()}`;
|
||||
if (category === this.state.activeTab) {
|
||||
className += " active";
|
||||
className += ' active';
|
||||
}
|
||||
tabs.push(
|
||||
<div key={`${category} container`} style={{flex: 1}}>
|
||||
<div key={`${category} container`} style={{ flex: 1 }}>
|
||||
<RetinaImg
|
||||
key={`${category} tab`}
|
||||
className={className}
|
||||
name={`icon-emojipicker-${(category.replace(/ /g, '-')).toLowerCase()}.png`}
|
||||
name={`icon-emojipicker-${category.replace(/ /g, '-').toLowerCase()}.png`}
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
onMouseDown={() => this.scrollToCategory(category)}
|
||||
/>
|
||||
|
@ -222,14 +232,14 @@ class EmojiButtonPopover extends React.Component {
|
|||
renderCanvas() {
|
||||
const keys = Object.keys(this.state.categoryPositions);
|
||||
this._canvasEl.height = this.state.categoryPositions[keys[keys.length - 1]].bottom * 2;
|
||||
const ctx = this._canvasEl.getContext("2d");
|
||||
ctx.font = "24px Nylas-Pro";
|
||||
const ctx = this._canvasEl.getContext('2d');
|
||||
ctx.font = '24px Nylas-Pro';
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.clearRect(0, 0, this._canvasEl.width, this._canvasEl.height);
|
||||
const position = {
|
||||
x: 15,
|
||||
y: 45,
|
||||
}
|
||||
};
|
||||
|
||||
let idx = 0;
|
||||
const categoryNames = Object.keys(this.state.categorizedEmoji);
|
||||
|
@ -238,12 +248,12 @@ class EmojiButtonPopover extends React.Component {
|
|||
if (!this._mounted) return;
|
||||
this.renderCategory(categoryNames[idx], idx, ctx, position, renderNextCategory);
|
||||
idx += 1;
|
||||
}
|
||||
};
|
||||
renderNextCategory();
|
||||
}
|
||||
|
||||
renderCategory(category, i, ctx, pos, callback) {
|
||||
const position = pos
|
||||
const position = pos;
|
||||
if (i > 0) {
|
||||
position.x = 18;
|
||||
position.y += 48;
|
||||
|
@ -267,10 +277,10 @@ class EmojiButtonPopover extends React.Component {
|
|||
position.x += 50;
|
||||
}
|
||||
|
||||
return {src, x, y};
|
||||
return { src, x, y };
|
||||
});
|
||||
|
||||
const drawEmojiAt = ({src, x, y} = {}) => {
|
||||
const drawEmojiAt = ({ src, x, y } = {}) => {
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
@ -282,9 +292,9 @@ class EmojiButtonPopover extends React.Component {
|
|||
} else {
|
||||
drawEmojiAt(emojiToDraw.shift());
|
||||
}
|
||||
}
|
||||
};
|
||||
this._emojiPreloadImage.src = src;
|
||||
}
|
||||
};
|
||||
|
||||
drawEmojiAt(emojiToDraw.shift());
|
||||
}
|
||||
|
@ -292,13 +302,8 @@ class EmojiButtonPopover extends React.Component {
|
|||
render() {
|
||||
return (
|
||||
<div className="emoji-button-popover" tabIndex="-1">
|
||||
<div className="emoji-tabs">
|
||||
{this.renderTabs()}
|
||||
</div>
|
||||
<ScrollRegion
|
||||
className="emoji-finder-container"
|
||||
onScroll={this.onScroll}
|
||||
>
|
||||
<div className="emoji-tabs">{this.renderTabs()}</div>
|
||||
<ScrollRegion className="emoji-finder-container" onScroll={this.onScroll}>
|
||||
<div className="emoji-search-container">
|
||||
<input
|
||||
type="text"
|
||||
|
@ -308,18 +313,18 @@ class EmojiButtonPopover extends React.Component {
|
|||
/>
|
||||
</div>
|
||||
<canvas
|
||||
ref={(el) => { this._canvasEl = el; }}
|
||||
ref={el => {
|
||||
this._canvasEl = el;
|
||||
}}
|
||||
width="400"
|
||||
height="2000"
|
||||
onMouseDown={this.onMouseDown}
|
||||
onMouseOut={this.onMouseOut}
|
||||
onMouseMove={this.onHover}
|
||||
style={{zoom: "0.5"}}
|
||||
style={{ zoom: '0.5' }}
|
||||
/>
|
||||
</ScrollRegion>
|
||||
<div className="emoji-name">
|
||||
{this.state.emojiName}
|
||||
</div>
|
||||
<div className="emoji-name">{this.state.emojiName}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
import {Actions, React, ReactDOM} from 'nylas-exports';
|
||||
import {RetinaImg} from 'nylas-component-kit';
|
||||
import { Actions, React, ReactDOM } from 'nylas-exports';
|
||||
import { RetinaImg } from 'nylas-component-kit';
|
||||
|
||||
import EmojiButtonPopover from './emoji-button-popover';
|
||||
|
||||
|
||||
class EmojiButton extends React.Component {
|
||||
static displayName = 'EmojiButton';
|
||||
|
||||
onClick = () => {
|
||||
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
Actions.openPopover(
|
||||
<EmojiButtonPopover />,
|
||||
{originRect: buttonRect, direction: 'up'}
|
||||
)
|
||||
}
|
||||
Actions.openPopover(<EmojiButtonPopover />, { originRect: buttonRect, direction: 'up' });
|
||||
};
|
||||
|
||||
render() {
|
||||
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} />
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -1,116 +1,119 @@
|
|||
import {DOMUtils, ComposerExtension, RegExpUtils} from 'nylas-exports';
|
||||
import { DOMUtils, ComposerExtension, RegExpUtils } from 'nylas-exports';
|
||||
import emoji from 'node-emoji';
|
||||
|
||||
import EmojiStore from './emoji-store';
|
||||
import EmojiActions from './emoji-actions';
|
||||
import EmojiPicker from './emoji-picker';
|
||||
|
||||
|
||||
class EmojiComposerExtension extends ComposerExtension {
|
||||
|
||||
static selState = null;
|
||||
|
||||
static onContentChanged = ({editor}) => {
|
||||
const sel = editor.currentSelection()
|
||||
const {emojiOptions, triggerWord} = EmojiComposerExtension._findEmojiOptions(sel);
|
||||
static onContentChanged = ({ editor }) => {
|
||||
const sel = editor.currentSelection();
|
||||
const { emojiOptions, triggerWord } = EmojiComposerExtension._findEmojiOptions(sel);
|
||||
if (sel.anchorNode && sel.isCollapsed) {
|
||||
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);
|
||||
editor.select(sel.anchorNode,
|
||||
anchorOffset,
|
||||
sel.focusNode,
|
||||
sel.focusOffset)
|
||||
|
||||
DOMUtils.wrap(sel.getRangeAt(0), "n1-emoji-autocomplete");
|
||||
editor.select(sel.anchorNode, anchorOffset, sel.focusNode, sel.focusOffset);
|
||||
|
||||
DOMUtils.wrap(sel.getRangeAt(0), 'n1-emoji-autocomplete');
|
||||
editor.currentSelection().collapseToEnd();
|
||||
}
|
||||
} else {
|
||||
if (DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")) {
|
||||
editor.unwrapNodeAndSelectAll(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.currentSelection().collapseToEnd();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")) {
|
||||
editor.unwrapNodeAndSelectAll(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.currentSelection().collapseToEnd();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static onBlur = ({editor}) => {
|
||||
static onBlur = ({ editor }) => {
|
||||
EmojiComposerExtension.selState = editor.currentSelection().exportSelection();
|
||||
};
|
||||
|
||||
static onFocus = ({editor}) => {
|
||||
static onFocus = ({ editor }) => {
|
||||
if (EmojiComposerExtension.selState) {
|
||||
editor.select(EmojiComposerExtension.selState);
|
||||
EmojiComposerExtension.selState = null;
|
||||
}
|
||||
};
|
||||
|
||||
static toolbarComponentConfig = ({toolbarState}) => {
|
||||
static toolbarComponentConfig = ({ toolbarState }) => {
|
||||
const sel = toolbarState.selectionSnapshot;
|
||||
if (sel) {
|
||||
const {emojiOptions} = EmojiComposerExtension._findEmojiOptions(sel);
|
||||
const { emojiOptions } = EmojiComposerExtension._findEmojiOptions(sel);
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
const selectedEmoji = locationRefNode.getAttribute("selectedEmoji");
|
||||
const selectedEmoji = locationRefNode.getAttribute('selectedEmoji');
|
||||
return {
|
||||
component: EmojiPicker,
|
||||
props: {emojiOptions,
|
||||
selectedEmoji},
|
||||
props: {
|
||||
emojiOptions,
|
||||
selectedEmoji,
|
||||
},
|
||||
locationRefNode: locationRefNode,
|
||||
width: EmojiComposerExtension._emojiPickerWidth(emojiOptions),
|
||||
height: EmojiComposerExtension._emojiPickerHeight(emojiOptions),
|
||||
hidePointer: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
static editingActions = () => {
|
||||
return [{
|
||||
action: EmojiActions.selectEmoji,
|
||||
callback: EmojiComposerExtension._onSelectEmoji,
|
||||
}]
|
||||
return [
|
||||
{
|
||||
action: EmojiActions.selectEmoji,
|
||||
callback: EmojiComposerExtension._onSelectEmoji,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
static onKeyDown = ({editor, event}) => {
|
||||
const sel = editor.currentSelection()
|
||||
const {emojiOptions} = EmojiComposerExtension._findEmojiOptions(sel);
|
||||
static onKeyDown = ({ editor, event }) => {
|
||||
const sel = editor.currentSelection();
|
||||
const { emojiOptions } = EmojiComposerExtension._findEmojiOptions(sel);
|
||||
if (emojiOptions.length > 0) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight" ||
|
||||
event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
if (
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowLeft'
|
||||
) {
|
||||
event.preventDefault();
|
||||
const moveToNext = (event.key === "ArrowDown" || event.key === "ArrowRight");
|
||||
const emojiNameNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete");
|
||||
const moveToNext = event.key === 'ArrowDown' || event.key === 'ArrowRight';
|
||||
const emojiNameNode = DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete');
|
||||
if (!emojiNameNode) return null;
|
||||
const selectedEmoji = emojiNameNode.getAttribute("selectedEmoji");
|
||||
const selectedEmoji = emojiNameNode.getAttribute('selectedEmoji');
|
||||
if (selectedEmoji) {
|
||||
const emojiIndex = emojiOptions.indexOf(selectedEmoji);
|
||||
if (emojiIndex < emojiOptions.length - 1 && moveToNext) {
|
||||
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[emojiIndex + 1]);
|
||||
emojiNameNode.setAttribute('selectedEmoji', emojiOptions[emojiIndex + 1]);
|
||||
} else if (emojiIndex > 0 && !moveToNext) {
|
||||
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[emojiIndex - 1]);
|
||||
emojiNameNode.setAttribute('selectedEmoji', emojiOptions[emojiIndex - 1]);
|
||||
} else {
|
||||
const index = moveToNext ? 0 : emojiOptions.length - 1;
|
||||
emojiNameNode.setAttribute("selectedEmoji", emojiOptions[index]);
|
||||
emojiNameNode.setAttribute('selectedEmoji', emojiOptions[index]);
|
||||
}
|
||||
} else {
|
||||
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();
|
||||
const emojiNameNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete");
|
||||
const emojiNameNode = DOMUtils.closest(sel.anchorNode, 'n1-emoji-autocomplete');
|
||||
if (!emojiNameNode) return null;
|
||||
let selectedEmoji = emojiNameNode.getAttribute("selectedEmoji");
|
||||
let selectedEmoji = emojiNameNode.getAttribute('selectedEmoji');
|
||||
if (!selectedEmoji) selectedEmoji = emojiOptions[0];
|
||||
const args = {
|
||||
editor: editor,
|
||||
|
@ -125,8 +128,8 @@ class EmojiComposerExtension extends ComposerExtension {
|
|||
return null;
|
||||
};
|
||||
|
||||
static applyTransformsForSending = ({draftBodyRootNode}) => {
|
||||
const imgs = draftBodyRootNode.querySelectorAll('img')
|
||||
static applyTransformsForSending = ({ draftBodyRootNode }) => {
|
||||
const imgs = draftBodyRootNode.querySelectorAll('img');
|
||||
for (const imgEl of Array.from(imgs)) {
|
||||
const names = imgEl.className.split(' ');
|
||||
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);
|
||||
while (treeWalker.nextNode()) {
|
||||
const textNode = treeWalker.currentNode;
|
||||
|
@ -148,7 +151,7 @@ class EmojiComposerExtension extends ComposerExtension {
|
|||
emojiPlusTrailingEl.splitText(match.length);
|
||||
const emojiEl = emojiPlusTrailingEl;
|
||||
const imgEl = document.createElement('img');
|
||||
const emojiName = emoji.which(match[0])
|
||||
const emojiName = emoji.which(match[0]);
|
||||
imgEl.className = `emoji ${emojiName}`;
|
||||
imgEl.src = EmojiStore.getImagePath(emojiName);
|
||||
imgEl.width = '14';
|
||||
|
@ -157,61 +160,73 @@ class EmojiComposerExtension extends ComposerExtension {
|
|||
emojiEl.parentNode.replaceChild(imgEl, emojiEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static _findEmojiOptions(sel) {
|
||||
if (sel.anchorNode &&
|
||||
sel.anchorNode.nodeValue &&
|
||||
sel.anchorNode.nodeValue.length > 0 &&
|
||||
sel.isCollapsed) {
|
||||
if (
|
||||
sel.anchorNode &&
|
||||
sel.anchorNode.nodeValue &&
|
||||
sel.anchorNode.nodeValue.length > 0 &&
|
||||
sel.isCollapsed
|
||||
) {
|
||||
const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset);
|
||||
let index = words.lastIndexOf(":");
|
||||
let lastWord = "";
|
||||
if (index !== -1 && words.lastIndexOf(" ") < index) {
|
||||
let index = words.lastIndexOf(':');
|
||||
let lastWord = '';
|
||||
if (index !== -1 && words.lastIndexOf(' ') < index) {
|
||||
lastWord = words.substring(index + 1, sel.anchorOffset);
|
||||
} else {
|
||||
const {text} = EmojiComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset);
|
||||
index = text.lastIndexOf(":");
|
||||
if (index !== -1 && text.lastIndexOf(" ") < index) {
|
||||
const { text } = EmojiComposerExtension._getTextUntilSpace(
|
||||
sel.anchorNode,
|
||||
sel.anchorOffset
|
||||
);
|
||||
index = text.lastIndexOf(':');
|
||||
if (index !== -1 && text.lastIndexOf(' ') < index) {
|
||||
lastWord = text.substring(index + 1);
|
||||
} else {
|
||||
return {triggerWord: "", emojiOptions: []};
|
||||
return { triggerWord: '', emojiOptions: [] };
|
||||
}
|
||||
}
|
||||
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}) => {
|
||||
const {emojiName, replaceSelection} = actionArg;
|
||||
static _onSelectEmoji = ({ editor, actionArg }) => {
|
||||
const { emojiName, replaceSelection } = actionArg;
|
||||
if (!emojiName) return null;
|
||||
if (replaceSelection) {
|
||||
const sel = editor.currentSelection();
|
||||
if (sel.anchorNode &&
|
||||
sel.anchorNode.nodeValue &&
|
||||
sel.anchorNode.nodeValue.length > 0 &&
|
||||
sel.isCollapsed) {
|
||||
if (
|
||||
sel.anchorNode &&
|
||||
sel.anchorNode.nodeValue &&
|
||||
sel.anchorNode.nodeValue.length > 0 &&
|
||||
sel.isCollapsed
|
||||
) {
|
||||
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);
|
||||
if (index !== -1 && words.lastIndexOf(" ") < index) {
|
||||
editor.select(sel.anchorNode,
|
||||
sel.anchorOffset - lastWord.length - 1,
|
||||
sel.focusNode,
|
||||
sel.focusOffset);
|
||||
if (index !== -1 && words.lastIndexOf(' ') < index) {
|
||||
editor.select(
|
||||
sel.anchorNode,
|
||||
sel.anchorOffset - lastWord.length - 1,
|
||||
sel.focusNode,
|
||||
sel.focusOffset
|
||||
);
|
||||
} else {
|
||||
const {text, textNode} = EmojiComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset);
|
||||
index = text.lastIndexOf(":");
|
||||
const { text, textNode } = EmojiComposerExtension._getTextUntilSpace(
|
||||
sel.anchorNode,
|
||||
sel.anchorOffset
|
||||
);
|
||||
index = text.lastIndexOf(':');
|
||||
lastWord = text.substring(index + 1);
|
||||
const offset = textNode.nodeValue.lastIndexOf(":");
|
||||
editor.select(textNode,
|
||||
offset,
|
||||
sel.focusNode,
|
||||
sel.focusOffset);
|
||||
const offset = textNode.nodeValue.lastIndexOf(':');
|
||||
editor.select(textNode, offset, sel.focusNode, sel.focusOffset);
|
||||
editor.delete();
|
||||
}
|
||||
}
|
||||
|
@ -223,8 +238,8 @@ class EmojiComposerExtension extends ComposerExtension {
|
|||
width="14"
|
||||
height="14"
|
||||
style="margin-top: -5px;">`;
|
||||
editor.insertHTML(html, {selectInsertion: false});
|
||||
EmojiActions.useEmoji({emojiName: emojiName, emojiChar: emojiChar});
|
||||
editor.insertHTML(html, { selectInsertion: false });
|
||||
EmojiActions.useEmoji({ emojiName: emojiName, emojiChar: emojiChar });
|
||||
return null;
|
||||
};
|
||||
|
||||
|
@ -251,25 +266,26 @@ class EmojiComposerExtension extends ComposerExtension {
|
|||
static _getTextUntilSpace(node, offset) {
|
||||
let text = node.nodeValue.substring(0, offset);
|
||||
let prevTextNode = DOMUtils.previousTextNode(node);
|
||||
if (!prevTextNode) return {text: text, textNode: node};
|
||||
if (!prevTextNode) return { text: text, textNode: node };
|
||||
while (prevTextNode) {
|
||||
if (prevTextNode.nodeValue.indexOf(" ") === -1 &&
|
||||
prevTextNode.nodeValue.indexOf(":") === -1) {
|
||||
if (
|
||||
prevTextNode.nodeValue.indexOf(' ') === -1 &&
|
||||
prevTextNode.nodeValue.indexOf(':') === -1
|
||||
) {
|
||||
text = prevTextNode.nodeValue + text;
|
||||
prevTextNode = DOMUtils.previousTextNode(prevTextNode);
|
||||
} else if (prevTextNode.nextSibling &&
|
||||
prevTextNode.nextSibling.nodeName !== "DIV") {
|
||||
} else if (prevTextNode.nextSibling && prevTextNode.nextSibling.nodeName !== 'DIV') {
|
||||
text = prevTextNode.nodeValue.trim() + text;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {text: text, textNode: prevTextNode};
|
||||
return { text: text, textNode: prevTextNode };
|
||||
}
|
||||
|
||||
static _findMatches(word) {
|
||||
const emojiOptions = []
|
||||
const emojiOptions = [];
|
||||
const emojiNames = Object.keys(emoji.emoji).sort();
|
||||
for (const emojiName of emojiNames) {
|
||||
if (word === emojiName.substring(0, word.length)) {
|
||||
|
@ -278,7 +294,6 @@ class EmojiComposerExtension extends ComposerExtension {
|
|||
}
|
||||
return emojiOptions;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default EmojiComposerExtension;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint no-cond-assign:0 */
|
||||
import {MessageViewExtension, RegExpUtils} from 'nylas-exports';
|
||||
import { MessageViewExtension, RegExpUtils } from 'nylas-exports';
|
||||
import emoji from 'node-emoji';
|
||||
|
||||
import EmojiStore from './emoji-store';
|
||||
|
@ -15,7 +15,7 @@ function makeIntoEmojiTag(nodeArg, emojiName) {
|
|||
}
|
||||
|
||||
class EmojiMessageExtension extends MessageViewExtension {
|
||||
static renderedMessageBodyIntoDocument({document}) {
|
||||
static renderedMessageBodyIntoDocument({ document }) {
|
||||
const emojiRegex = RegExpUtils.emojiRegex();
|
||||
|
||||
// Look for emoji in the content of text nodes
|
||||
|
@ -27,10 +27,10 @@ class EmojiMessageExtension extends MessageViewExtension {
|
|||
const node = treeWalker.currentNode;
|
||||
let match = null;
|
||||
|
||||
while (match = emojiRegex.exec(node.textContent)) {
|
||||
while ((match = emojiRegex.exec(node.textContent))) {
|
||||
const matchEmojiName = emoji.which(match[0]);
|
||||
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);
|
||||
const imageNode = document.createElement('img');
|
||||
makeIntoEmojiTag(imageNode, matchEmojiName);
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
import {React, ReactDOM} from 'nylas-exports';
|
||||
import { React, ReactDOM, PropTypes } from 'nylas-exports';
|
||||
import emoji from 'node-emoji';
|
||||
|
||||
import EmojiStore from './emoji-store';
|
||||
import EmojiActions from './emoji-actions';
|
||||
|
||||
|
||||
class EmojiPicker extends React.Component {
|
||||
static displayName = "EmojiPicker";
|
||||
static displayName = 'EmojiPicker';
|
||||
static propTypes = {
|
||||
emojiOptions: React.PropTypes.array,
|
||||
selectedEmoji: React.PropTypes.string,
|
||||
emojiOptions: PropTypes.array,
|
||||
selectedEmoji: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -18,14 +17,14 @@ class EmojiPicker extends React.Component {
|
|||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const selectedButton = ReactDOM.findDOMNode(this).querySelector(".emoji-option");
|
||||
const selectedButton = ReactDOM.findDOMNode(this).querySelector('.emoji-option');
|
||||
if (selectedButton) {
|
||||
selectedButton.scrollIntoViewIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseDown(emojiName) {
|
||||
EmojiActions.selectEmoji({emojiName, replaceSelection: true});
|
||||
EmojiActions.selectEmoji({ emojiName, replaceSelection: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -34,7 +33,7 @@ class EmojiPicker extends React.Component {
|
|||
if (emojiIndex === -1) emojiIndex = 0;
|
||||
if (this.props.emojiOptions) {
|
||||
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);
|
||||
emojiChar = (
|
||||
<img
|
||||
|
@ -42,7 +41,7 @@ class EmojiPicker extends React.Component {
|
|||
src={EmojiStore.getImagePath(emojiOption)}
|
||||
width="16"
|
||||
height="16"
|
||||
style={{marginTop: "-4px", marginRight: "3px"}}
|
||||
style={{ marginTop: '-4px', marginRight: '3px' }}
|
||||
/>
|
||||
);
|
||||
emojiButtons.push(
|
||||
|
@ -57,11 +56,7 @@ class EmojiPicker extends React.Component {
|
|||
emojiButtons.push(<br key={`${emojiOption} br`} />);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="emoji-picker">
|
||||
{emojiButtons}
|
||||
</div>
|
||||
);
|
||||
return <div className="emoji-picker">{emojiButtons}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ class EmojiStore extends NylasStore {
|
|||
const sortedEmoji = this._emoji;
|
||||
sortedEmoji.sort((a, b) => {
|
||||
if (a.frequency < b.frequency) return 1;
|
||||
return (b.frequency < a.frequency) ? -1 : 0;
|
||||
return b.frequency < a.frequency ? -1 : 0;
|
||||
});
|
||||
const sortedEmojiNames = [];
|
||||
for (const emoji of sortedEmoji) {
|
||||
|
@ -41,23 +41,23 @@ class EmojiStore extends NylasStore {
|
|||
return sortedEmojiNames.slice(0, 32);
|
||||
}
|
||||
return sortedEmojiNames;
|
||||
}
|
||||
};
|
||||
|
||||
getImagePath(emojiName) {
|
||||
emojiData = emojiData || require('./emoji-data').emojiData
|
||||
emojiData = emojiData || require('./emoji-data').emojiData;
|
||||
for (const emoji of emojiData) {
|
||||
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/twitter/${emoji.image}`;
|
||||
}
|
||||
}
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
|
||||
_onUseEmoji = (emoji) => {
|
||||
const savedEmoji = _.find(this._emoji, (curEmoji) => {
|
||||
_onUseEmoji = emoji => {
|
||||
const savedEmoji = _.find(this._emoji, curEmoji => {
|
||||
return curEmoji.emojiChar === emoji.emojiChar;
|
||||
});
|
||||
if (savedEmoji) {
|
||||
|
@ -66,17 +66,16 @@ class EmojiStore extends NylasStore {
|
|||
}
|
||||
savedEmoji.frequency++;
|
||||
} else {
|
||||
Object.assign(emoji, {frequency: 1});
|
||||
Object.assign(emoji, { frequency: 1 });
|
||||
this._emoji.push(emoji);
|
||||
}
|
||||
this._saveEmoji();
|
||||
this.trigger();
|
||||
}
|
||||
};
|
||||
|
||||
_saveEmoji = () => {
|
||||
window.localStorage.setItem(EmojiJSONKey, JSON.stringify(this._emoji));
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
export default new EmojiStore();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {ExtensionRegistry, ComponentRegistry} from 'nylas-exports';
|
||||
import { ExtensionRegistry, ComponentRegistry } from 'nylas-exports';
|
||||
import EmojiStore from './emoji-store';
|
||||
import EmojiComposerExtension from './emoji-composer-extension';
|
||||
import EmojiMessageExtension from './emoji-message-extension';
|
||||
|
@ -7,7 +7,7 @@ import EmojiButton from './emoji-button';
|
|||
export function activate() {
|
||||
ExtensionRegistry.Composer.register(EmojiComposerExtension);
|
||||
ExtensionRegistry.MessageView.register(EmojiMessageExtension);
|
||||
ComponentRegistry.register(EmojiButton, {role: 'Composer:ActionButton'});
|
||||
ComponentRegistry.register(EmojiButton, { role: 'Composer:ActionButton' });
|
||||
EmojiStore.activate();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import ReactTestUtils from 'react-dom/test-utils';
|
||||
|
||||
import {findDOMNode} from 'react-dom';
|
||||
import {renderIntoDocument} from '../../../spec/nylas-test-utils';
|
||||
import { findDOMNode } from 'react-dom';
|
||||
import { renderIntoDocument } from '../../../spec/nylas-test-utils';
|
||||
import Contenteditable from '../../../src/components/contenteditable/contenteditable';
|
||||
import EmojiButtonPopover from '../lib/emoji-button-popover';
|
||||
import EmojiComposerExtension from '../lib/emoji-composer-extension';
|
||||
|
@ -12,12 +12,14 @@ describe('EmojiButtonPopover', function emojiButtonPopover() {
|
|||
this.position = {
|
||||
x: 20,
|
||||
y: 40,
|
||||
}
|
||||
};
|
||||
spyOn(EmojiButtonPopover.prototype, 'calcPosition').andReturn(this.position);
|
||||
spyOn(EmojiComposerExtension, '_onSelectEmoji').andCallThrough();
|
||||
|
||||
this.component = renderIntoDocument(<EmojiButtonPopover />);
|
||||
this.canvas = findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(this.component, 'canvas'));
|
||||
this.canvas = findDOMNode(
|
||||
ReactTestUtils.findRenderedDOMComponentWithTag(this.component, 'canvas')
|
||||
);
|
||||
|
||||
this.composer = renderIntoDocument(
|
||||
<Contenteditable
|
||||
|
@ -37,12 +39,14 @@ describe('EmojiButtonPopover', function emojiButtonPopover() {
|
|||
|
||||
describe('when searching for emoji', () => {
|
||||
it('should filter for matches', () => {
|
||||
this.searchNode = findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'search'))
|
||||
this.searchNode = findDOMNode(
|
||||
ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'search')
|
||||
);
|
||||
const event = {
|
||||
target: {
|
||||
value: "heart",
|
||||
value: 'heart',
|
||||
},
|
||||
}
|
||||
};
|
||||
ReactTestUtils.Simulate.change(this.searchNode, event);
|
||||
ReactTestUtils.Simulate.mouseDown(this.canvas);
|
||||
expect(EmojiComposerExtension._onSelectEmoji).toHaveBeenCalled();
|
||||
|
|
|
@ -2,99 +2,132 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
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 EmojiComposerExtension from '../lib/emoji-composer-extension';
|
||||
|
||||
xdescribe('EmojiComposerExtension', function emojiComposerExtension() {
|
||||
beforeEach(() => {
|
||||
spyOn(EmojiComposerExtension, 'onContentChanged').andCallThrough()
|
||||
spyOn(EmojiComposerExtension, '_onSelectEmoji').andCallThrough()
|
||||
spyOn(EmojiComposerExtension, 'onContentChanged').andCallThrough();
|
||||
spyOn(EmojiComposerExtension, '_onSelectEmoji').andCallThrough();
|
||||
this.component = renderIntoDocument(
|
||||
<Contenteditable
|
||||
html={''}
|
||||
onChange={jasmine.createSpy('onChange')}
|
||||
extensions={[EmojiComposerExtension]}
|
||||
/>
|
||||
)
|
||||
);
|
||||
this.editableNode = ReactDOM.findDOMNode(this.component).querySelector('[contenteditable]');
|
||||
})
|
||||
});
|
||||
|
||||
describe('when emoji trigger is typed', () => {
|
||||
beforeEach(() => {
|
||||
this._performEdit = (newHTML) => {
|
||||
this._performEdit = newHTML => {
|
||||
this.editableNode.innerHTML = newHTML.substr(0, newHTML.length - 1);
|
||||
const sel = document.getSelection();
|
||||
const textNode = this.editableNode.childNodes[0];
|
||||
sel.setBaseAndExtent(textNode, textNode.length, textNode, textNode.length);
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
it('should show the emoji picker', () => {
|
||||
this._performEdit('Testing! :h');
|
||||
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', () => {
|
||||
this._performEdit('Testing! :h');
|
||||
waitsFor(() => {
|
||||
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length > 0
|
||||
return (
|
||||
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length >
|
||||
0
|
||||
);
|
||||
});
|
||||
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', () => {
|
||||
this._performEdit('Testing! :h');
|
||||
waitsFor(() => {
|
||||
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length > 0
|
||||
return (
|
||||
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length >
|
||||
0
|
||||
);
|
||||
});
|
||||
runs(() => {
|
||||
ReactTestUtils.Simulate.keyDown(this.editableNode, {key: "Enter", keyCode: 13, which: 13});
|
||||
ReactTestUtils.Simulate.keyDown(this.editableNode, {
|
||||
key: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
});
|
||||
});
|
||||
waitsFor(() => {
|
||||
return EmojiComposerExtension._onSelectEmoji.calls.length > 0
|
||||
})
|
||||
runs(() => {
|
||||
expect(this.editableNode.innerHTML).toContain("emoji haircut")
|
||||
return EmojiComposerExtension._onSelectEmoji.calls.length > 0;
|
||||
});
|
||||
})
|
||||
runs(() => {
|
||||
expect(this.editableNode.innerHTML).toContain('emoji haircut');
|
||||
});
|
||||
});
|
||||
|
||||
it('should insert an emoji on click', () => {
|
||||
this._performEdit('Testing! :h');
|
||||
waitsFor(() => {
|
||||
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length > 0
|
||||
return (
|
||||
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-picker').length >
|
||||
0
|
||||
);
|
||||
});
|
||||
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);
|
||||
expect(EmojiComposerExtension._onSelectEmoji).toHaveBeenCalled()
|
||||
expect(EmojiComposerExtension._onSelectEmoji).toHaveBeenCalled();
|
||||
});
|
||||
waitsFor(() => {
|
||||
return EmojiComposerExtension._onSelectEmoji.calls.length > 0
|
||||
})
|
||||
runs(() => {
|
||||
expect(this.editableNode.innerHTML).toContain("emoji haircut")
|
||||
return EmojiComposerExtension._onSelectEmoji.calls.length > 0;
|
||||
});
|
||||
})
|
||||
runs(() => {
|
||||
expect(this.editableNode.innerHTML).toContain('emoji haircut');
|
||||
});
|
||||
});
|
||||
|
||||
it('should move to the next emoji on arrow down', () => {
|
||||
this._performEdit('Testing! :h');
|
||||
waitsFor(() => {
|
||||
return ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length > 0
|
||||
return (
|
||||
ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'emoji-option').length >
|
||||
0
|
||||
);
|
||||
});
|
||||
runs(() => {
|
||||
ReactTestUtils.Simulate.keyDown(this.editableNode, {key: "ArrowDown", keyCode: 40, which: 40});
|
||||
ReactTestUtils.Simulate.keyDown(this.editableNode, {
|
||||
key: 'ArrowDown',
|
||||
keyCode: 40,
|
||||
which: 40,
|
||||
});
|
||||
});
|
||||
waitsFor(() => {
|
||||
return EmojiComposerExtension.onContentChanged.calls.length > 1
|
||||
return EmojiComposerExtension.onContentChanged.calls.length > 1;
|
||||
});
|
||||
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);
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
Utils = require './utils'
|
||||
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.
|
||||
# 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
|
||||
|
||||
@contextTypes:
|
||||
parentTabGroup: React.PropTypes.object,
|
||||
parentTabGroup: PropTypes.object,
|
||||
|
||||
@propTypes:
|
||||
body: React.PropTypes.string.isRequired,
|
||||
onBodyChanged: React.PropTypes.func.isRequired,
|
||||
body: PropTypes.string.isRequired,
|
||||
onBodyChanged: PropTypes.func.isRequired,
|
||||
|
||||
componentDidMount: =>
|
||||
@mde = new SimpleMDE(
|
||||
|
|
|
@ -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 SignatureComposerDropdown from './signature-composer-dropdown';
|
||||
|
||||
export function activate() {
|
||||
this.preferencesTab = new PreferencesUIStore.TabItem({
|
||||
tabId: "Signatures",
|
||||
displayName: "Signatures",
|
||||
tabId: 'Signatures',
|
||||
displayName: 'Signatures',
|
||||
componentClassFn: () => require('./preferences-signatures').default, // eslint-disable-line
|
||||
});
|
||||
|
||||
|
|
|
@ -1,104 +1,97 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Flexbox,
|
||||
RetinaImg,
|
||||
EditableList,
|
||||
Contenteditable,
|
||||
ScrollRegion,
|
||||
MultiselectDropdown,
|
||||
Flexbox,
|
||||
RetinaImg,
|
||||
EditableList,
|
||||
Contenteditable,
|
||||
ScrollRegion,
|
||||
MultiselectDropdown,
|
||||
} 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 {
|
||||
static displayName = 'PreferencesSignatures';
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.state = this._getStateFromStores()
|
||||
super();
|
||||
this.state = this._getStateFromStores();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribers = [
|
||||
SignatureStore.listen(this._onChange),
|
||||
]
|
||||
this.unsubscribers = [SignatureStore.listen(this._onChange)];
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribers.forEach(unsubscribe => unsubscribe());
|
||||
}
|
||||
|
||||
|
||||
_onChange = () => {
|
||||
this.setState(this._getStateFromStores())
|
||||
}
|
||||
this.setState(this._getStateFromStores());
|
||||
};
|
||||
|
||||
_getStateFromStores() {
|
||||
const signatures = SignatureStore.getSignatures()
|
||||
const accountsAndAliases = AccountStore.aliases()
|
||||
const selected = SignatureStore.selectedSignature()
|
||||
const defaults = SignatureStore.getDefaults()
|
||||
const signatures = SignatureStore.getSignatures();
|
||||
const accountsAndAliases = AccountStore.aliases();
|
||||
const selected = SignatureStore.selectedSignature();
|
||||
const defaults = SignatureStore.getDefaults();
|
||||
return {
|
||||
signatures: signatures,
|
||||
selectedSignature: selected,
|
||||
defaults: defaults,
|
||||
accountsAndAliases: accountsAndAliases,
|
||||
editAsHTML: this.state ? this.state.editAsHTML : false,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
_onCreateButtonClick = () => {
|
||||
this._onAddSignature()
|
||||
}
|
||||
this._onAddSignature();
|
||||
};
|
||||
|
||||
_onAddSignature = () => {
|
||||
Actions.addSignature()
|
||||
}
|
||||
Actions.addSignature();
|
||||
};
|
||||
|
||||
_onDeleteSignature = (signature) => {
|
||||
Actions.removeSignature(signature)
|
||||
}
|
||||
_onDeleteSignature = signature => {
|
||||
Actions.removeSignature(signature);
|
||||
};
|
||||
|
||||
_onEditSignature = (edit) => {
|
||||
_onEditSignature = edit => {
|
||||
let editedSig;
|
||||
if (typeof edit === "object") {
|
||||
if (typeof edit === 'object') {
|
||||
editedSig = {
|
||||
title: this.state.selectedSignature.title,
|
||||
body: edit.target.value,
|
||||
}
|
||||
};
|
||||
} else {
|
||||
editedSig = {
|
||||
title: edit,
|
||||
body: this.state.selectedSignature.body,
|
||||
}
|
||||
};
|
||||
}
|
||||
Actions.updateSignature(editedSig, this.state.selectedSignature.id)
|
||||
}
|
||||
Actions.updateSignature(editedSig, this.state.selectedSignature.id);
|
||||
};
|
||||
|
||||
_onSelectSignature = (sig) => {
|
||||
Actions.selectSignature(sig.id)
|
||||
}
|
||||
_onSelectSignature = sig => {
|
||||
Actions.selectSignature(sig.id);
|
||||
};
|
||||
|
||||
_onToggleAccount = (account) => {
|
||||
Actions.toggleAccount(account.email)
|
||||
}
|
||||
_onToggleAccount = account => {
|
||||
Actions.toggleAccount(account.email);
|
||||
};
|
||||
|
||||
_onToggleEditAsHTML = () => {
|
||||
const toggled = !this.state.editAsHTML
|
||||
this.setState({editAsHTML: toggled})
|
||||
}
|
||||
const toggled = !this.state.editAsHTML;
|
||||
this.setState({ editAsHTML: toggled });
|
||||
};
|
||||
|
||||
_renderListItemContent = (sig) => {
|
||||
return sig.title
|
||||
}
|
||||
_renderListItemContent = sig => {
|
||||
return sig.title;
|
||||
};
|
||||
|
||||
_renderSignatureToolbar() {
|
||||
return (
|
||||
<div className="editable-toolbar">
|
||||
<div className="account-picker">
|
||||
Default for: {this._renderAccountPicker()}
|
||||
</div>
|
||||
<div className="account-picker">Default for: {this._renderAccountPicker()}</div>
|
||||
<div className="render-mode">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
@ -109,30 +102,30 @@ export default class PreferencesSignatures extends React.Component {
|
|||
<label htmlFor="render-mode">Edit raw HTML</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
_selectItemKey = (accountOrAlias) => {
|
||||
return accountOrAlias.id
|
||||
}
|
||||
_selectItemKey = accountOrAlias => {
|
||||
return accountOrAlias.id;
|
||||
};
|
||||
|
||||
_isChecked = (accountOrAlias) => {
|
||||
_isChecked = accountOrAlias => {
|
||||
if (!this.state.selectedSignature) {
|
||||
return false;
|
||||
}
|
||||
return (this.state.defaults[accountOrAlias.email] === this.state.selectedSignature.id);
|
||||
}
|
||||
return this.state.defaults[accountOrAlias.email] === this.state.selectedSignature.id;
|
||||
};
|
||||
|
||||
_labelForAccountPicker() {
|
||||
const sel = this.state.accountsAndAliases.filter((accountOrAlias) => {
|
||||
return this._isChecked(accountOrAlias)
|
||||
})
|
||||
const sel = this.state.accountsAndAliases.filter(accountOrAlias => {
|
||||
return this._isChecked(accountOrAlias);
|
||||
});
|
||||
const numSelected = sel.length;
|
||||
return numSelected.toString() + (numSelected === 1 ? " Account" : " Accounts")
|
||||
return numSelected.toString() + (numSelected === 1 ? ' Account' : ' Accounts');
|
||||
}
|
||||
|
||||
_renderAccountPicker() {
|
||||
const buttonText = this._labelForAccountPicker()
|
||||
const buttonText = this._labelForAccountPicker();
|
||||
|
||||
return (
|
||||
<MultiselectDropdown
|
||||
|
@ -143,33 +136,24 @@ export default class PreferencesSignatures extends React.Component {
|
|||
itemKey={this._selectItemKey}
|
||||
current={this.selectedSignature}
|
||||
buttonText={buttonText}
|
||||
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}
|
||||
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} />;
|
||||
}
|
||||
|
||||
_renderSignatures() {
|
||||
const sigArr = Object.values(this.state.signatures)
|
||||
const sigArr = Object.values(this.state.signatures);
|
||||
if (sigArr.length === 0) {
|
||||
return (
|
||||
<div className="empty-list">
|
||||
|
@ -208,16 +192,14 @@ export default class PreferencesSignatures extends React.Component {
|
|||
{this._renderSignatureToolbar()}
|
||||
</div>
|
||||
</Flexbox>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="preferences-signatures-container">
|
||||
<section>
|
||||
{this._renderSignatures()}
|
||||
</section>
|
||||
<section>{this._renderSignatures()}</section>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,106 +1,101 @@
|
|||
import {
|
||||
React,
|
||||
Actions,
|
||||
SignatureStore,
|
||||
} from 'nylas-exports'
|
||||
import {
|
||||
Menu,
|
||||
RetinaImg,
|
||||
ButtonDropdown,
|
||||
} from 'nylas-component-kit'
|
||||
import _ from 'underscore'
|
||||
|
||||
import SignatureUtils from './signature-utils'
|
||||
import { React, Actions, PropTypes, SignatureStore } from 'nylas-exports';
|
||||
import { Menu, RetinaImg, ButtonDropdown } from 'nylas-component-kit';
|
||||
|
||||
import SignatureUtils from './signature-utils';
|
||||
|
||||
export default class SignatureComposerDropdown extends React.Component {
|
||||
static displayName = 'SignatureComposerDropdown'
|
||||
static displayName = 'SignatureComposerDropdown';
|
||||
|
||||
static containerRequired = false
|
||||
static containerRequired = false;
|
||||
|
||||
static propTypes = {
|
||||
draft: React.PropTypes.object.isRequired,
|
||||
session: React.PropTypes.object.isRequired,
|
||||
currentAccount: React.PropTypes.object,
|
||||
accounts: React.PropTypes.array,
|
||||
}
|
||||
draft: PropTypes.object.isRequired,
|
||||
session: PropTypes.object.isRequired,
|
||||
currentAccount: PropTypes.object,
|
||||
accounts: PropTypes.array,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.state = this._getStateFromStores()
|
||||
super();
|
||||
this.state = this._getStateFromStores();
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
this.unsubscribers = [
|
||||
SignatureStore.listen(this._onChange),
|
||||
]
|
||||
}
|
||||
this.unsubscribers = [SignatureStore.listen(this._onChange)];
|
||||
};
|
||||
|
||||
componentDidUpdate(previousProps) {
|
||||
if (previousProps.currentAccount.id !== this.props.currentAccount.id) {
|
||||
const nextDefaultSignature = SignatureStore.signatureForEmail(this.props.currentAccount.email)
|
||||
this._changeSignature(nextDefaultSignature)
|
||||
const nextDefaultSignature = SignatureStore.signatureForEmail(
|
||||
this.props.currentAccount.email
|
||||
);
|
||||
this._changeSignature(nextDefaultSignature);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribers.forEach(unsubscribe => unsubscribe())
|
||||
this.unsubscribers.forEach(unsubscribe => unsubscribe());
|
||||
}
|
||||
|
||||
_onChange = () => {
|
||||
this.setState(this._getStateFromStores())
|
||||
}
|
||||
|
||||
this.setState(this._getStateFromStores());
|
||||
};
|
||||
|
||||
_getStateFromStores() {
|
||||
const signatures = SignatureStore.getSignatures()
|
||||
const signatures = SignatureStore.getSignatures();
|
||||
return {
|
||||
signatures: signatures,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_renderSigItem = (sigItem) => {
|
||||
return (
|
||||
<span className={`signature-title-${sigItem.title}`}>{sigItem.title}</span>
|
||||
)
|
||||
}
|
||||
_renderSigItem = sigItem => {
|
||||
return <span className={`signature-title-${sigItem.title}`}>{sigItem.title}</span>;
|
||||
};
|
||||
|
||||
_changeSignature = (sig) => {
|
||||
_changeSignature = sig => {
|
||||
let body;
|
||||
if (sig) {
|
||||
body = SignatureUtils.applySignature(this.props.draft.body, sig.body)
|
||||
body = SignatureUtils.applySignature(this.props.draft.body, sig.body);
|
||||
} 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
|
||||
const escapeRegExp = (str) => {
|
||||
return str.replace(/[-[\]/}{)(*+?.\\^$|]/g, "\\$&");
|
||||
}
|
||||
const signatureRegex = new RegExp(escapeRegExp(`<signature>${sigObj.body}</signature>`))
|
||||
const signatureLocation = signatureRegex.exec(this.props.draft.body)
|
||||
if (signatureLocation) return true
|
||||
return false
|
||||
}
|
||||
const escapeRegExp = str => {
|
||||
return str.replace(/[-[\]/}{)(*+?.\\^$|]/g, '\\$&');
|
||||
};
|
||||
const signatureRegex = new RegExp(escapeRegExp(`<signature>${sigObj.body}</signature>`));
|
||||
const signatureLocation = signatureRegex.exec(this.props.draft.body);
|
||||
if (signatureLocation) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
_onClickNoSignature = () => {
|
||||
this._changeSignature({body: ''})
|
||||
}
|
||||
this._changeSignature({ body: '' });
|
||||
};
|
||||
|
||||
_onClickEditSignatures() {
|
||||
Actions.switchPreferencesTab('Signatures')
|
||||
Actions.openPreferences()
|
||||
Actions.switchPreferencesTab('Signatures');
|
||||
Actions.openPreferences();
|
||||
}
|
||||
|
||||
_renderSignatures() {
|
||||
// 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 footer = [<div className="item item-edit" key="edit" onMouseDown={this._onClickEditSignatures}><span>Edit Signatures...</span></div>]
|
||||
const header = [
|
||||
<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 (
|
||||
<Menu
|
||||
headerComponents={header}
|
||||
|
@ -111,7 +106,7 @@ export default class SignatureComposerDropdown extends React.Component {
|
|||
onSelect={this._changeSignature}
|
||||
itemChecked={this._isSelected}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
_renderSignatureIcon() {
|
||||
|
@ -121,26 +116,20 @@ export default class SignatureComposerDropdown extends React.Component {
|
|||
name="top-signature-dropdown.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const sigs = this.state.signatures;
|
||||
const icon = this._renderSignatureIcon()
|
||||
const icon = this._renderSignatureIcon();
|
||||
|
||||
if (Object.values(sigs).length > 0) {
|
||||
return (
|
||||
<div className="signature-button-dropdown">
|
||||
<ButtonDropdown
|
||||
primaryItem={icon}
|
||||
menu={this._renderSignatures()}
|
||||
bordered={false}
|
||||
/>
|
||||
<ButtonDropdown primaryItem={icon} menu={this._renderSignatures()} bordered={false} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import {ComposerExtension, SignatureStore} from 'nylas-exports';
|
||||
import { ComposerExtension, SignatureStore } from 'nylas-exports';
|
||||
import SignatureUtils from './signature-utils';
|
||||
|
||||
export default class SignatureComposerExtension extends ComposerExtension {
|
||||
static prepareNewDraft = ({draft}) => {
|
||||
const signatureObj = draft.from && draft.from[0] ? SignatureStore.signatureForEmail(draft.from[0].email) : null;
|
||||
static prepareNewDraft = ({ draft }) => {
|
||||
const signatureObj =
|
||||
draft.from && draft.from[0] ? SignatureStore.signatureForEmail(draft.from[0].email) : null;
|
||||
if (!signatureObj) {
|
||||
return;
|
||||
}
|
||||
draft.body = SignatureUtils.applySignature(draft.body, signatureObj.body);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {RegExpUtils} from 'nylas-exports'
|
||||
import { RegExpUtils } from 'nylas-exports';
|
||||
|
||||
export default {
|
||||
applySignature(body, signature) {
|
||||
|
@ -9,8 +9,8 @@ export default {
|
|||
let paddingBefore = '';
|
||||
|
||||
// Remove any existing signature in the body
|
||||
newBody = newBody.replace(signatureRegex, "");
|
||||
const signatureInPrevious = newBody !== body
|
||||
newBody = newBody.replace(signatureRegex, '');
|
||||
const signatureInPrevious = newBody !== body;
|
||||
|
||||
// http://www.regexpal.com/?fam=94390
|
||||
// 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());
|
||||
if (insertionPoint === -1) {
|
||||
insertionPoint = newBody.length;
|
||||
if (!signatureInPrevious) paddingBefore = '<br><br>'
|
||||
if (!signatureInPrevious) paddingBefore = '<br><br>';
|
||||
}
|
||||
|
||||
const contentBefore = newBody.slice(0, insertionPoint);
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
/* eslint quote-props: 0 */
|
||||
import ReactTestUtils from 'react-dom/test-utils';
|
||||
import React from 'react';
|
||||
import {SignatureStore, Actions} from 'nylas-exports';
|
||||
import { SignatureStore, Actions } from 'nylas-exports';
|
||||
import PreferencesSignatures from '../lib/preferences-signatures';
|
||||
|
||||
|
||||
const SIGNATURES = {
|
||||
'1': {
|
||||
id: '1',
|
||||
|
@ -16,76 +15,84 @@ const SIGNATURES = {
|
|||
title: 'two',
|
||||
body: 'Here is my second sig!',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
'one@nylas.com': '1',
|
||||
'two@nylas.com': '2',
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const makeComponent = (props = {}) => {
|
||||
return ReactTestUtils.renderIntoDocument(<PreferencesSignatures {...props} />)
|
||||
}
|
||||
return ReactTestUtils.renderIntoDocument(<PreferencesSignatures {...props} />);
|
||||
};
|
||||
|
||||
describe('PreferencesSignatures', function preferencesSignatures() {
|
||||
this.component = null
|
||||
this.component = null;
|
||||
|
||||
describe('when there are no signatures', () => {
|
||||
it('should add a signature when you click the button', () => {
|
||||
spyOn(SignatureStore, 'getSignatures').andReturn({})
|
||||
spyOn(SignatureStore, 'selectedSignature')
|
||||
spyOn(SignatureStore, 'getDefaults').andReturn({})
|
||||
this.component = makeComponent()
|
||||
spyOn(Actions, 'addSignature')
|
||||
this.button = ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'btn-create-signature')
|
||||
ReactTestUtils.Simulate.click(this.button)
|
||||
expect(Actions.addSignature).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
spyOn(SignatureStore, 'getSignatures').andReturn({});
|
||||
spyOn(SignatureStore, 'selectedSignature');
|
||||
spyOn(SignatureStore, 'getDefaults').andReturn({});
|
||||
this.component = makeComponent();
|
||||
spyOn(Actions, 'addSignature');
|
||||
this.button = ReactTestUtils.findRenderedDOMComponentWithClass(
|
||||
this.component,
|
||||
'btn-create-signature'
|
||||
);
|
||||
ReactTestUtils.Simulate.click(this.button);
|
||||
expect(Actions.addSignature).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there are signatures', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES)
|
||||
spyOn(SignatureStore, 'selectedSignature').andReturn(SIGNATURES['1'])
|
||||
spyOn(SignatureStore, 'getDefaults').andReturn(DEFAULTS)
|
||||
this.component = makeComponent()
|
||||
})
|
||||
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES);
|
||||
spyOn(SignatureStore, 'selectedSignature').andReturn(SIGNATURES['1']);
|
||||
spyOn(SignatureStore, 'getDefaults').andReturn(DEFAULTS);
|
||||
this.component = makeComponent();
|
||||
});
|
||||
it('should add a signature when you click the plus button', () => {
|
||||
spyOn(Actions, 'addSignature')
|
||||
this.plus = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'btn-editable-list')[0]
|
||||
ReactTestUtils.Simulate.click(this.plus)
|
||||
expect(Actions.addSignature).toHaveBeenCalled()
|
||||
})
|
||||
spyOn(Actions, 'addSignature');
|
||||
this.plus = ReactTestUtils.scryRenderedDOMComponentsWithClass(
|
||||
this.component,
|
||||
'btn-editable-list'
|
||||
)[0];
|
||||
ReactTestUtils.Simulate.click(this.plus);
|
||||
expect(Actions.addSignature).toHaveBeenCalled();
|
||||
});
|
||||
it('should delete a signature when you click the minus button', () => {
|
||||
spyOn(Actions, 'removeSignature')
|
||||
this.minus = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'btn-editable-list')[1]
|
||||
ReactTestUtils.Simulate.click(this.minus)
|
||||
expect(Actions.removeSignature).toHaveBeenCalledWith(SIGNATURES['1'])
|
||||
})
|
||||
spyOn(Actions, 'removeSignature');
|
||||
this.minus = ReactTestUtils.scryRenderedDOMComponentsWithClass(
|
||||
this.component,
|
||||
'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', () => {
|
||||
spyOn(Actions, 'toggleAccount')
|
||||
this.account = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'item')[0]
|
||||
ReactTestUtils.Simulate.mouseDown(this.account)
|
||||
expect(Actions.toggleAccount).toHaveBeenCalledWith('tester@nylas.com')
|
||||
})
|
||||
spyOn(Actions, 'toggleAccount');
|
||||
this.account = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'item')[0];
|
||||
ReactTestUtils.Simulate.mouseDown(this.account);
|
||||
expect(Actions.toggleAccount).toHaveBeenCalledWith('tester@nylas.com');
|
||||
});
|
||||
it('should set the selected signature when you click on one that is not currently selected', () => {
|
||||
spyOn(Actions, 'selectSignature')
|
||||
this.item = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'list-item')[1]
|
||||
ReactTestUtils.Simulate.click(this.item)
|
||||
expect(Actions.selectSignature).toHaveBeenCalledWith('2')
|
||||
})
|
||||
spyOn(Actions, 'selectSignature');
|
||||
this.item = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'list-item')[1];
|
||||
ReactTestUtils.Simulate.click(this.item);
|
||||
expect(Actions.selectSignature).toHaveBeenCalledWith('2');
|
||||
});
|
||||
it('should modify the signature body when edited', () => {
|
||||
spyOn(Actions, 'updateSignature')
|
||||
const newText = 'Changed <strong>NEW 1 HTML</strong><br>'
|
||||
this.component._onEditSignature({target: {value: newText}});
|
||||
expect(Actions.updateSignature).toHaveBeenCalled()
|
||||
})
|
||||
spyOn(Actions, 'updateSignature');
|
||||
const newText = 'Changed <strong>NEW 1 HTML</strong><br>';
|
||||
this.component._onEditSignature({ target: { value: newText } });
|
||||
expect(Actions.updateSignature).toHaveBeenCalled();
|
||||
});
|
||||
it('should modify the signature title when edited', () => {
|
||||
spyOn(Actions, 'updateSignature')
|
||||
const newTitle = 'Changed'
|
||||
this.component._onEditSignature(newTitle)
|
||||
expect(Actions.updateSignature).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
spyOn(Actions, 'updateSignature');
|
||||
const newTitle = 'Changed';
|
||||
this.component._onEditSignature(newTitle);
|
||||
expect(Actions.updateSignature).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
/* eslint quote-props: 0 */
|
||||
|
||||
import React from 'react';
|
||||
import ReactTestUtils from 'react-dom/test-utils'
|
||||
import {SignatureStore} from 'nylas-exports';
|
||||
import SignatureComposerDropdown from '../lib/signature-composer-dropdown'
|
||||
import {renderIntoDocument} from '../../../spec/nylas-test-utils'
|
||||
import ReactTestUtils from 'react-dom/test-utils';
|
||||
import { SignatureStore } from 'nylas-exports';
|
||||
import SignatureComposerDropdown from '../lib/signature-composer-dropdown';
|
||||
import { renderIntoDocument } from '../../../spec/nylas-test-utils';
|
||||
|
||||
const SIGNATURES = {
|
||||
'1': {
|
||||
|
@ -17,42 +17,59 @@ const SIGNATURES = {
|
|||
title: 'two',
|
||||
body: 'Here is my second sig!',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
describe('SignatureComposerDropdown', function signatureComposerDropdown() {
|
||||
beforeEach(() => {
|
||||
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES)
|
||||
spyOn(SignatureStore, 'selectedSignature')
|
||||
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES);
|
||||
spyOn(SignatureStore, 'selectedSignature');
|
||||
this.session = {
|
||||
changes: {
|
||||
add: jasmine.createSpy('add'),
|
||||
},
|
||||
}
|
||||
};
|
||||
this.draft = {
|
||||
body: "draft body",
|
||||
}
|
||||
this.button = renderIntoDocument(<SignatureComposerDropdown draft={this.draft} session={this.session} />)
|
||||
})
|
||||
body: 'draft body',
|
||||
};
|
||||
this.button = renderIntoDocument(
|
||||
<SignatureComposerDropdown draft={this.draft} session={this.session} />
|
||||
);
|
||||
});
|
||||
describe('the button dropdown', () => {
|
||||
it('calls add signature with the correct signature', () => {
|
||||
const sigToAdd = SIGNATURES['2']
|
||||
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item'))
|
||||
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>`})
|
||||
})
|
||||
const sigToAdd = SIGNATURES['2'];
|
||||
ReactTestUtils.Simulate.click(
|
||||
ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item')
|
||||
);
|
||||
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', () => {
|
||||
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item'))
|
||||
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>`})
|
||||
})
|
||||
ReactTestUtils.Simulate.click(
|
||||
ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item')
|
||||
);
|
||||
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', () => {
|
||||
this.draft = 'draft body<signature>Remove me</signature>'
|
||||
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item'))
|
||||
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>`})
|
||||
})
|
||||
})
|
||||
})
|
||||
this.draft = 'draft body<signature>Remove me</signature>';
|
||||
ReactTestUtils.Simulate.click(
|
||||
ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item')
|
||||
);
|
||||
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>`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,28 +1,28 @@
|
|||
import {Message, SignatureStore} from 'nylas-exports';
|
||||
import { Message, SignatureStore } from 'nylas-exports';
|
||||
import SignatureComposerExtension from '../lib/signature-composer-extension';
|
||||
|
||||
const TEST_ID = 1
|
||||
const TEST_ID = 1;
|
||||
const TEST_SIGNATURE = {
|
||||
id: TEST_ID,
|
||||
title: 'test-sig',
|
||||
body: '<div class="something">This is my signature.</div>',
|
||||
}
|
||||
};
|
||||
|
||||
const TEST_SIGNATURES = {}
|
||||
TEST_SIGNATURES[TEST_ID] = TEST_SIGNATURE
|
||||
const TEST_SIGNATURES = {};
|
||||
TEST_SIGNATURES[TEST_ID] = TEST_SIGNATURE;
|
||||
|
||||
describe('SignatureComposerExtension', function signatureComposerExtension() {
|
||||
describe("prepareNewDraft", () => {
|
||||
describe("when a signature is defined", () => {
|
||||
describe('prepareNewDraft', () => {
|
||||
describe('when a signature is defined', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(NylasEnv.config, 'get').andCallFake((key) =>
|
||||
(key === 'nylas.signatures' ? TEST_SIGNATURES : null)
|
||||
spyOn(NylasEnv.config, 'get').andCallFake(
|
||||
key => (key === 'nylas.signatures' ? TEST_SIGNATURES : null)
|
||||
);
|
||||
spyOn(SignatureStore, 'signatureForEmail').andReturn(TEST_SIGNATURE)
|
||||
SignatureStore.activate()
|
||||
spyOn(SignatureStore, 'signatureForEmail').andReturn(TEST_SIGNATURE);
|
||||
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({
|
||||
draft: true,
|
||||
from: ['one@nylas.com'],
|
||||
|
@ -36,10 +36,14 @@ describe('SignatureComposerExtension', function signatureComposerExtension() {
|
|||
body: 'This is a another test.',
|
||||
});
|
||||
|
||||
SignatureComposerExtension.prepareNewDraft({draft: a});
|
||||
expect(a.body).toEqual(`This is a test! <signature>${TEST_SIGNATURE.body}</signature><div class="gmail_quote">Hello world</div>`);
|
||||
SignatureComposerExtension.prepareNewDraft({draft: b});
|
||||
expect(b.body).toEqual(`This is a another test.<br><br><signature>${TEST_SIGNATURE.body}</signature>`);
|
||||
SignatureComposerExtension.prepareNewDraft({ draft: a });
|
||||
expect(a.body).toEqual(
|
||||
`This is a test! <signature>${TEST_SIGNATURE.body}</signature><div class="gmail_quote">Hello world</div>`
|
||||
);
|
||||
SignatureComposerExtension.prepareNewDraft({ draft: b });
|
||||
expect(b.body).toEqual(
|
||||
`This is a another test.<br><br><signature>${TEST_SIGNATURE.body}</signature>`
|
||||
);
|
||||
});
|
||||
|
||||
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>',
|
||||
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})`, () => {
|
||||
const message = new Message({
|
||||
draft: true,
|
||||
from: ['one@nylas.com'],
|
||||
body: scenario.body,
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
})
|
||||
SignatureComposerExtension.prepareNewDraft({draft: message});
|
||||
expect(message.body).toEqual(scenario.expected)
|
||||
});
|
||||
SignatureComposerExtension.prepareNewDraft({ draft: message });
|
||||
expect(message.body).toEqual(scenario.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint quote-props: 0 */
|
||||
import {SignatureStore} from 'nylas-exports'
|
||||
import { SignatureStore } from 'nylas-exports';
|
||||
|
||||
let SIGNATURES = {
|
||||
'1': {
|
||||
|
@ -12,35 +12,36 @@ let SIGNATURES = {
|
|||
title: 'two',
|
||||
body: 'Here is my second sig!',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
'one@nylas.com': '2',
|
||||
'two@nylas.com': '2',
|
||||
'three@nylas.com': null,
|
||||
}
|
||||
};
|
||||
|
||||
describe('SignatureStore', function signatureStore() {
|
||||
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(() => {
|
||||
NylasEnv.config.set(`nylas.signatures`, SignatureStore.signatures)
|
||||
})
|
||||
spyOn(SignatureStore, 'signatureForEmail').andCallFake((email) => SIGNATURES[DEFAULTS[email]])
|
||||
spyOn(SignatureStore, 'selectedSignature').andCallFake(() => SIGNATURES['1'])
|
||||
SignatureStore.activate()
|
||||
})
|
||||
|
||||
NylasEnv.config.set(`nylas.signatures`, SignatureStore.signatures);
|
||||
});
|
||||
spyOn(SignatureStore, 'signatureForEmail').andCallFake(email => SIGNATURES[DEFAULTS[email]]);
|
||||
spyOn(SignatureStore, 'selectedSignature').andCallFake(() => SIGNATURES['1']);
|
||||
SignatureStore.activate();
|
||||
});
|
||||
|
||||
describe('signatureForAccountId', () => {
|
||||
it('should return the default signature for that account', () => {
|
||||
const titleForAccount1 = SignatureStore.signatureForEmail('one@nylas.com').title
|
||||
expect(titleForAccount1).toEqual(SIGNATURES['2'].title)
|
||||
const account2Def = SignatureStore.signatureForEmail('three@nylas.com')
|
||||
expect(account2Def).toEqual(undefined)
|
||||
})
|
||||
})
|
||||
const titleForAccount1 = SignatureStore.signatureForEmail('one@nylas.com').title;
|
||||
expect(titleForAccount1).toEqual(SIGNATURES['2'].title);
|
||||
const account2Def = SignatureStore.signatureForEmail('three@nylas.com');
|
||||
expect(account2Def).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSignature', () => {
|
||||
beforeEach(() => {
|
||||
|
@ -48,17 +49,17 @@ describe('SignatureStore', function signatureStore() {
|
|||
if (key === 'nylas.signatures') {
|
||||
SIGNATURES = newObject;
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
it('should remove the signature from our list of signatures', () => {
|
||||
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId]
|
||||
SignatureStore._onRemoveSignature(toRemove)
|
||||
expect(SIGNATURES['1']).toEqual(undefined)
|
||||
})
|
||||
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId];
|
||||
SignatureStore._onRemoveSignature(toRemove);
|
||||
expect(SIGNATURES['1']).toEqual(undefined);
|
||||
});
|
||||
it('should reset selectedSignatureId to a different signature', () => {
|
||||
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId]
|
||||
SignatureStore._onRemoveSignature(toRemove)
|
||||
expect(SignatureStore.selectedSignatureId).toNotEqual('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId];
|
||||
SignatureStore._onRemoveSignature(toRemove);
|
||||
expect(SignatureStore.selectedSignatureId).toNotEqual('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import {ExtensionRegistry} from 'nylas-exports';
|
||||
import { ExtensionRegistry } from 'nylas-exports';
|
||||
import SpellcheckComposerExtension from './spellcheck-composer-extension';
|
||||
|
||||
export function activate() {
|
||||
if (NylasEnv.config.get("core.composing.spellcheck")) {
|
||||
if (NylasEnv.config.get('core.composing.spellcheck')) {
|
||||
ExtensionRegistry.Composer.register(SpellcheckComposerExtension);
|
||||
}
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
if (NylasEnv.config.get("core.composing.spellcheck")) {
|
||||
if (NylasEnv.config.get('core.composing.spellcheck')) {
|
||||
ExtensionRegistry.Composer.unregister(SpellcheckComposerExtension);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import _ from 'underscore'
|
||||
import {DOMUtils, ComposerExtension, Spellchecker} from 'nylas-exports';
|
||||
import _ from 'underscore';
|
||||
import { DOMUtils, ComposerExtension, Spellchecker } from 'nylas-exports';
|
||||
|
||||
const recycled = [];
|
||||
const MAX_MISPELLINGS = 10
|
||||
const MAX_MISPELLINGS = 10;
|
||||
|
||||
function getSpellingNodeForText(text) {
|
||||
let node = recycled.pop();
|
||||
|
@ -28,12 +28,17 @@ function whileApplyingSelectionChanges(rootNode, cb) {
|
|||
modified: false,
|
||||
};
|
||||
|
||||
rootNode.style.display = 'none'
|
||||
rootNode.style.display = 'none';
|
||||
cb(selectionSnapshot);
|
||||
rootNode.style.display = 'block'
|
||||
rootNode.style.display = 'block';
|
||||
|
||||
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
|
||||
// are not split between text nodes. (ie: doesn, 't => doesn't)
|
||||
function unwrapWords(rootNode) {
|
||||
whileApplyingSelectionChanges(rootNode, (selectionSnapshot) => {
|
||||
whileApplyingSelectionChanges(rootNode, selectionSnapshot => {
|
||||
const spellingNodes = rootNode.querySelectorAll('spelling');
|
||||
for (let ii = 0; ii < spellingNodes.length; 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
|
||||
// with a <spelling> node and updates the selection to account for the change.
|
||||
function wrapMisspelledWords(rootNode) {
|
||||
whileApplyingSelectionChanges(rootNode, (selectionSnapshot) => {
|
||||
const treeWalker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
|
||||
acceptNode: (node) => {
|
||||
// skip the entire subtree inside <code> tags and <a> tags...
|
||||
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;
|
||||
},
|
||||
});
|
||||
whileApplyingSelectionChanges(rootNode, selectionSnapshot => {
|
||||
const treeWalker = document.createTreeWalker(
|
||||
rootNode,
|
||||
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
|
||||
{
|
||||
acceptNode: node => {
|
||||
// skip the entire subtree inside <code> tags and <a> tags...
|
||||
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 = [];
|
||||
|
||||
|
@ -89,7 +100,7 @@ function wrapMisspelledWords(rootNode) {
|
|||
|
||||
while (true) {
|
||||
const node = nodeList.shift();
|
||||
if ((node === undefined) || (nodeMisspellingsFound > MAX_MISPELLINGS)) {
|
||||
if (node === undefined || nodeMisspellingsFound > MAX_MISPELLINGS) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -98,18 +109,21 @@ function wrapMisspelledWords(rootNode) {
|
|||
|
||||
while (true) {
|
||||
const match = nodeWordRegexp.exec(nodeContent);
|
||||
if ((match === null) || (nodeMisspellingsFound > MAX_MISPELLINGS)) {
|
||||
if (match === null || nodeMisspellingsFound > MAX_MISPELLINGS) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Spellchecker.isMisspelled(match[0])) {
|
||||
// The insertion point is currently at the end of this misspelled word.
|
||||
// Do not mark it until the user types a space or leaves.
|
||||
if ((selectionSnapshot.focusNode === node) && (selectionSnapshot.focusOffset === match.index + match[0].length)) {
|
||||
if (
|
||||
selectionSnapshot.focusNode === node &&
|
||||
selectionSnapshot.focusOffset === match.index + match[0].length
|
||||
) {
|
||||
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 spellingSpan = getSpellingNodeForText(match[0]);
|
||||
|
@ -139,11 +153,11 @@ function wrapMisspelledWords(rootNode) {
|
|||
}
|
||||
|
||||
let currentlyRunningSpellChecker = false;
|
||||
const runSpellChecker = _.debounce((rootNode) => {
|
||||
const runSpellChecker = _.debounce(rootNode => {
|
||||
currentlyRunningSpellChecker = true;
|
||||
unwrapWords(rootNode);
|
||||
Spellchecker.handler.provideHintText(rootNode.textContent).then(() => {
|
||||
wrapMisspelledWords(rootNode)
|
||||
wrapMisspelledWords(rootNode);
|
||||
|
||||
// We defer here so that when the MutationObserver fires the
|
||||
// SpellcheckComposerExtension.onContentChanged callback we will properly
|
||||
|
@ -153,20 +167,18 @@ const runSpellChecker = _.debounce((rootNode) => {
|
|||
_.defer(() => {
|
||||
currentlyRunningSpellChecker = false;
|
||||
});
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
export default class SpellcheckComposerExtension extends ComposerExtension {
|
||||
|
||||
static onContentChanged({editor}) {
|
||||
const {rootNode} = editor
|
||||
static onContentChanged({ editor }) {
|
||||
const { rootNode } = editor;
|
||||
if (!currentlyRunningSpellChecker) {
|
||||
runSpellChecker(rootNode);
|
||||
}
|
||||
}
|
||||
|
||||
static onShowContextMenu({editor, menu}) {
|
||||
static onShowContextMenu({ editor, menu }) {
|
||||
const selection = editor.currentSelection();
|
||||
const range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0);
|
||||
const word = range.toString();
|
||||
|
@ -174,17 +186,17 @@ export default class SpellcheckComposerExtension extends ComposerExtension {
|
|||
Spellchecker.appendSpellingItemsToMenu({
|
||||
menu,
|
||||
word,
|
||||
onCorrect: (correction) => {
|
||||
onCorrect: correction => {
|
||||
DOMUtils.Mutating.applyTextInRange(range, selection, correction);
|
||||
SpellcheckComposerExtension.onContentChanged({editor});
|
||||
SpellcheckComposerExtension.onContentChanged({ editor });
|
||||
},
|
||||
onDidLearn: () => {
|
||||
SpellcheckComposerExtension.onContentChanged({editor});
|
||||
SpellcheckComposerExtension.onContentChanged({ editor });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static applyTransformsForSending({draftBodyRootNode}) {
|
||||
static applyTransformsForSending({ draftBodyRootNode }) {
|
||||
const spellingEls = draftBodyRootNode.querySelectorAll('spelling');
|
||||
for (const spellingEl of Array.from(spellingEls)) {
|
||||
// move contents out of the spelling node, remove the node
|
||||
|
@ -199,5 +211,4 @@ export default class SpellcheckComposerExtension extends ComposerExtension {
|
|||
static unapplyTransformsForSending() {
|
||||
// no need to put spelling nodes back!
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {Spellchecker, Message} from 'nylas-exports';
|
||||
import { Spellchecker, Message } from 'nylas-exports';
|
||||
|
||||
import SpellcheckComposerExtension from '../lib/spellcheck-composer-extension';
|
||||
|
||||
|
@ -14,31 +14,31 @@ describe('SpellcheckComposerExtension', function spellcheckComposerExtension() {
|
|||
// Avoid differences between node-spellcheck on different platforms
|
||||
const lookupPath = path.join(__dirname, 'fixtures', 'california-spelling-lookup.json');
|
||||
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({
|
||||
then(cb) {
|
||||
cb()
|
||||
cb();
|
||||
},
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe("onContentChanged", () => {
|
||||
it("correctly walks a DOM tree and surrounds mispelled words", () => {
|
||||
describe('onContentChanged', () => {
|
||||
it('correctly walks a DOM tree and surrounds mispelled words', () => {
|
||||
const node = document.createElement('div');
|
||||
node.innerHTML = initialHTML;
|
||||
|
||||
const editor = {
|
||||
rootNode: node,
|
||||
whilePreservingSelection: (cb) => cb(),
|
||||
whilePreservingSelection: cb => cb(),
|
||||
};
|
||||
|
||||
SpellcheckComposerExtension.onContentChanged({editor});
|
||||
advanceClock(1000) // Wait for debounce
|
||||
advanceClock(1) // Wait for defer
|
||||
SpellcheckComposerExtension.onContentChanged({ editor });
|
||||
advanceClock(1000); // Wait for debounce
|
||||
advanceClock(1); // Wait for defer
|
||||
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');
|
||||
node.innerHTML = `
|
||||
<br>
|
||||
|
@ -54,12 +54,12 @@ describe('SpellcheckComposerExtension', function spellcheckComposerExtension() {
|
|||
|
||||
const editor = {
|
||||
rootNode: node,
|
||||
whilePreservingSelection: (cb) => cb(),
|
||||
whilePreservingSelection: cb => cb(),
|
||||
};
|
||||
|
||||
SpellcheckComposerExtension.onContentChanged({editor});
|
||||
advanceClock(1000) // Wait for debounce
|
||||
advanceClock(1) // Wait for defer
|
||||
SpellcheckComposerExtension.onContentChanged({ editor });
|
||||
advanceClock(1000); // Wait for debounce
|
||||
advanceClock(1); // Wait for defer
|
||||
expect(node.innerHTML).toEqual(`
|
||||
<br>
|
||||
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", () => {
|
||||
it("removes the spelling annotations it inserted", () => {
|
||||
describe('applyTransformsForSending', () => {
|
||||
it('removes the spelling annotations it inserted', () => {
|
||||
const draft = new Message({ body: afterHTML });
|
||||
const fragment = document.createDocumentFragment();
|
||||
const draftBodyRootNode = document.createElement('root')
|
||||
fragment.appendChild(draftBodyRootNode)
|
||||
draftBodyRootNode.innerHTML = afterHTML
|
||||
SpellcheckComposerExtension.applyTransformsForSending({draftBodyRootNode, draft});
|
||||
const draftBodyRootNode = document.createElement('root');
|
||||
fragment.appendChild(draftBodyRootNode);
|
||||
draftBodyRootNode.innerHTML = afterHTML;
|
||||
SpellcheckComposerExtension.applyTransformsForSending({ draftBodyRootNode, draft });
|
||||
expect(draftBodyRootNode.innerHTML).toEqual(initialHTML);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint global-require: 0 */
|
||||
import {PreferencesUIStore, ComponentRegistry, ExtensionRegistry} from 'nylas-exports';
|
||||
import { PreferencesUIStore, ComponentRegistry, ExtensionRegistry } from 'nylas-exports';
|
||||
import TemplatePicker from './template-picker';
|
||||
import TemplateStatusBar from './template-status-bar';
|
||||
import TemplateComposerExtension from './template-composer-extension';
|
||||
|
@ -11,8 +11,8 @@ export function activate(state = {}) {
|
|||
displayName: 'Quick Replies',
|
||||
componentClassFn: () => require('./preferences-templates').default,
|
||||
});
|
||||
ComponentRegistry.register(TemplatePicker, {role: 'Composer:ActionButton'});
|
||||
ComponentRegistry.register(TemplateStatusBar, {role: 'Composer:Footer'});
|
||||
ComponentRegistry.register(TemplatePicker, { role: 'Composer:ActionButton' });
|
||||
ComponentRegistry.register(TemplateStatusBar, { role: 'Composer:Footer' });
|
||||
PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
|
||||
ExtensionRegistry.Composer.register(TemplateComposerExtension);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import _ from 'underscore';
|
||||
import {Contenteditable, RetinaImg} from 'nylas-component-kit';
|
||||
import {React} from 'nylas-exports';
|
||||
import { Contenteditable, RetinaImg } from 'nylas-component-kit';
|
||||
import { React } from 'nylas-exports';
|
||||
|
||||
import TemplateStore from './template-store';
|
||||
import TemplateEditor from './template-editor';
|
||||
|
||||
|
||||
class PreferencesTemplates extends React.Component {
|
||||
static displayName = 'PreferencesTemplates';
|
||||
|
||||
|
@ -13,10 +12,10 @@ class PreferencesTemplates extends React.Component {
|
|||
super();
|
||||
this._templateSaveQueue = {};
|
||||
|
||||
const {templates, selectedTemplate, selectedTemplateName} = this._getStateFromStores();
|
||||
const { templates, selectedTemplate, selectedTemplateName } = this._getStateFromStores();
|
||||
this.state = {
|
||||
editAsHTML: false,
|
||||
editState: templates.length === 0 ? "new" : null,
|
||||
editState: templates.length === 0 ? 'new' : null,
|
||||
templates: templates,
|
||||
selectedTemplate: selectedTemplate,
|
||||
selectedTemplateName: selectedTemplateName,
|
||||
|
@ -36,13 +35,13 @@ class PreferencesTemplates extends React.Component {
|
|||
}
|
||||
|
||||
// SAVING AND LOADING TEMPLATES
|
||||
_loadTemplateContents = (template) => {
|
||||
_loadTemplateContents = template => {
|
||||
if (template) {
|
||||
TemplateStore.getTemplateContents(template.id, (contents) => {
|
||||
this.setState({contents: contents});
|
||||
TemplateStore.getTemplateContents(template.id, contents => {
|
||||
this.setState({ contents: contents });
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_saveTemplateNow(name, contents, callback) {
|
||||
TemplateStore.saveTemplate(name, contents, callback);
|
||||
|
@ -60,17 +59,20 @@ class PreferencesTemplates extends React.Component {
|
|||
this._templateSaveQueue = {};
|
||||
}
|
||||
|
||||
_saveTemplatesFromCache = _.debounce(PreferencesTemplates.prototype.__saveTemplatesFromCache, 500);
|
||||
_saveTemplatesFromCache = _.debounce(
|
||||
PreferencesTemplates.prototype.__saveTemplatesFromCache,
|
||||
500
|
||||
);
|
||||
|
||||
// OVERALL STATE HANDLING
|
||||
_onChange = () => {
|
||||
this.setState(this._getStateFromStores());
|
||||
}
|
||||
};
|
||||
|
||||
_getStateFromStores() {
|
||||
const templates = TemplateStore.items();
|
||||
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;
|
||||
} else if (!selectedTemplate) {
|
||||
selectedTemplate = templates.length > 0 ? templates[0] : null;
|
||||
|
@ -80,27 +82,25 @@ class PreferencesTemplates extends React.Component {
|
|||
if (selectedTemplate) {
|
||||
selectedTemplateName = this.state ? this.state.selectedTemplateName : selectedTemplate.name;
|
||||
}
|
||||
return {templates, selectedTemplate, selectedTemplateName};
|
||||
return { templates, selectedTemplate, selectedTemplateName };
|
||||
}
|
||||
|
||||
// TEMPLATE CONTENT EDITING
|
||||
_onEditTemplate = (event) => {
|
||||
_onEditTemplate = event => {
|
||||
const html = event.target.value;
|
||||
this.setState({contents: html});
|
||||
this.setState({ contents: html });
|
||||
if (this.state.selectedTemplate) {
|
||||
this._saveTemplateSoon(this.state.selectedTemplate.name, html);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onSelectTemplate = (event) => {
|
||||
_onSelectTemplate = event => {
|
||||
if (this.state.selectedTemplate) {
|
||||
this._saveTemplateNow(this.state.selectedTemplate.name, this.state.contents);
|
||||
}
|
||||
|
||||
const selectedId = event.target.value;
|
||||
const selectedTemplate = this.state.templates.find((template) =>
|
||||
template.id === selectedId
|
||||
);
|
||||
const selectedTemplate = this.state.templates.find(template => template.id === selectedId);
|
||||
|
||||
this.setState({
|
||||
selectedTemplate: selectedTemplate,
|
||||
|
@ -108,15 +108,22 @@ class PreferencesTemplates extends React.Component {
|
|||
contents: null,
|
||||
});
|
||||
this._loadTemplateContents(selectedTemplate);
|
||||
}
|
||||
};
|
||||
|
||||
_renderTemplatePicker() {
|
||||
const options = this.state.templates.map((template) => {
|
||||
return <option value={template.id} key={template.id}>{template.name}</option>
|
||||
const options = this.state.templates.map(template => {
|
||||
return (
|
||||
<option value={template.id} key={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
|
||||
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}
|
||||
</select>
|
||||
);
|
||||
|
@ -125,7 +132,7 @@ class PreferencesTemplates extends React.Component {
|
|||
_renderEditableTemplate() {
|
||||
return (
|
||||
<Contenteditable
|
||||
value={this.state.contents || ""}
|
||||
value={this.state.contents || ''}
|
||||
onChange={this._onEditTemplate}
|
||||
extensions={[TemplateEditor]}
|
||||
spellcheck={false}
|
||||
|
@ -134,76 +141,118 @@ class PreferencesTemplates extends React.Component {
|
|||
}
|
||||
|
||||
_renderHTMLTemplate() {
|
||||
return (
|
||||
<textarea
|
||||
value={this.state.contents || ""}
|
||||
onChange={this._onEditTemplate}
|
||||
/>
|
||||
);
|
||||
return <textarea value={this.state.contents || ''} onChange={this._onEditTemplate} />;
|
||||
}
|
||||
|
||||
_renderModeToggle() {
|
||||
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) {
|
||||
return (event) => {
|
||||
if (event.key === "Enter") {
|
||||
action()
|
||||
return event => {
|
||||
if (event.key === 'Enter') {
|
||||
action();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// TEMPLATE NAME EDITING
|
||||
_renderEditName() {
|
||||
return (
|
||||
<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)} />
|
||||
<button className="btn template-name-btn" onClick={this._saveName}>Save Name</button>
|
||||
<button className="btn template-name-btn" onClick={this._cancelEditName}>Cancel</button>
|
||||
Template Name:{' '}
|
||||
<input
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
_renderName() {
|
||||
const rawText = this.state.editAsHTML ? "Raw HTML " : "";
|
||||
const rawText = this.state.editAsHTML ? 'Raw HTML ' : '';
|
||||
return (
|
||||
<div className="section-title">
|
||||
{rawText}Template: {this._renderTemplatePicker()}
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
_onEditName = (event) => {
|
||||
this.setState({selectedTemplateName: event.target.value});
|
||||
}
|
||||
_onEditName = event => {
|
||||
this.setState({ selectedTemplateName: event.target.value });
|
||||
};
|
||||
|
||||
_cancelEditName = () => {
|
||||
this.setState({
|
||||
selectedTemplateName: this.state.selectedTemplate ? this.state.selectedTemplate.name : null,
|
||||
editState: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_saveName = () => {
|
||||
if (this.state.selectedTemplate && this.state.selectedTemplate.name !== this.state.selectedTemplateName) {
|
||||
TemplateStore.renameTemplate(this.state.selectedTemplate.name, this.state.selectedTemplateName, (renamedTemplate) => {
|
||||
this.setState({
|
||||
selectedTemplate: renamedTemplate,
|
||||
editState: null,
|
||||
});
|
||||
});
|
||||
if (
|
||||
this.state.selectedTemplate &&
|
||||
this.state.selectedTemplate.name !== this.state.selectedTemplateName
|
||||
) {
|
||||
TemplateStore.renameTemplate(
|
||||
this.state.selectedTemplate.name,
|
||||
this.state.selectedTemplateName,
|
||||
renamedTemplate => {
|
||||
this.setState({
|
||||
selectedTemplate: renamedTemplate,
|
||||
editState: null,
|
||||
});
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.setState({
|
||||
editState: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE AND NEW
|
||||
_deleteTemplate = () => {
|
||||
|
@ -213,32 +262,32 @@ class PreferencesTemplates extends React.Component {
|
|||
}
|
||||
if (numTemplates === 1) {
|
||||
this.setState({
|
||||
editState: "new",
|
||||
editState: 'new',
|
||||
selectedTemplate: null,
|
||||
selectedTemplateName: "",
|
||||
contents: "",
|
||||
selectedTemplateName: '',
|
||||
contents: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_startNewTemplate = () => {
|
||||
this.setState({
|
||||
editState: "new",
|
||||
editState: 'new',
|
||||
selectedTemplate: null,
|
||||
selectedTemplateName: "",
|
||||
contents: "",
|
||||
selectedTemplateName: '',
|
||||
contents: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_saveNewTemplate = () => {
|
||||
this.setState({contents: ""})
|
||||
TemplateStore.saveNewTemplate(this.state.selectedTemplateName, "", (template) => {
|
||||
this.setState({ contents: '' });
|
||||
TemplateStore.saveNewTemplate(this.state.selectedTemplateName, '', template => {
|
||||
this.setState({
|
||||
selectedTemplate: template,
|
||||
editState: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_cancelNewTemplate = () => {
|
||||
const template = this.state.templates.length > 0 ? this.state.templates[0] : null;
|
||||
|
@ -248,14 +297,27 @@ class PreferencesTemplates extends React.Component {
|
|||
editState: null,
|
||||
});
|
||||
this._loadTemplateContents(template);
|
||||
}
|
||||
};
|
||||
|
||||
_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 (
|
||||
<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)} />
|
||||
<button className="btn btn-emphasis template-name-btn" onClick={this._saveNewTemplate}>Save</button>
|
||||
Template Name:{' '}
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
|
@ -274,23 +336,23 @@ class PreferencesTemplates extends React.Component {
|
|||
<div className="template-wrap">
|
||||
{this.state.editAsHTML ? this._renderHTMLTemplate() : this._renderEditableTemplate()}
|
||||
</div>
|
||||
<div style={{marginTop: "5px"}}>
|
||||
<div style={{ marginTop: '5px' }}>
|
||||
<span className="editor-note">
|
||||
{_.size(this._templateSaveQueue) === 0 ? "Changes saved." : ""}
|
||||
{_.size(this._templateSaveQueue) === 0 ? 'Changes saved.' : ''}
|
||||
|
||||
</span>
|
||||
<span style={{"float": "right"}}>{this.state.editState === null ? deleteBtn : ""}</span>
|
||||
<span style={{ float: 'right' }}>{this.state.editState === null ? deleteBtn : ''}</span>
|
||||
</div>
|
||||
<div className="toggle-mode" style={{marginTop: "1em"}}>
|
||||
<div className="toggle-mode" style={{ marginTop: '1em' }}>
|
||||
{this._renderModeToggle()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
let editContainer = this._renderName();
|
||||
if (this.state.editState === "name") {
|
||||
if (this.state.editState === 'name') {
|
||||
editContainer = this._renderEditName();
|
||||
} else if (this.state.editState === "new") {
|
||||
} else if (this.state.editState === 'new') {
|
||||
editContainer = this._renderCreateNew();
|
||||
}
|
||||
|
||||
|
@ -302,26 +364,31 @@ class PreferencesTemplates extends React.Component {
|
|||
|
||||
return (
|
||||
<div className="container-templates">
|
||||
<section style={this.state.editState === "new" ? {marginBottom: 50} : null}>
|
||||
<section style={this.state.editState === 'new' ? { marginBottom: 50 } : null}>
|
||||
{editContainer}
|
||||
{this.state.editState !== "new" ? editor : null}
|
||||
{this.state.editState !== 'new' ? editor : null}
|
||||
{this.state.templates.length === 0 ? noTemplatesMessage : null}
|
||||
</section>
|
||||
|
||||
<section className="templates-instructions">
|
||||
<p>
|
||||
{`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
|
||||
sent.
|
||||
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 sent.
|
||||
</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 <code> tags with class "var empty".
|
||||
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 <code> tags with class
|
||||
"var empty".
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default PreferencesTemplates;
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import {DOMUtils, ComposerExtension} from 'nylas-exports';
|
||||
import { DOMUtils, ComposerExtension } from 'nylas-exports';
|
||||
|
||||
export default class TemplatesComposerExtension extends ComposerExtension {
|
||||
|
||||
static warningsForSending({draft}) {
|
||||
static warningsForSending({ draft }) {
|
||||
const warnings = [];
|
||||
if (draft.body.search(/<code[^>]*empty[^>]*>/i) > 0) {
|
||||
warnings.push('with an empty template area');
|
||||
|
@ -10,26 +9,32 @@ export default class TemplatesComposerExtension extends ComposerExtension {
|
|||
return warnings;
|
||||
}
|
||||
|
||||
static applyTransformsForSending = ({draftBodyRootNode}) => {
|
||||
draftBodyRootNode.innerHTML = draftBodyRootNode.innerHTML.replace(/<\/?code[^>]*>/g, (match) =>
|
||||
`<!-- ${match} -->`
|
||||
static applyTransformsForSending = ({ draftBodyRootNode }) => {
|
||||
draftBodyRootNode.innerHTML = draftBodyRootNode.innerHTML.replace(
|
||||
/<\/?code[^>]*>/g,
|
||||
match => `<!-- ${match} -->`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static unapplyTransformsForSending = ({draftBodyRootNode}) => {
|
||||
draftBodyRootNode.innerHTML = draftBodyRootNode.innerHTML.replace(/<!-- (<\/?code[^>]*>) -->/g, (match, node) =>
|
||||
node
|
||||
static unapplyTransformsForSending = ({ draftBodyRootNode }) => {
|
||||
draftBodyRootNode.innerHTML = draftBodyRootNode.innerHTML.replace(
|
||||
/<!-- (<\/?code[^>]*>) -->/g,
|
||||
(match, node) => node
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static onClick({editor, event}) {
|
||||
static onClick({ editor, event }) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
static onKeyDown({editor, event}) {
|
||||
static onKeyDown({ editor, event }) {
|
||||
const editableNode = editor.rootNode;
|
||||
if (event.key === 'Tab') {
|
||||
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
|
||||
// nearest <code> before/after the selection (depending on shift).
|
||||
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 nextIndex = null;
|
||||
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 selection = editor.currentSelection().rawSelection;
|
||||
const isWithinNode = (node) => {
|
||||
const isWithinNode = node => {
|
||||
let test = selection.baseNode;
|
||||
while (test !== editableNode) {
|
||||
if (test === node) { return true; }
|
||||
if (test === node) {
|
||||
return true;
|
||||
}
|
||||
test = test.parentNode;
|
||||
}
|
||||
return false;
|
||||
|
|
|
@ -1,22 +1,27 @@
|
|||
import {DOMUtils, ContenteditableExtension} from 'nylas-exports';
|
||||
import { DOMUtils, ContenteditableExtension } from 'nylas-exports';
|
||||
|
||||
export default class TemplateEditor extends ContenteditableExtension {
|
||||
|
||||
static onContentChanged = ({editor}) => {
|
||||
static onContentChanged = ({ editor }) => {
|
||||
// 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++) {
|
||||
const codeNode = codeNodes[ii];
|
||||
|
||||
// remove any style that was added by contenteditable
|
||||
codeNode.removeAttribute("style");
|
||||
codeNode.removeAttribute('style');
|
||||
|
||||
// grab the text content and the indexable text content
|
||||
const codeNodeText = codeNode.textContent;
|
||||
const indexText = DOMUtils.getIndexedTextContent(codeNode).map(({text}) => text).join("");
|
||||
const indexText = DOMUtils.getIndexedTextContent(codeNode)
|
||||
.map(({ text }) => text)
|
||||
.join('');
|
||||
|
||||
// unwrap any code nodes that don't start/end with {{}}, and any with line breaks inside
|
||||
if ((!codeNodeText.startsWith("{{")) || (!codeNodeText.endsWith("}}")) || (indexText.indexOf("\n") > -1)) {
|
||||
if (
|
||||
!codeNodeText.startsWith('{{') ||
|
||||
!codeNodeText.endsWith('}}') ||
|
||||
indexText.indexOf('\n') > -1
|
||||
) {
|
||||
editor.whilePreservingSelection(() => {
|
||||
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
|
||||
// trying to be "smart" and preserve styles, which is very undesirable for the <code> node styles. The
|
||||
// below code is a hack to prevent yellow text from appearing.
|
||||
const starNodes = editor.rootNode.querySelectorAll("*");
|
||||
const starNodes = editor.rootNode.querySelectorAll('*');
|
||||
for (let ii = 0; ii < starNodes.length; ii++) {
|
||||
const node = starNodes[ii];
|
||||
if ((!node.className) && (node.style.color === "#c79b11")) {
|
||||
if (!node.className && node.style.color === '#c79b11') {
|
||||
editor.whilePreservingSelection(() => {
|
||||
DOMUtils.unwrapNode(node);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fontNodes = editor.rootNode.querySelectorAll("font");
|
||||
const fontNodes = editor.rootNode.querySelectorAll('font');
|
||||
for (let ii = 0; ii < fontNodes.length; ii++) {
|
||||
const node = fontNodes[ii];
|
||||
if (node.color === "#c79b11") {
|
||||
if (node.color === '#c79b11') {
|
||||
editor.whilePreservingSelection(() => {
|
||||
DOMUtils.unwrapNode(node);
|
||||
});
|
||||
|
@ -53,11 +58,11 @@ export default class TemplateEditor extends ContenteditableExtension {
|
|||
// Regex finds any {{ <contents> }} that doesn't contain {, }, or \n
|
||||
// https://regex101.com/r/jF2oF4/1
|
||||
for (const range of editor.regExpSelectorAll(/\{\{[^\n{}]*?\}\}/g)) {
|
||||
if (!DOMUtils.isWrapped(range, "CODE")) {
|
||||
if (!DOMUtils.isWrapped(range, 'CODE')) {
|
||||
// Preserve the selection based on text index within the range matched by the regex
|
||||
const selIndex = editor.getSelectionTextIndex(range);
|
||||
const codeNode = DOMUtils.wrap(range, "CODE");
|
||||
codeNode.className = "var empty";
|
||||
const codeNode = DOMUtils.wrap(range, 'CODE');
|
||||
codeNode.className = 'var empty';
|
||||
|
||||
// Sets node contents to just its textContent, strips HTML
|
||||
codeNode.textContent = codeNode.textContent;
|
||||
|
@ -67,5 +72,5 @@ export default class TemplateEditor extends ContenteditableExtension {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
/* eslint jsx-a11y/tabindex-no-positive: 0 */
|
||||
import {Actions, React, ReactDOM} from 'nylas-exports';
|
||||
import {Menu, RetinaImg} from 'nylas-component-kit';
|
||||
import { Actions, React, ReactDOM, PropTypes } from 'nylas-exports';
|
||||
import { Menu, RetinaImg } from 'nylas-component-kit';
|
||||
import TemplateStore from './template-store';
|
||||
|
||||
class TemplatePopover extends React.Component {
|
||||
static displayName = 'TemplatePopover';
|
||||
|
||||
static propTypes = {
|
||||
headerMessageId: React.PropTypes.string,
|
||||
headerMessageId: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -20,7 +20,7 @@ class TemplatePopover extends React.Component {
|
|||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = TemplateStore.listen(() => {
|
||||
this.setState({templates: TemplateStore.items()});
|
||||
this.setState({ templates: TemplateStore.items() });
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -31,38 +31,40 @@ class TemplatePopover extends React.Component {
|
|||
}
|
||||
|
||||
_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;
|
||||
});
|
||||
}
|
||||
|
||||
_onSearchValueChange = (event) => {
|
||||
this.setState({searchValue: event.target.value});
|
||||
_onSearchValueChange = event => {
|
||||
this.setState({ searchValue: event.target.value });
|
||||
};
|
||||
|
||||
_onChooseTemplate = (template) => {
|
||||
Actions.insertTemplateId({templateId: template.id, headerMessageId: this.props.headerMessageId});
|
||||
_onChooseTemplate = template => {
|
||||
Actions.insertTemplateId({
|
||||
templateId: template.id,
|
||||
headerMessageId: this.props.headerMessageId,
|
||||
});
|
||||
Actions.closePopover();
|
||||
}
|
||||
};
|
||||
|
||||
_onManageTemplates = () => {
|
||||
Actions.showTemplates();
|
||||
};
|
||||
|
||||
_onNewTemplate = () => {
|
||||
Actions.createTemplate({headerMessageId: this.props.headerMessageId});
|
||||
Actions.createTemplate({ headerMessageId: this.props.headerMessageId });
|
||||
};
|
||||
|
||||
_onClickButton = () => {
|
||||
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
|
||||
Actions.openPopover(
|
||||
this._renderPopover(),
|
||||
{originRect: buttonRect, direction: 'up'}
|
||||
)
|
||||
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
Actions.openPopover(this._renderPopover(), { originRect: buttonRect, direction: 'up' });
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -81,8 +83,12 @@ class TemplatePopover extends React.Component {
|
|||
|
||||
// note: these are using onMouseDown to avoid clearing focus in the composer (I think)
|
||||
const footerComponents = [
|
||||
<div className="item" key="new" onMouseDown={this._onNewTemplate}>Save Draft as Template...</div>,
|
||||
<div className="item" key="manage" onMouseDown={this._onManageTemplates}>Manage Templates...</div>,
|
||||
<div className="item" key="new" onMouseDown={this._onNewTemplate}>
|
||||
Save Draft as Template...
|
||||
</div>,
|
||||
<div className="item" key="manage" onMouseDown={this._onManageTemplates}>
|
||||
Manage Templates...
|
||||
</div>,
|
||||
];
|
||||
|
||||
return (
|
||||
|
@ -91,28 +97,27 @@ class TemplatePopover extends React.Component {
|
|||
headerComponents={headerComponents}
|
||||
footerComponents={footerComponents}
|
||||
items={filteredTemplates}
|
||||
itemKey={(item) => item.id}
|
||||
itemContent={(item) => item.name}
|
||||
itemKey={item => item.id}
|
||||
itemContent={item => item.name}
|
||||
onSelect={this._onChooseTemplate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class TemplatePicker extends React.Component {
|
||||
static displayName = 'TemplatePicker';
|
||||
|
||||
static propTypes = {
|
||||
headerMessageId: React.PropTypes.string,
|
||||
headerMessageId: PropTypes.string,
|
||||
};
|
||||
|
||||
_onClickButton = () => {
|
||||
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
|
||||
Actions.openPopover(
|
||||
<TemplatePopover headerMessageId={this.props.headerMessageId} />,
|
||||
{originRect: buttonRect, direction: 'up'}
|
||||
)
|
||||
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
Actions.openPopover(<TemplatePopover headerMessageId={this.props.headerMessageId} />, {
|
||||
originRect: buttonRect,
|
||||
direction: 'up',
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -128,10 +133,7 @@ class TemplatePicker extends React.Component {
|
|||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
|
||||
<RetinaImg
|
||||
name="icon-composer-dropdown.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import {React} from 'nylas-exports';
|
||||
import { React, PropTypes } from 'nylas-exports';
|
||||
|
||||
class TemplateStatusBar extends React.Component {
|
||||
static displayName = 'TemplateStatusBar';
|
||||
|
||||
static propTypes = {
|
||||
draft: React.PropTypes.object.isRequired,
|
||||
draft: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -19,13 +19,13 @@ class TemplateStatusBar extends React.Component {
|
|||
if (this._usingTemplate(this.props)) {
|
||||
return (
|
||||
<div className="template-status-bar">
|
||||
Press "tab" to quickly move between the blanks - highlighting will not be visible to recipients.
|
||||
Press "tab" to quickly move between the blanks - highlighting will not be
|
||||
visible to recipients.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TemplateStatusBar.containerStyles = {
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
/* eslint global-require: 0*/
|
||||
|
||||
import {DraftStore, Actions, QuotedHTMLTransformer} from 'nylas-exports';
|
||||
import { DraftStore, Actions, QuotedHTMLTransformer } from 'nylas-exports';
|
||||
import NylasStore from 'nylas-store';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
class TemplateStore extends NylasStore {
|
||||
|
||||
// Support accented characters in template names
|
||||
// https://regex101.com/r/nD3eY8/1
|
||||
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
|
||||
// could possibly slow down app launch
|
||||
fs.exists(this._templatesDir, (exists) => {
|
||||
fs.exists(this._templatesDir, exists => {
|
||||
if (exists) {
|
||||
this._populate();
|
||||
this.watch();
|
||||
|
@ -92,15 +91,18 @@ class TemplateStore extends NylasStore {
|
|||
fs.readdir(this._templatesDir, (err, filenames) => {
|
||||
if (err) {
|
||||
NylasEnv.showErrorDialog({
|
||||
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.`,
|
||||
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.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._items = [];
|
||||
for (let i = 0, filename; i < filenames.length; i++) {
|
||||
filename = filenames[i];
|
||||
if (filename[0] === '.') { continue; }
|
||||
if (filename[0] === '.') {
|
||||
continue;
|
||||
}
|
||||
const displayname = path.basename(filename, path.extname(filename));
|
||||
this._items.push({
|
||||
id: filename,
|
||||
|
@ -112,11 +114,12 @@ class TemplateStore extends NylasStore {
|
|||
});
|
||||
}
|
||||
|
||||
_onCreateTemplate({headerMessageId, name, contents} = {}) {
|
||||
_onCreateTemplate({ headerMessageId, name, contents } = {}) {
|
||||
if (headerMessageId) {
|
||||
DraftStore.sessionForClientId(headerMessageId).then((session) => {
|
||||
DraftStore.sessionForClientId(headerMessageId).then(session => {
|
||||
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);
|
||||
|
||||
const sigIndex = draftContents.indexOf('<signature>');
|
||||
|
@ -125,7 +128,9 @@ class TemplateStore extends NylasStore {
|
|||
this._displayError('Give your draft a subject to name your template.');
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
@ -151,13 +156,15 @@ class TemplateStore extends NylasStore {
|
|||
}
|
||||
_displayDialog(title, message, buttons) {
|
||||
const dialog = require('electron').remote.dialog;
|
||||
return (dialog.showMessageBox({
|
||||
title: title,
|
||||
message: title,
|
||||
detail: message,
|
||||
buttons: buttons,
|
||||
type: 'info',
|
||||
}) === 0);
|
||||
return (
|
||||
dialog.showMessageBox({
|
||||
title: title,
|
||||
message: title,
|
||||
detail: message,
|
||||
buttons: buttons,
|
||||
type: 'info',
|
||||
}) === 0
|
||||
);
|
||||
}
|
||||
|
||||
saveNewTemplate(name, contents, callback) {
|
||||
|
@ -167,7 +174,9 @@ class TemplateStore extends NylasStore {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -195,9 +204,11 @@ class TemplateStore extends NylasStore {
|
|||
|
||||
let template = this._getTemplate(name);
|
||||
this.unwatch();
|
||||
fs.writeFile(templatePath, contents, (err) => {
|
||||
fs.writeFile(templatePath, contents, err => {
|
||||
this.watch();
|
||||
if (err) { this._displayError(err); }
|
||||
if (err) {
|
||||
this._displayError(err);
|
||||
}
|
||||
if (template === null) {
|
||||
template = {
|
||||
id: filename,
|
||||
|
@ -214,13 +225,17 @@ class TemplateStore extends NylasStore {
|
|||
|
||||
deleteTemplate(name, callback) {
|
||||
const template = this._getTemplate(name);
|
||||
if (!template) { return; }
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._displayDialog(
|
||||
if (
|
||||
this._displayDialog(
|
||||
'Delete this template?',
|
||||
'The template and its file will be permanently deleted.',
|
||||
['Delete', 'Cancel']
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
fs.unlink(template.path, () => {
|
||||
this._populate();
|
||||
if (callback) {
|
||||
|
@ -232,10 +247,14 @@ class TemplateStore extends NylasStore {
|
|||
|
||||
renameTemplate(oldName, newName, callback) {
|
||||
const template = this._getTemplate(oldName);
|
||||
if (!template) { return; }
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (newName.length === 0) {
|
||||
|
@ -255,16 +274,16 @@ class TemplateStore extends NylasStore {
|
|||
});
|
||||
}
|
||||
|
||||
_onInsertTemplateId({templateId, headerMessageId} = {}) {
|
||||
this.getTemplateContents(templateId, (templateBody) => {
|
||||
DraftStore.sessionForClientId(headerMessageId).then((session) => {
|
||||
_onInsertTemplateId({ templateId, headerMessageId } = {}) {
|
||||
this.getTemplateContents(templateId, templateBody => {
|
||||
DraftStore.sessionForClientId(headerMessageId).then(session => {
|
||||
let proceed = true;
|
||||
if (!session.draft().pristine && !session.draft().hasEmptyBody()) {
|
||||
proceed = this._displayDialog(
|
||||
'Replace draft contents?',
|
||||
'It looks like your draft already has some content. Loading this template will ' +
|
||||
'Replace draft contents?',
|
||||
'It looks like your draft already has some content. Loading this template will ' +
|
||||
'overwrite all draft contents.',
|
||||
['Replace contents', 'Cancel']
|
||||
['Replace contents', 'Cancel']
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -273,9 +292,12 @@ class TemplateStore extends NylasStore {
|
|||
const sigIndex = draftContents.indexOf('<signature>');
|
||||
const signature = sigIndex > -1 ? draftContents.slice(sigIndex) : '';
|
||||
|
||||
const draftHtml = QuotedHTMLTransformer.appendQuotedHTML(templateBody + signature, session.draft().body);
|
||||
Actions.recordUserEvent("Email Template Inserted")
|
||||
session.changes.add({body: draftHtml});
|
||||
const draftHtml = QuotedHTMLTransformer.appendQuotedHTML(
|
||||
templateBody + signature,
|
||||
session.draft().body
|
||||
);
|
||||
Actions.recordUserEvent('Email Template Inserted');
|
||||
session.changes.add({ body: draftHtml });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -283,7 +305,9 @@ class TemplateStore extends NylasStore {
|
|||
|
||||
getTemplateContents(templateId, callback) {
|
||||
const template = this._getTemplate(null, templateId);
|
||||
if (!template) { return; }
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.readFile(template.path, (err, data) => {
|
||||
const body = data.toString();
|
||||
|
@ -293,4 +317,4 @@ class TemplateStore extends NylasStore {
|
|||
}
|
||||
|
||||
const store = new TemplateStore();
|
||||
export default store
|
||||
export default store;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import fs from 'fs';
|
||||
import { remote } from 'electron';
|
||||
import {Message, DraftStore} from 'nylas-exports';
|
||||
import { Message, DraftStore } from 'nylas-exports';
|
||||
import TemplateStore from '../lib/template-store';
|
||||
|
||||
const { shell } = remote;
|
||||
|
@ -13,8 +13,8 @@ const stubTemplateFiles = {
|
|||
};
|
||||
|
||||
const stubTemplates = [
|
||||
{id: 'template1.html', name: 'template1', path: `${stubTemplatesDir}/template1.html`},
|
||||
{id: 'template2.html', name: 'template2', path: `${stubTemplatesDir}/template2.html`},
|
||||
{ id: 'template1.html', name: 'template1', path: `${stubTemplatesDir}/template1.html` },
|
||||
{ id: 'template2.html', name: 'template2', path: `${stubTemplatesDir}/template2.html` },
|
||||
];
|
||||
|
||||
xdescribe('TemplateStore', function templateStore() {
|
||||
|
@ -38,9 +38,15 @@ xdescribe('TemplateStore', function templateStore() {
|
|||
|
||||
it('should expose templates in the templates directory', () => {
|
||||
let watchCallback;
|
||||
spyOn(fs, 'exists').andCallFake((path, callback) => { callback(true); });
|
||||
spyOn(fs, 'watch').andCallFake((path, callback) => { watchCallback = callback });
|
||||
spyOn(fs, 'readdir').andCallFake((path, callback) => { callback(null, Object.keys(stubTemplateFiles)); });
|
||||
spyOn(fs, 'exists').andCallFake((path, callback) => {
|
||||
callback(true);
|
||||
});
|
||||
spyOn(fs, 'watch').andCallFake((path, callback) => {
|
||||
watchCallback = callback;
|
||||
});
|
||||
spyOn(fs, 'readdir').andCallFake((path, callback) => {
|
||||
callback(null, Object.keys(stubTemplateFiles));
|
||||
});
|
||||
TemplateStore._init(stubTemplatesDir);
|
||||
watchCallback();
|
||||
expect(TemplateStore.items()).toEqual(stubTemplates);
|
||||
|
@ -51,7 +57,9 @@ xdescribe('TemplateStore', function templateStore() {
|
|||
let watchFired = false;
|
||||
|
||||
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) => {
|
||||
if (watchFired) {
|
||||
callback(null, Object.keys(stubTemplateFiles));
|
||||
|
@ -70,14 +78,20 @@ xdescribe('TemplateStore', function templateStore() {
|
|||
describe('insertTemplateId', () => {
|
||||
xit('should insert the template with the given id into the draft with the given id', () => {
|
||||
let watchCallback;
|
||||
spyOn(fs, 'exists').andCallFake((path, callback) => { callback(true); });
|
||||
spyOn(fs, 'watch').andCallFake((path, callback) => { watchCallback = callback });
|
||||
spyOn(fs, 'readdir').andCallFake((path, callback) => { callback(null, Object.keys(stubTemplateFiles)); });
|
||||
spyOn(fs, 'exists').andCallFake((path, callback) => {
|
||||
callback(true);
|
||||
});
|
||||
spyOn(fs, 'watch').andCallFake((path, callback) => {
|
||||
watchCallback = callback;
|
||||
});
|
||||
spyOn(fs, 'readdir').andCallFake((path, callback) => {
|
||||
callback(null, Object.keys(stubTemplateFiles));
|
||||
});
|
||||
TemplateStore._init(stubTemplatesDir);
|
||||
watchCallback();
|
||||
const add = jasmine.createSpy('add');
|
||||
spyOn(DraftStore, 'sessionForClientId').andCallFake(() => {
|
||||
return Promise.resolve({changes: {add}});
|
||||
return Promise.resolve({ changes: { add } });
|
||||
});
|
||||
|
||||
runs(() => {
|
||||
|
@ -98,13 +112,17 @@ xdescribe('TemplateStore', function templateStore() {
|
|||
describe('onCreateTemplate', () => {
|
||||
beforeEach(() => {
|
||||
let d;
|
||||
spyOn(DraftStore, 'sessionForClientId').andCallFake((headerMessageId) => {
|
||||
spyOn(DraftStore, 'sessionForClientId').andCallFake(headerMessageId => {
|
||||
if (headerMessageId === 'localid-nosubject') {
|
||||
d = new Message({subject: '', body: '<p>Body</p>'});
|
||||
d = new Message({ subject: '', body: '<p>Body</p>' });
|
||||
} 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);
|
||||
});
|
||||
TemplateStore._init(stubTemplatesDir);
|
||||
|
@ -112,8 +130,8 @@ xdescribe('TemplateStore', function templateStore() {
|
|||
|
||||
xit('should create a template with the given name and contents', () => {
|
||||
const ref = TemplateStore.items();
|
||||
TemplateStore._onCreateTemplate({name: '123', contents: 'bla'});
|
||||
const item = (ref != null ? ref[0] : undefined);
|
||||
TemplateStore._onCreateTemplate({ name: '123', contents: 'bla' });
|
||||
const item = ref != null ? ref[0] : undefined;
|
||||
expect(item.id).toBe('123.html');
|
||||
expect(item.name).toBe('123');
|
||||
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', () => {
|
||||
spyOn(TemplateStore, '_displayError');
|
||||
TemplateStore._onCreateTemplate({contents: 'bla'});
|
||||
TemplateStore._onCreateTemplate({ contents: 'bla' });
|
||||
expect(TemplateStore._displayError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
xit('should display an error if no content is provided', () => {
|
||||
spyOn(TemplateStore, '_displayError');
|
||||
TemplateStore._onCreateTemplate({name: 'bla'});
|
||||
TemplateStore._onCreateTemplate({ name: 'bla' });
|
||||
expect(TemplateStore._displayError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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`;
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
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', () => {
|
||||
TemplateStore._onCreateTemplate({name: '123', contents: 'bla'});
|
||||
TemplateStore._onCreateTemplate({ name: '123', contents: 'bla' });
|
||||
expect(shell.showItemInFolder).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -149,7 +167,7 @@ xdescribe('TemplateStore', function templateStore() {
|
|||
spyOn(TemplateStore, 'trigger');
|
||||
spyOn(TemplateStore, '_populate');
|
||||
runs(() => {
|
||||
TemplateStore._onCreateTemplate({headerMessageId: 'localid-b'});
|
||||
TemplateStore._onCreateTemplate({ headerMessageId: 'localid-b' });
|
||||
});
|
||||
waitsFor(() => TemplateStore.trigger.callCount > 0);
|
||||
runs(() => {
|
||||
|
@ -161,7 +179,7 @@ xdescribe('TemplateStore', function templateStore() {
|
|||
spyOn(TemplateStore, '_displayError');
|
||||
spyOn(fs, 'watch');
|
||||
runs(() => {
|
||||
TemplateStore._onCreateTemplate({headerMessageId: 'localid-nosubject'});
|
||||
TemplateStore._onCreateTemplate({ headerMessageId: 'localid-nosubject' });
|
||||
});
|
||||
waitsFor(() => TemplateStore._displayError.callCount > 0);
|
||||
runs(() => {
|
||||
|
|
|
@ -9,18 +9,17 @@
|
|||
import {
|
||||
React,
|
||||
ReactDOM,
|
||||
PropTypes,
|
||||
ComponentRegistry,
|
||||
QuotedHTMLTransformer,
|
||||
Actions,
|
||||
} from 'nylas-exports';
|
||||
|
||||
import {
|
||||
Menu,
|
||||
RetinaImg,
|
||||
} from 'nylas-component-kit';
|
||||
import { Menu, RetinaImg } from 'nylas-component-kit';
|
||||
|
||||
const YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate';
|
||||
const YandexTranslationKey = 'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e';
|
||||
const YandexTranslationKey =
|
||||
'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e';
|
||||
const YandexLanguages = {
|
||||
English: 'en',
|
||||
Spanish: 'es',
|
||||
|
@ -35,7 +34,6 @@ const YandexLanguages = {
|
|||
};
|
||||
|
||||
class TranslateButton extends React.Component {
|
||||
|
||||
// Adding a `displayName` makes debugging React easier
|
||||
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
|
||||
// property). Since our code depends on this prop, we mark it as a requirement.
|
||||
static propTypes = {
|
||||
draft: React.PropTypes.object.isRequired,
|
||||
session: React.PropTypes.object.isRequired,
|
||||
draft: PropTypes.object.isRequired,
|
||||
session: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
|
@ -54,13 +52,13 @@ class TranslateButton extends React.Component {
|
|||
}
|
||||
|
||||
_onError(error) {
|
||||
Actions.closePopover()
|
||||
Actions.closePopover();
|
||||
const dialog = require('electron').remote.dialog;
|
||||
dialog.showErrorBox('Language Conversion Failed', error.toString());
|
||||
}
|
||||
|
||||
_onTranslate = async (lang) => {
|
||||
Actions.closePopover()
|
||||
_onTranslate = async lang => {
|
||||
Actions.closePopover();
|
||||
|
||||
// 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
|
||||
|
@ -68,9 +66,9 @@ class TranslateButton extends React.Component {
|
|||
const draftHtml = this.props.draft.body;
|
||||
const text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml);
|
||||
|
||||
Actions.recordUserEvent("Email Translated", {
|
||||
Actions.recordUserEvent('Email Translated', {
|
||||
language: YandexLanguages[lang],
|
||||
})
|
||||
});
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('key', YandexTranslationKey);
|
||||
|
@ -79,9 +77,9 @@ class TranslateButton extends React.Component {
|
|||
queryParams.set('format', 'html');
|
||||
|
||||
try {
|
||||
const resp = await fetch(YandexTranslationURL, {body: queryParams});
|
||||
const resp = await fetch(YandexTranslationURL, { body: queryParams });
|
||||
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();
|
||||
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
|
||||
// automatically marshalls changes to the database and ensures that others accessing
|
||||
// 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();
|
||||
} catch (error) {
|
||||
this._onError(error);
|
||||
|
@ -101,29 +99,24 @@ class TranslateButton extends React.Component {
|
|||
};
|
||||
|
||||
_onClickTranslateButton = () => {
|
||||
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
|
||||
Actions.openPopover(
|
||||
this._renderPopover(),
|
||||
{originRect: buttonRect, direction: 'up'}
|
||||
)
|
||||
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
Actions.openPopover(this._renderPopover(), { originRect: buttonRect, direction: 'up' });
|
||||
};
|
||||
|
||||
// Helper method that will render the contents of our popover.
|
||||
_renderPopover() {
|
||||
const headerComponents = [
|
||||
<span>Translate:</span>,
|
||||
];
|
||||
const headerComponents = [<span>Translate:</span>];
|
||||
return (
|
||||
<Menu
|
||||
className="translate-language-picker"
|
||||
items={Object.keys(YandexLanguages)}
|
||||
itemKey={(item) => item}
|
||||
itemContent={(item) => item}
|
||||
itemKey={item => item}
|
||||
itemContent={item => item}
|
||||
headerComponents={headerComponents}
|
||||
defaultSelectedIndex={-1}
|
||||
onSelect={this._onTranslate}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 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"
|
||||
/>
|
||||
|
||||
<RetinaImg
|
||||
name="icon-composer-dropdown.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -183,9 +173,7 @@ export function activate() {
|
|||
});
|
||||
}
|
||||
|
||||
export function serialize() {
|
||||
|
||||
}
|
||||
export function serialize() {}
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(TranslateButton);
|
||||
|
|
|
@ -1,32 +1,29 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import {
|
||||
AccountStore,
|
||||
} from 'nylas-exports';
|
||||
import {Menu, ButtonDropdown, InjectedComponentSet} from 'nylas-component-kit';
|
||||
import { AccountStore } from 'nylas-exports';
|
||||
import { Menu, ButtonDropdown, InjectedComponentSet } from 'nylas-component-kit';
|
||||
|
||||
export default class AccountContactField extends React.Component {
|
||||
static displayName = 'AccountContactField';
|
||||
|
||||
static propTypes = {
|
||||
value: React.PropTypes.object,
|
||||
accounts: React.PropTypes.array,
|
||||
session: React.PropTypes.object.isRequired,
|
||||
draft: React.PropTypes.object.isRequired,
|
||||
onChange: React.PropTypes.func.isRequired,
|
||||
value: PropTypes.object,
|
||||
accounts: PropTypes.array,
|
||||
session: PropTypes.object.isRequired,
|
||||
draft: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
_onChooseContact = (contact) => {
|
||||
this.props.onChange({from: [contact]});
|
||||
this.props.session.ensureCorrectAccount()
|
||||
_onChooseContact = contact => {
|
||||
this.props.onChange({ from: [contact] });
|
||||
this.props.session.ensureCorrectAccount();
|
||||
this._dropdownComponent.toggleDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
_renderAccountSelector() {
|
||||
if (!this.props.value) {
|
||||
return (
|
||||
<span />
|
||||
);
|
||||
return <span />;
|
||||
}
|
||||
|
||||
const label = this.props.value.toString();
|
||||
|
@ -36,7 +33,9 @@ export default class AccountContactField extends React.Component {
|
|||
if (multipleAccounts || hasAliases) {
|
||||
return (
|
||||
<ButtonDropdown
|
||||
ref={(cm) => { this._dropdownComponent = cm; }}
|
||||
ref={cm => {
|
||||
this._dropdownComponent = cm;
|
||||
}}
|
||||
bordered={false}
|
||||
primaryItem={<span>{label}</span>}
|
||||
menu={this._renderAccounts(this.props.accounts)}
|
||||
|
@ -46,23 +45,21 @@ export default class AccountContactField extends React.Component {
|
|||
return this._renderAccountSpan(label);
|
||||
}
|
||||
|
||||
_renderAccountSpan = (label) => {
|
||||
_renderAccountSpan = label => {
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_renderMenuItem = (contact) => {
|
||||
_renderMenuItem = contact => {
|
||||
const className = classnames({
|
||||
'contact': true,
|
||||
contact: true,
|
||||
'is-alias': contact.isAlias,
|
||||
});
|
||||
return (
|
||||
<span className={className}>{contact.toString()}</span>
|
||||
);
|
||||
}
|
||||
return <span className={className}>{contact.toString()}</span>;
|
||||
};
|
||||
|
||||
_renderAccounts(accounts) {
|
||||
const items = AccountStore.aliasesFor(accounts);
|
||||
|
@ -76,13 +73,12 @@ export default class AccountContactField extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
_renderFromFieldComponents = () => {
|
||||
const {draft, session, accounts} = this.props
|
||||
const { draft, session, accounts } = this.props;
|
||||
return (
|
||||
<InjectedComponentSet
|
||||
className="dropdown-component"
|
||||
matching={{role: "Composer:FromFieldComponents"}}
|
||||
matching={{ role: 'Composer:FromFieldComponents' }}
|
||||
exposedProps={{
|
||||
draft,
|
||||
session,
|
||||
|
@ -90,8 +86,8 @@ export default class AccountContactField extends React.Component {
|
|||
currentAccount: draft.from[0],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
|
|
|
@ -1,26 +1,25 @@
|
|||
import React from 'react'
|
||||
import classnames from 'classnames'
|
||||
import {ComponentRegistry} from 'nylas-exports'
|
||||
import {InjectedComponentSet} from 'nylas-component-kit'
|
||||
import classnames from 'classnames';
|
||||
import { React, PropTypes, ComponentRegistry } from 'nylas-exports';
|
||||
import { InjectedComponentSet } from 'nylas-component-kit';
|
||||
|
||||
const ROLE = "Composer:ActionButton";
|
||||
const ROLE = 'Composer:ActionButton';
|
||||
|
||||
export default class ActionBarPlugins extends React.Component {
|
||||
static displayName = "ActionBarPlugins";
|
||||
static displayName = 'ActionBarPlugins';
|
||||
|
||||
static propTypes = {
|
||||
draft: React.PropTypes.object,
|
||||
session: React.PropTypes.object,
|
||||
isValidDraft: React.PropTypes.func,
|
||||
}
|
||||
draft: PropTypes.object,
|
||||
session: PropTypes.object,
|
||||
isValidDraft: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = this._getStateFromStores()
|
||||
this.state = this._getStateFromStores();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._usub = ComponentRegistry.listen(this._onComponentsChange)
|
||||
this._usub = ComponentRegistry.listen(this._onComponentsChange);
|
||||
}
|
||||
|
||||
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.
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.setState(this._getStateFromStores())
|
||||
})
|
||||
})
|
||||
this.setState(this._getStateFromStores());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_getPluginsLength() {
|
||||
return ComponentRegistry.findComponentsMatching({role: ROLE}).length;
|
||||
return ComponentRegistry.findComponentsMatching({ role: ROLE }).length;
|
||||
}
|
||||
|
||||
_getStateFromStores() {
|
||||
return {
|
||||
pluginsLoaded: this._getPluginsLength() > 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const className = classnames({
|
||||
"action-bar-animation-wrap": true,
|
||||
"plugins-loaded": this.state.pluginsLoaded,
|
||||
'action-bar-animation-wrap': true,
|
||||
'plugins-loaded': this.state.pluginsLoaded,
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -64,7 +63,7 @@ export default class ActionBarPlugins extends React.Component {
|
|||
<div className="action-bar-cover" />
|
||||
<InjectedComponentSet
|
||||
className="composer-action-bar-plugins"
|
||||
matching={{role: ROLE}}
|
||||
matching={{ role: ROLE }}
|
||||
exposedProps={{
|
||||
draft: this.props.draft,
|
||||
threadId: this.props.draft.threadId,
|
||||
|
@ -74,6 +73,6 @@ export default class ActionBarPlugins extends React.Component {
|
|||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,19 @@
|
|||
import React from 'react';
|
||||
import {Utils} from 'nylas-exports';
|
||||
import {DropZone, InjectedComponentSet} from 'nylas-component-kit';
|
||||
import { React, PropTypes, Utils } from 'nylas-exports';
|
||||
import { DropZone, InjectedComponentSet } from 'nylas-component-kit';
|
||||
|
||||
const NUM_TO_DISPLAY_MAX = 999;
|
||||
|
||||
export default class CollapsedParticipants extends React.Component {
|
||||
static displayName = "CollapsedParticipants";
|
||||
static displayName = 'CollapsedParticipants';
|
||||
|
||||
static propTypes = {
|
||||
// Arrays of Contact objects.
|
||||
to: React.PropTypes.array,
|
||||
cc: React.PropTypes.array,
|
||||
bcc: React.PropTypes.array,
|
||||
onDrop: React.PropTypes.func,
|
||||
onDragChange: React.PropTypes.func,
|
||||
}
|
||||
to: PropTypes.array,
|
||||
cc: PropTypes.array,
|
||||
bcc: PropTypes.array,
|
||||
onDrop: PropTypes.func,
|
||||
onDragChange: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
to: [],
|
||||
|
@ -22,7 +21,7 @@ export default class CollapsedParticipants extends React.Component {
|
|||
bcc: [],
|
||||
onDrop: () => {},
|
||||
onDragChange: () => {},
|
||||
}
|
||||
};
|
||||
|
||||
constructor(props = {}) {
|
||||
super(props);
|
||||
|
@ -30,7 +29,7 @@ export default class CollapsedParticipants extends React.Component {
|
|||
numToDisplay: NUM_TO_DISPLAY_MAX,
|
||||
numRemaining: 0,
|
||||
numBccRemaining: 0,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -60,8 +59,8 @@ export default class CollapsedParticipants extends React.Component {
|
|||
|
||||
_setNumHiddenParticipants() {
|
||||
const $wrap = this._participantsWrapEl;
|
||||
const $regulars = Array.from($wrap.getElementsByClassName("regular-contact"));
|
||||
const $bccs = Array.from($wrap.getElementsByClassName("bcc-contact"));
|
||||
const $regulars = Array.from($wrap.getElementsByClassName('regular-contact'));
|
||||
const $bccs = Array.from($wrap.getElementsByClassName('bcc-contact'));
|
||||
|
||||
const availableSpace = $wrap.getBoundingClientRect().width;
|
||||
let numRemaining = this.props.to.length + this.props.cc.length;
|
||||
|
@ -87,7 +86,7 @@ export default class CollapsedParticipants extends React.Component {
|
|||
numToDisplay += 1;
|
||||
}
|
||||
|
||||
this.setState({numToDisplay, numRemaining, numBccRemaining});
|
||||
this.setState({ numToDisplay, numRemaining, numBccRemaining });
|
||||
}
|
||||
|
||||
_renderNumRemaining() {
|
||||
|
@ -99,7 +98,8 @@ export default class CollapsedParticipants extends React.Component {
|
|||
} else if (this.state.numRemaining === 0 && this.state.numBccRemaining > 0) {
|
||||
str = `${this.state.numBccRemaining} Bcc`;
|
||||
} 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 (
|
||||
|
@ -110,25 +110,22 @@ export default class CollapsedParticipants extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
_collapsedContact = (contact) => {
|
||||
_collapsedContact = contact => {
|
||||
const name = contact.displayName();
|
||||
const key = contact.email + contact.name;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className="collapsed-contact regular-contact"
|
||||
>
|
||||
<span key={key} className="collapsed-contact regular-contact">
|
||||
<InjectedComponentSet
|
||||
matching={{role: "Composer:RecipientChip"}}
|
||||
exposedProps={{contact: contact, collapsed: true}}
|
||||
matching={{ role: 'Composer:RecipientChip' }}
|
||||
exposedProps={{ contact: contact, collapsed: true }}
|
||||
direction="row"
|
||||
inline
|
||||
/>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_collapsedBccContact = (contact, i) => {
|
||||
let name = contact.displayName();
|
||||
|
@ -137,18 +134,20 @@ export default class CollapsedParticipants extends React.Component {
|
|||
name = `Bcc: ${name}`;
|
||||
}
|
||||
return (
|
||||
<span key={key} className="collapsed-contact bcc-contact">{name}</span>
|
||||
<span key={key} className="collapsed-contact bcc-contact">
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
let toDisplay = contacts.concat(bcc);
|
||||
toDisplay = toDisplay.splice(0, this.state.numToDisplay);
|
||||
if (toDisplay.length === 0) {
|
||||
toDisplay = "Recipients";
|
||||
toDisplay = 'Recipients';
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -159,7 +158,9 @@ export default class CollapsedParticipants extends React.Component {
|
|||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
ref={(el) => { this._participantsWrapEl = el; }}
|
||||
ref={el => {
|
||||
this._participantsWrapEl = el;
|
||||
}}
|
||||
className="collapsed-composer-participants"
|
||||
>
|
||||
{this._renderNumRemaining()}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import React from 'react';
|
||||
import {Actions} from 'nylas-exports';
|
||||
import {RetinaImg} from 'nylas-component-kit';
|
||||
import { Actions } from 'nylas-exports';
|
||||
import { RetinaImg } from 'nylas-component-kit';
|
||||
|
||||
export default class ComposeButton extends React.Component {
|
||||
static displayName = 'ComposeButton';
|
||||
|
||||
_onNewCompose = () => {
|
||||
Actions.composeNewBlankDraft()
|
||||
}
|
||||
Actions.composeNewBlankDraft();
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types'
|
||||
import {ExtensionRegistry, DOMUtils} from 'nylas-exports';
|
||||
import {DropZone, ScrollRegion, Contenteditable} from 'nylas-component-kit';
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ExtensionRegistry, DOMUtils } from 'nylas-exports';
|
||||
import { DropZone, ScrollRegion, Contenteditable } from 'nylas-component-kit';
|
||||
|
||||
/**
|
||||
* Renders the text editor for the composer
|
||||
|
@ -89,7 +89,6 @@ class ComposerEditor extends Component {
|
|||
this.unsub();
|
||||
}
|
||||
|
||||
|
||||
// Public 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
|
||||
// quoted text that is visible. (as in forwarded messages.)
|
||||
//
|
||||
this._contenteditableComponent.atomicEdit(({editor}) => {
|
||||
this._contenteditableComponent.atomicEdit(({ editor }) => {
|
||||
editor.rootNode.focus();
|
||||
const lastNode = this._findLastNodeBeforeQuoteOrSignature(editor)
|
||||
const lastNode = this._findLastNodeBeforeQuoteOrSignature(editor);
|
||||
if (lastNode) {
|
||||
this._selectNode(lastNode, {collapseTo: NODE_END});
|
||||
this._selectNode(lastNode, { collapseTo: NODE_END });
|
||||
} else {
|
||||
this._selectNode(editor.rootNode, {collapseTo: NODE_BEGINNING});
|
||||
this._selectNode(editor.rootNode, { collapseTo: NODE_BEGINNING });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
focusAbsoluteEnd() {
|
||||
this._contenteditableComponent.atomicEdit(({editor}) => {
|
||||
this._contenteditableComponent.atomicEdit(({ editor }) => {
|
||||
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.
|
||||
_findLastNodeBeforeQuoteOrSignature(editor) {
|
||||
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 node = walker.nextNode();
|
||||
|
@ -150,10 +151,10 @@ class ComposerEditor extends Component {
|
|||
lastNode = node;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return lastNode
|
||||
return lastNode;
|
||||
}
|
||||
|
||||
_selectNode(node, {collapseTo} = {}) {
|
||||
_selectNode(node, { collapseTo } = {}) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
range.collapse(collapseTo);
|
||||
|
@ -171,17 +172,17 @@ class ComposerEditor extends Component {
|
|||
this._contenteditableComponent._onDOMMutated(mutations);
|
||||
}
|
||||
|
||||
_onDrop = (event) => {
|
||||
this._contenteditableComponent._onDrop(event)
|
||||
}
|
||||
_onDrop = event => {
|
||||
this._contenteditableComponent._onDrop(event);
|
||||
};
|
||||
|
||||
_onDragOver = (event) => {
|
||||
this._contenteditableComponent._onDragOver(event)
|
||||
}
|
||||
_onDragOver = event => {
|
||||
this._contenteditableComponent._onDragOver(event);
|
||||
};
|
||||
|
||||
_shouldAcceptDrop = (event) => {
|
||||
return this._contenteditableComponent._shouldAcceptDrop(event)
|
||||
}
|
||||
_shouldAcceptDrop = event => {
|
||||
return this._contenteditableComponent._shouldAcceptDrop(event);
|
||||
};
|
||||
// Helpers
|
||||
|
||||
_scrollToBottom = () => {
|
||||
|
@ -200,7 +201,7 @@ class ComposerEditor extends Component {
|
|||
* of the contenteditable. props.parentActions.scrollToBottom moves to the bottom of
|
||||
* the "send" button.
|
||||
*/
|
||||
_bottomIsNearby = (editableNode) => {
|
||||
_bottomIsNearby = editableNode => {
|
||||
const parentRect = this.props.parentActions.getComposerBoundingRect();
|
||||
const selfRect = editableNode.getBoundingClientRect();
|
||||
return Math.abs(parentRect.bottom - selfRect.bottom) <= 250;
|
||||
|
@ -246,19 +247,17 @@ class ComposerEditor extends Component {
|
|||
rect = DOMUtils.getSelectionRectFromDOM(selection);
|
||||
}
|
||||
if (rect) {
|
||||
this.props.parentActions.scrollTo({rect});
|
||||
this.props.parentActions.scrollTo({ rect });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Handlers
|
||||
|
||||
_onExtensionsChanged = () => {
|
||||
this.setState({extensions: ExtensionRegistry.Composer.extensions()});
|
||||
this.setState({ extensions: ExtensionRegistry.Composer.extensions() });
|
||||
};
|
||||
|
||||
|
||||
// Renderers
|
||||
|
||||
render() {
|
||||
|
@ -270,7 +269,11 @@ class ComposerEditor extends Component {
|
|||
shouldAcceptDrop={this._shouldAcceptDrop}
|
||||
>
|
||||
<Contenteditable
|
||||
ref={(cm) => { if (cm) { this._contenteditableComponent = cm; } }}
|
||||
ref={cm => {
|
||||
if (cm) {
|
||||
this._contenteditableComponent = cm;
|
||||
}
|
||||
}}
|
||||
value={this.props.body}
|
||||
onChange={this.props.onBodyChanged}
|
||||
onFilePaste={this.props.onFilePaste}
|
||||
|
@ -281,6 +284,6 @@ class ComposerEditor extends Component {
|
|||
);
|
||||
}
|
||||
}
|
||||
ComposerEditor.containerRequired = false
|
||||
ComposerEditor.containerRequired = false;
|
||||
|
||||
export default ComposerEditor;
|
||||
|
|
|
@ -1,21 +1,22 @@
|
|||
import React from 'react';
|
||||
import {Actions} from 'nylas-exports';
|
||||
import {RetinaImg} from 'nylas-component-kit';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Actions } from 'nylas-exports';
|
||||
import { RetinaImg } from 'nylas-component-kit';
|
||||
import Fields from './fields';
|
||||
|
||||
export default class ComposerHeaderActions extends React.Component {
|
||||
static displayName = 'ComposerHeaderActions';
|
||||
|
||||
static propTypes = {
|
||||
headerMessageId: React.PropTypes.string.isRequired,
|
||||
enabledFields: React.PropTypes.array.isRequired,
|
||||
participantsFocused: React.PropTypes.bool,
|
||||
onShowAndFocusField: React.PropTypes.func.isRequired,
|
||||
}
|
||||
headerMessageId: PropTypes.string.isRequired,
|
||||
enabledFields: PropTypes.array.isRequired,
|
||||
participantsFocused: PropTypes.bool,
|
||||
onShowAndFocusField: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
_onPopoutComposer = () => {
|
||||
Actions.composePopoutDraft(this.props.headerMessageId);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const items = [];
|
||||
|
@ -24,18 +25,24 @@ export default class ComposerHeaderActions extends React.Component {
|
|||
if (!this.props.enabledFields.includes(Fields.Cc)) {
|
||||
items.push(
|
||||
<span
|
||||
className="action show-cc" key="cc"
|
||||
className="action show-cc"
|
||||
key="cc"
|
||||
onClick={() => this.props.onShowAndFocusField(Fields.Cc)}
|
||||
>Cc</span>
|
||||
>
|
||||
Cc
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.props.enabledFields.includes(Fields.Bcc)) {
|
||||
items.push(
|
||||
<span
|
||||
className="action show-bcc" key="bcc"
|
||||
className="action show-bcc"
|
||||
key="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)) {
|
||||
items.push(
|
||||
<span
|
||||
className="action show-subject" key="subject"
|
||||
className="action show-subject"
|
||||
key="subject"
|
||||
onClick={() => this.props.onShowAndFocusField(Fields.Subject)}
|
||||
>Subject</span>
|
||||
>
|
||||
Subject
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -60,16 +70,12 @@ export default class ComposerHeaderActions extends React.Component {
|
|||
<RetinaImg
|
||||
name="composer-popout.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
style={{position: "relative", top: "-2px"}}
|
||||
style={{ position: 'relative', top: '-2px' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="composer-header-actions">
|
||||
{items}
|
||||
</div>
|
||||
);
|
||||
return <div className="composer-header-actions">{items}</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import _ from 'underscore';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {Utils, DraftHelpers, Actions, AccountStore} from 'nylas-exports';
|
||||
import {
|
||||
React,
|
||||
ReactDOM,
|
||||
PropTypes,
|
||||
Utils,
|
||||
DraftHelpers,
|
||||
Actions,
|
||||
AccountStore,
|
||||
} from 'nylas-exports';
|
||||
import {
|
||||
InjectedComponent,
|
||||
KeyCommandsRegion,
|
||||
|
@ -14,36 +20,35 @@ import ComposerHeaderActions from './composer-header-actions';
|
|||
import SubjectTextField from './subject-text-field';
|
||||
import Fields from './fields';
|
||||
|
||||
|
||||
const ScopedFromField = ListensToFluxStore(AccountContactField, {
|
||||
stores: [AccountStore],
|
||||
getStateFromStores: (props) => {
|
||||
getStateFromStores: props => {
|
||||
const savedOrReplyToThread = !!props.draft.threadId;
|
||||
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 {
|
||||
static displayName = "ComposerHeader";
|
||||
static displayName = 'ComposerHeader';
|
||||
|
||||
static propTypes = {
|
||||
draft: React.PropTypes.object.isRequired,
|
||||
session: React.PropTypes.object.isRequired,
|
||||
initiallyFocused: React.PropTypes.bool,
|
||||
draft: PropTypes.object.isRequired,
|
||||
session: PropTypes.object.isRequired,
|
||||
initiallyFocused: PropTypes.bool,
|
||||
// Subject text field injected component needs to call this function
|
||||
// when it is rendered with a new header component
|
||||
onNewHeaderComponents: React.PropTypes.func,
|
||||
}
|
||||
onNewHeaderComponents: PropTypes.func,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
parentTabGroup: React.PropTypes.object,
|
||||
}
|
||||
parentTabGroup: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props = {}) {
|
||||
super(props)
|
||||
super(props);
|
||||
this._els = {};
|
||||
this.state = this._initialStateForDraft(this.props.draft, props);
|
||||
}
|
||||
|
@ -66,26 +71,26 @@ export default class ComposerHeader extends React.Component {
|
|||
this.showAndFocusField(Fields.To);
|
||||
}
|
||||
|
||||
showAndFocusField = (fieldName) => {
|
||||
showAndFocusField = 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(() =>
|
||||
this._els[fieldName].focus()
|
||||
).catch(() => {
|
||||
})
|
||||
Utils.waitFor(() => this._els[fieldName])
|
||||
.then(() => this._els[fieldName].focus())
|
||||
.catch(() => {});
|
||||
|
||||
this.setState({enabledFields, participantsFocused});
|
||||
}
|
||||
this.setState({ enabledFields, participantsFocused });
|
||||
};
|
||||
|
||||
hideField = (fieldName) => {
|
||||
hideField = fieldName => {
|
||||
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)
|
||||
this.setState({enabledFields})
|
||||
}
|
||||
const enabledFields = _.without(this.state.enabledFields, fieldName);
|
||||
this.setState({ enabledFields });
|
||||
};
|
||||
|
||||
_ensureFilledFieldsEnabled(draft) {
|
||||
let enabledFields = this.state.enabledFields;
|
||||
|
@ -96,7 +101,7 @@ export default class ComposerHeader extends React.Component {
|
|||
enabledFields = enabledFields.concat([Fields.Bcc]);
|
||||
}
|
||||
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 true;
|
||||
}
|
||||
};
|
||||
|
||||
_onChangeParticipants = (changes) => {
|
||||
_onChangeParticipants = changes => {
|
||||
this.props.session.changes.add(changes);
|
||||
Actions.draftParticipantsChanged(this.props.draft.id, changes);
|
||||
}
|
||||
};
|
||||
|
||||
_onSubjectChange = (value) => {
|
||||
this.props.session.changes.add({subject: value});
|
||||
}
|
||||
_onSubjectChange = value => {
|
||||
this.props.session.changes.add({ subject: value });
|
||||
};
|
||||
|
||||
_onFocusInParticipants = () => {
|
||||
const fieldName = this.state.participantsLastActiveField || Fields.To;
|
||||
Utils.waitFor(() =>
|
||||
this._els[fieldName]
|
||||
).then(() =>
|
||||
this._els[fieldName].focus()
|
||||
).catch(() => {
|
||||
});
|
||||
Utils.waitFor(() => this._els[fieldName])
|
||||
.then(() => this._els[fieldName].focus())
|
||||
.catch(() => {});
|
||||
|
||||
this.setState({
|
||||
participantsFocused: true,
|
||||
participantsLastActiveField: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_onFocusOutParticipants = (lastFocusedEl) => {
|
||||
const active = Fields.ParticipantFields.find((fieldName) => {
|
||||
return this._els[fieldName] ? ReactDOM.findDOMNode(this._els[fieldName]).contains(lastFocusedEl) : false
|
||||
}
|
||||
);
|
||||
_onFocusOutParticipants = lastFocusedEl => {
|
||||
const active = Fields.ParticipantFields.find(fieldName => {
|
||||
return this._els[fieldName]
|
||||
? ReactDOM.findDOMNode(this._els[fieldName]).contains(lastFocusedEl)
|
||||
: false;
|
||||
});
|
||||
this.setState({
|
||||
participantsFocused: false,
|
||||
participantsLastActiveField: active,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_onFocusInSubject = () => {
|
||||
this.setState({
|
||||
subjectFocused: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_onFocusOutSubject = () => {
|
||||
this.setState({
|
||||
subjectFocused: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
isFocused() {
|
||||
return this.state.participantsFocused || this.state.subjectFocused;
|
||||
}
|
||||
|
||||
_onDragCollapsedParticipants = ({isDropping}) => {
|
||||
_onDragCollapsedParticipants = ({ isDropping }) => {
|
||||
if (isDropping) {
|
||||
this.setState({
|
||||
participantsFocused: true,
|
||||
enabledFields: [...Fields.ParticipantFields, Fields.From, Fields.Subject],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_renderParticipants = () => {
|
||||
let content = null;
|
||||
|
@ -205,7 +208,7 @@ export default class ComposerHeader extends React.Component {
|
|||
bcc={this.props.draft.bcc}
|
||||
onDragChange={this._onDragCollapsedParticipants}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// When the participants field collapses, we store the field that was last
|
||||
|
@ -214,7 +217,11 @@ export default class ComposerHeader extends React.Component {
|
|||
return (
|
||||
<KeyCommandsRegion
|
||||
tabIndex={-1}
|
||||
ref={(el) => { if (el) { this._els.participantsContainer = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els.participantsContainer = el;
|
||||
}
|
||||
}}
|
||||
className="expanded-participants"
|
||||
onFocusIn={this._onFocusInParticipants}
|
||||
onFocusOut={this._onFocusOutParticipants}
|
||||
|
@ -222,24 +229,32 @@ export default class ComposerHeader extends React.Component {
|
|||
{content}
|
||||
</KeyCommandsRegion>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_renderSubject = () => {
|
||||
if (!this.state.enabledFields.includes(Fields.Subject)) {
|
||||
return false;
|
||||
}
|
||||
const {draft, session} = this.props
|
||||
const { draft, session } = this.props;
|
||||
return (
|
||||
<KeyCommandsRegion
|
||||
tabIndex={-1}
|
||||
ref={(el) => { if (el) { this._els.subjectContainer = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els.subjectContainer = el;
|
||||
}
|
||||
}}
|
||||
onFocusIn={this._onFocusInSubject}
|
||||
onFocusOut={this._onFocusOutSubject}
|
||||
>
|
||||
<InjectedComponent
|
||||
ref={(el) => { if (el) { this._els[Fields.Subject] = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els[Fields.Subject] = el;
|
||||
}
|
||||
}}
|
||||
key="subject-wrap"
|
||||
matching={{role: 'Composer:SubjectTextField'}}
|
||||
matching={{ role: 'Composer:SubjectTextField' }}
|
||||
exposedProps={{
|
||||
draft,
|
||||
session,
|
||||
|
@ -252,11 +267,11 @@ export default class ComposerHeader extends React.Component {
|
|||
onComponentDidChange={this.props.onNewHeaderComponents}
|
||||
/>
|
||||
</KeyCommandsRegion>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
_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.
|
||||
// If they're hidden, shift-tab between fields breaks.
|
||||
|
@ -264,64 +279,80 @@ export default class ComposerHeader extends React.Component {
|
|||
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={(el) => { if (el) { this._els[Fields.To] = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els[Fields.To] = el;
|
||||
}
|
||||
}}
|
||||
key="to"
|
||||
field="to"
|
||||
change={this._onChangeParticipants}
|
||||
className="composer-participant-field to-field"
|
||||
participants={{to, cc, bcc}}
|
||||
participants={{ to, cc, bcc }}
|
||||
draft={this.props.draft}
|
||||
session={this.props.session}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
if (this.state.enabledFields.includes(Fields.Cc)) {
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={(el) => { if (el) { this._els[Fields.Cc] = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els[Fields.Cc] = el;
|
||||
}
|
||||
}}
|
||||
key="cc"
|
||||
field="cc"
|
||||
change={this._onChangeParticipants}
|
||||
onEmptied={() => this.hideField(Fields.Cc)}
|
||||
className="composer-participant-field cc-field"
|
||||
participants={{to, cc, bcc}}
|
||||
participants={{ to, cc, bcc }}
|
||||
draft={this.props.draft}
|
||||
session={this.props.session}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.enabledFields.includes(Fields.Bcc)) {
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={(el) => { if (el) { this._els[Fields.Bcc] = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els[Fields.Bcc] = el;
|
||||
}
|
||||
}}
|
||||
key="bcc"
|
||||
field="bcc"
|
||||
change={this._onChangeParticipants}
|
||||
onEmptied={() => this.hideField(Fields.Bcc)}
|
||||
className="composer-participant-field bcc-field"
|
||||
participants={{to, cc, bcc}}
|
||||
participants={{ to, cc, bcc }}
|
||||
draft={this.props.draft}
|
||||
session={this.props.session}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.enabledFields.includes(Fields.From)) {
|
||||
fields.push(
|
||||
<ScopedFromField
|
||||
key="from"
|
||||
ref={(el) => { if (el) { this._els[Fields.From] = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els[Fields.From] = el;
|
||||
}
|
||||
}}
|
||||
value={from[0]}
|
||||
draft={this.props.draft}
|
||||
session={this.props.session}
|
||||
onChange={this._onChangeParticipants}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
|
@ -335,6 +366,6 @@ export default class ComposerHeader extends React.Component {
|
|||
{this._renderParticipants()}
|
||||
{this._renderSubject()}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import {remote} from 'electron'
|
||||
import { remote } from 'electron';
|
||||
import {
|
||||
React,
|
||||
ReactDOM,
|
||||
PropTypes,
|
||||
Utils,
|
||||
Actions,
|
||||
DraftStore,
|
||||
AttachmentStore,
|
||||
DraftHelpers,
|
||||
} from 'nylas-exports'
|
||||
} from 'nylas-exports';
|
||||
import {
|
||||
DropZone,
|
||||
RetinaImg,
|
||||
|
@ -19,12 +20,12 @@ import {
|
|||
OverlaidComponents,
|
||||
ImageAttachmentItem,
|
||||
InjectedComponentSet,
|
||||
} from 'nylas-component-kit'
|
||||
import ComposerEditor from './composer-editor'
|
||||
import ComposerHeader from './composer-header'
|
||||
import SendActionButton from './send-action-button'
|
||||
import ActionBarPlugins from './action-bar-plugins'
|
||||
import Fields from './fields'
|
||||
} from 'nylas-component-kit';
|
||||
import ComposerEditor from './composer-editor';
|
||||
import ComposerHeader from './composer-header';
|
||||
import SendActionButton from './send-action-button';
|
||||
import ActionBarPlugins from './action-bar-plugins';
|
||||
import Fields from './fields';
|
||||
|
||||
// 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
|
||||
|
@ -33,24 +34,24 @@ export default class ComposerView extends React.Component {
|
|||
static displayName = 'ComposerView';
|
||||
|
||||
static propTypes = {
|
||||
session: React.PropTypes.object.isRequired,
|
||||
draft: React.PropTypes.object.isRequired,
|
||||
session: PropTypes.object.isRequired,
|
||||
draft: PropTypes.object.isRequired,
|
||||
|
||||
// Sometimes when changes in the composer happens it's desirable to
|
||||
// have the parent scroll to a certain location. A parent component can
|
||||
// pass a callback that gets called when this composer wants to be
|
||||
// scrolled to.
|
||||
scrollTo: React.PropTypes.func,
|
||||
className: React.PropTypes.string,
|
||||
}
|
||||
scrollTo: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
super(props);
|
||||
this._els = {};
|
||||
this.state = {
|
||||
showQuotedText: DraftHelpers.isForwardedMessage(props.draft),
|
||||
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(props.draft),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -64,8 +65,12 @@ export default class ComposerView extends React.Component {
|
|||
this._teardownForProps();
|
||||
this._setupForProps(nextProps);
|
||||
}
|
||||
if (DraftHelpers.isForwardedMessage(this.props.draft) !== DraftHelpers.isForwardedMessage(nextProps.draft) ||
|
||||
DraftHelpers.shouldAppendQuotedText(this.props.draft) !== DraftHelpers.shouldAppendQuotedText(nextProps.draft)) {
|
||||
if (
|
||||
DraftHelpers.isForwardedMessage(this.props.draft) !==
|
||||
DraftHelpers.isForwardedMessage(nextProps.draft) ||
|
||||
DraftHelpers.shouldAppendQuotedText(this.props.draft) !==
|
||||
DraftHelpers.shouldAppendQuotedText(nextProps.draft)
|
||||
) {
|
||||
this.setState({
|
||||
showQuotedText: DraftHelpers.isForwardedMessage(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-cc': () => this._els.header.showAndFocusField(Fields.Cc),
|
||||
'composer:focus-to': () => this._els.header.showAndFocusField(Fields.To),
|
||||
"composer:show-and-focus-from": () => {},
|
||||
"core:undo": (event) => {
|
||||
'composer:show-and-focus-from': () => {},
|
||||
'core:undo': event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.props.session.undo();
|
||||
},
|
||||
"core:redo": (event) => {
|
||||
'core:redo': event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.props.session.redo();
|
||||
|
@ -110,7 +115,7 @@ export default class ComposerView extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
_setupForProps({draft, session}) {
|
||||
_setupForProps({ draft, session }) {
|
||||
this.setState({
|
||||
showQuotedText: DraftHelpers.isForwardedMessage(draft),
|
||||
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(draft),
|
||||
|
@ -128,13 +133,13 @@ export default class ComposerView extends React.Component {
|
|||
return this._els[Fields.Body].getPreviousSelection();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
session._composerViewSelectionRestore = (selection) => {
|
||||
session._composerViewSelectionRestore = selection => {
|
||||
this._els[Fields.Body].setSelection(selection);
|
||||
}
|
||||
};
|
||||
|
||||
draft.files.forEach((file) => {
|
||||
draft.files.forEach(file => {
|
||||
if (Utils.shouldDisplayAsImage(file)) {
|
||||
Actions.fetchFile(file);
|
||||
}
|
||||
|
@ -148,15 +153,19 @@ export default class ComposerView extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_setSREl = (el) => {
|
||||
_setSREl = el => {
|
||||
this._els.scrollregion = el;
|
||||
}
|
||||
};
|
||||
_renderContentScrollRegion() {
|
||||
if (NylasEnv.isComposerWindow()) {
|
||||
return (
|
||||
<ScrollRegion
|
||||
className="compose-body-scroll"
|
||||
ref={(el) => { if (el) { this._els.scrollregion = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els.scrollregion = el;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{this._renderContent()}
|
||||
</ScrollRegion>
|
||||
|
@ -167,15 +176,19 @@ export default class ComposerView extends React.Component {
|
|||
|
||||
_onNewHeaderComponents = () => {
|
||||
if (this._els.header) {
|
||||
this.focus()
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_renderContent() {
|
||||
return (
|
||||
<div className="composer-centered">
|
||||
<ComposerHeader
|
||||
ref={(el) => { if (el) { this._els.header = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els.header = el;
|
||||
}
|
||||
}}
|
||||
draft={this.props.draft}
|
||||
session={this.props.session}
|
||||
initiallyFocused={this.props.draft.to.length === 0}
|
||||
|
@ -183,7 +196,11 @@ export default class ComposerView extends React.Component {
|
|||
/>
|
||||
<div
|
||||
className="compose-body"
|
||||
ref={(el) => { if (el) { this._els.composeBody = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els.composeBody = el;
|
||||
}
|
||||
}}
|
||||
onMouseUp={this._onMouseUpComposerBody}
|
||||
onMouseDown={this._onMouseDownComposerBody}
|
||||
>
|
||||
|
@ -198,15 +215,17 @@ export default class ComposerView extends React.Component {
|
|||
const exposedProps = {
|
||||
draft: this.props.draft,
|
||||
session: this.props.session,
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={(el) => { if (el) { this._els.composerBodyWrap = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els.composerBodyWrap = el;
|
||||
}
|
||||
}}
|
||||
className="composer-body-wrap"
|
||||
>
|
||||
<OverlaidComponents exposedProps={exposedProps}>
|
||||
{this._renderEditor()}
|
||||
</OverlaidComponents>
|
||||
<OverlaidComponents exposedProps={exposedProps}>{this._renderEditor()}</OverlaidComponents>
|
||||
{this._renderQuotedTextControl()}
|
||||
{this._renderAttachments()}
|
||||
</div>
|
||||
|
@ -227,9 +246,13 @@ export default class ComposerView extends React.Component {
|
|||
|
||||
return (
|
||||
<InjectedComponent
|
||||
ref={(el) => { if (el) { this._els[Fields.Body] = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els[Fields.Body] = el;
|
||||
}
|
||||
}}
|
||||
className="body-field"
|
||||
matching={{role: "Composer:Editor"}}
|
||||
matching={{ role: 'Composer:Editor' }}
|
||||
fallback={ComposerEditor}
|
||||
requiredMethods={[
|
||||
'focus',
|
||||
|
@ -248,8 +271,8 @@ export default class ComposerView extends React.Component {
|
|||
// component. We provide it our boundingClientRect so it can calculate
|
||||
// this value.
|
||||
_getComposerBoundingRect = () => {
|
||||
return ReactDOM.findDOMNode(this._els.composerWrap).getBoundingClientRect()
|
||||
}
|
||||
return ReactDOM.findDOMNode(this._els.composerWrap).getBoundingClientRect();
|
||||
};
|
||||
|
||||
_renderQuotedTextControl() {
|
||||
if (this.state.showQuotedTextControl) {
|
||||
|
@ -270,36 +293,38 @@ export default class ComposerView extends React.Component {
|
|||
}
|
||||
|
||||
_onExpandQuotedText = () => {
|
||||
this.setState({
|
||||
showQuotedText: true,
|
||||
showQuotedTextControl: false,
|
||||
}, () => {
|
||||
DraftHelpers.appendQuotedTextToDraft(this.props.draft)
|
||||
.then((draftWithQuotedText) => {
|
||||
this.props.session.changes.add({
|
||||
body: `${draftWithQuotedText.body}<div id="n1-quoted-text-marker" />`,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
this.setState(
|
||||
{
|
||||
showQuotedText: true,
|
||||
showQuotedTextControl: false,
|
||||
},
|
||||
() => {
|
||||
DraftHelpers.appendQuotedTextToDraft(this.props.draft).then(draftWithQuotedText => {
|
||||
this.props.session.changes.add({
|
||||
body: `${draftWithQuotedText.body}<div id="n1-quoted-text-marker" />`,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
_onRemoveQuotedText = (event) => {
|
||||
event.stopPropagation()
|
||||
const {session, draft} = this.props
|
||||
_onRemoveQuotedText = event => {
|
||||
event.stopPropagation();
|
||||
const { session, draft } = this.props;
|
||||
session.changes.add({
|
||||
body: `${draft.body}<div id="n1-quoted-text-marker" />`,
|
||||
})
|
||||
});
|
||||
this.setState({
|
||||
showQuotedText: false,
|
||||
showQuotedTextControl: false,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_renderFooterRegions() {
|
||||
return (
|
||||
<div className="composer-footer-region">
|
||||
<InjectedComponentSet
|
||||
matching={{role: "Composer:Footer"}}
|
||||
matching={{ role: 'Composer:Footer' }}
|
||||
exposedProps={{
|
||||
draft: this.props.draft,
|
||||
threadId: this.props.draft.threadId,
|
||||
|
@ -313,11 +338,11 @@ export default class ComposerView extends React.Component {
|
|||
}
|
||||
|
||||
_renderAttachments() {
|
||||
const {files, headerMessageId} = this.props.draft;
|
||||
const { files, headerMessageId } = this.props.draft;
|
||||
|
||||
const nonImageFiles = files
|
||||
.filter(f => !Utils.shouldDisplayAsImage(f))
|
||||
.map((file) =>
|
||||
.map(file => (
|
||||
<AttachmentItem
|
||||
key={file.id}
|
||||
className="file-upload"
|
||||
|
@ -327,11 +352,11 @@ export default class ComposerView extends React.Component {
|
|||
fileIconName={`file-${file.extension}.png`}
|
||||
onRemoveAttachment={() => Actions.removeAttachment(headerMessageId, file)}
|
||||
/>
|
||||
);
|
||||
));
|
||||
const imageFiles = files
|
||||
.filter(f => Utils.shouldDisplayAsImage(f))
|
||||
.filter(f => !f.contentId)
|
||||
.map((file) =>
|
||||
.map(file => (
|
||||
<ImageAttachmentItem
|
||||
key={file.id}
|
||||
className="file-upload"
|
||||
|
@ -340,19 +365,15 @@ export default class ComposerView extends React.Component {
|
|||
displayName={file.filename}
|
||||
onRemoveAttachment={() => Actions.removeAttachment(headerMessageId, file)}
|
||||
/>
|
||||
);
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="attachments-area">
|
||||
{nonImageFiles.concat(imageFiles)}
|
||||
</div>
|
||||
);
|
||||
return <div className="attachments-area">{nonImageFiles.concat(imageFiles)}</div>;
|
||||
}
|
||||
|
||||
_renderActionsWorkspaceRegion() {
|
||||
return (
|
||||
<InjectedComponentSet
|
||||
matching={{role: "Composer:ActionBarWorkspace"}}
|
||||
matching={{ role: 'Composer:ActionBarWorkspace' }}
|
||||
exposedProps={{
|
||||
draft: this.props.draft,
|
||||
threadId: this.props.draft.threadId,
|
||||
|
@ -360,7 +381,7 @@ export default class ComposerView extends React.Component {
|
|||
session: this.props.session,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
_renderActionsRegion() {
|
||||
|
@ -375,7 +396,7 @@ export default class ComposerView extends React.Component {
|
|||
<button
|
||||
tabIndex={-1}
|
||||
className="btn btn-toolbar btn-trash"
|
||||
style={{order: 100}}
|
||||
style={{ order: 100 }}
|
||||
title="Delete draft"
|
||||
onClick={this._onDestroyDraft}
|
||||
>
|
||||
|
@ -385,25 +406,26 @@ export default class ComposerView extends React.Component {
|
|||
<button
|
||||
tabIndex={-1}
|
||||
className="btn btn-toolbar btn-attach"
|
||||
style={{order: 50}}
|
||||
style={{ order: 50 }}
|
||||
title="Attach file"
|
||||
onClick={this._onSelectAttachment}
|
||||
>
|
||||
<RetinaImg name="icon-composer-attachment.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
|
||||
<div style={{order: 0, flex: 1}} />
|
||||
|
||||
<div style={{ order: 0, flex: 1 }} />
|
||||
|
||||
<InjectedComponent
|
||||
ref={(el) => { if (el) { this._els.sendActionButton = el; } }}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els.sendActionButton = el;
|
||||
}
|
||||
}}
|
||||
tabIndex={-1}
|
||||
style={{order: -100}}
|
||||
matching={{role: "Composer:SendActionButton"}}
|
||||
style={{ order: -100 }}
|
||||
matching={{ role: 'Composer:SendActionButton' }}
|
||||
fallback={SendActionButton}
|
||||
requiredMethods={[
|
||||
'primarySend',
|
||||
]}
|
||||
requiredMethods={['primarySend']}
|
||||
exposedProps={{
|
||||
draft: this.props.draft,
|
||||
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
|
||||
// start and end target are both not in the contenteditable. This ensures
|
||||
// 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)) {
|
||||
this._mouseDownTarget = null;
|
||||
} else {
|
||||
this._mouseDownTarget = event.target;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_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)) {
|
||||
// We don't set state directly here because we want the native
|
||||
// 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) {
|
||||
this._els[Fields.Body].focus()
|
||||
this._els[Fields.Body].focus();
|
||||
} else {
|
||||
this._els[Fields.Body].focusAbsoluteEnd();
|
||||
}
|
||||
}
|
||||
this._mouseDownTarget = null;
|
||||
}
|
||||
};
|
||||
|
||||
_onMouseMoveComposeBody = () => {
|
||||
if (this._mouseComposeBody === "down") {
|
||||
this._mouseComposeBody = "move";
|
||||
if (this._mouseComposeBody === 'down') {
|
||||
this._mouseComposeBody = 'move';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_shouldAcceptDrop = (event) => {
|
||||
_shouldAcceptDrop = event => {
|
||||
// Ensure that you can't pick up a file and drop it on the same draft
|
||||
const nonNativeFilePath = this._nonNativeFilePathForDrop(event);
|
||||
|
||||
|
@ -463,11 +485,11 @@ export default class ComposerView extends React.Component {
|
|||
const hasNonNativeFilePath = nonNativeFilePath !== null;
|
||||
|
||||
return hasNativeFile || hasNonNativeFilePath;
|
||||
}
|
||||
};
|
||||
|
||||
_nonNativeFilePathForDrop = (event) => {
|
||||
if (event.dataTransfer.types.includes("text/nylas-file-url")) {
|
||||
const downloadURL = event.dataTransfer.getData("text/nylas-file-url");
|
||||
_nonNativeFilePathForDrop = event => {
|
||||
if (event.dataTransfer.types.includes('text/nylas-file-url')) {
|
||||
const downloadURL = event.dataTransfer.getData('text/nylas-file-url');
|
||||
const downloadFilePath = downloadURL.split('file://')[1];
|
||||
if (downloadFilePath) {
|
||||
return downloadFilePath;
|
||||
|
@ -475,16 +497,16 @@ export default class ComposerView extends React.Component {
|
|||
}
|
||||
|
||||
// Accept drops of images from within the app
|
||||
if (event.dataTransfer.types.includes("text/uri-list")) {
|
||||
const uri = event.dataTransfer.getData('text/uri-list')
|
||||
if (event.dataTransfer.types.includes('text/uri-list')) {
|
||||
const uri = event.dataTransfer.getData('text/uri-list');
|
||||
if (uri.indexOf('file://') === 0) {
|
||||
return decodeURI(uri.split('file://')[1]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
_onDrop = (event) => {
|
||||
_onDrop = event => {
|
||||
// Accept drops of real files from other applications
|
||||
for (const file of Array.from(event.dataTransfer.files)) {
|
||||
this._onFileReceived(file.path);
|
||||
|
@ -495,23 +517,25 @@ export default class ComposerView extends React.Component {
|
|||
if (uri) {
|
||||
this._onFileReceived(uri);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onFileReceived = (filePath) => {
|
||||
_onFileReceived = filePath => {
|
||||
// called from onDrop and onFilePaste - assume images should be inline
|
||||
Actions.addAttachment({
|
||||
filePath: filePath,
|
||||
headerMessageId: this.props.draft.headerMessageId,
|
||||
onCreated: (file) => {
|
||||
onCreated: 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);
|
||||
if (!match) { return; }
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
match.contentId = Utils.generateTempId();
|
||||
session.changes.add({
|
||||
files: [].concat(draft.files),
|
||||
})
|
||||
});
|
||||
Actions.insertAttachmentIntoDraft({
|
||||
headerMessageId: draft.headerMessageId,
|
||||
fileId: match.id,
|
||||
|
@ -519,12 +543,12 @@ export default class ComposerView extends React.Component {
|
|||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_onBodyChanged = (event) => {
|
||||
this.props.session.changes.add({body: event.target.value});
|
||||
_onBodyChanged = event => {
|
||||
this.props.session.changes.add({ body: event.target.value });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
_isValidDraft = (options = {}) => {
|
||||
// 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 {session} = this.props
|
||||
const {errors, warnings} = session.validateDraftForSending()
|
||||
const { session } = this.props;
|
||||
const { errors, warnings } = session.validateDraftForSending();
|
||||
|
||||
if (errors.length > 0) {
|
||||
dialog.showMessageBox(remote.getCurrentWindow(), {
|
||||
|
@ -549,33 +573,34 @@ export default class ComposerView extends React.Component {
|
|||
return false;
|
||||
}
|
||||
|
||||
if ((warnings.length > 0) && (!options.force)) {
|
||||
if (warnings.length > 0 && !options.force) {
|
||||
const response = dialog.showMessageBox(remote.getCurrentWindow(), {
|
||||
type: 'warning',
|
||||
buttons: ['Send Anyway', 'Cancel'],
|
||||
message: 'Are you sure?',
|
||||
detail: `Send ${warnings.join(' and ')}?`,
|
||||
});
|
||||
if (response === 0) { // response is button array index
|
||||
return this._isValidDraft({force: true});
|
||||
if (response === 0) {
|
||||
// response is button array index
|
||||
return this._isValidDraft({ force: true });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
_onPrimarySend = () => {
|
||||
this._els.sendActionButton.primarySend();
|
||||
}
|
||||
};
|
||||
|
||||
_onDestroyDraft = () => {
|
||||
const {draft} = this.props;
|
||||
const { draft } = this.props;
|
||||
Actions.destroyDraft(draft);
|
||||
}
|
||||
};
|
||||
|
||||
_onSelectAttachment = () => {
|
||||
Actions.selectAttachment({headerMessageId: this.props.draft.headerMessageId});
|
||||
}
|
||||
Actions.selectAttachment({ headerMessageId: this.props.draft.headerMessageId });
|
||||
};
|
||||
|
||||
render() {
|
||||
const dropCoverDisplay = this.state.isDropping ? 'block' : 'none';
|
||||
|
@ -584,18 +609,22 @@ export default class ComposerView extends React.Component {
|
|||
<div className={this.props.className}>
|
||||
<KeyCommandsRegion
|
||||
localHandlers={this._keymapHandlers()}
|
||||
className={"message-item-white-wrap composer-outer-wrap"}
|
||||
ref={(el) => { if (el) { this._els.composerWrap = el; } }}
|
||||
className={'message-item-white-wrap composer-outer-wrap'}
|
||||
ref={el => {
|
||||
if (el) {
|
||||
this._els.composerWrap = el;
|
||||
}
|
||||
}}
|
||||
tabIndex="-1"
|
||||
>
|
||||
<TabGroupRegion className="composer-inner-wrap">
|
||||
<DropZone
|
||||
className="composer-inner-wrap"
|
||||
shouldAcceptDrop={this._shouldAcceptDrop}
|
||||
onDragStateChange={({isDropping}) => this.setState({isDropping})}
|
||||
onDragStateChange={({ isDropping }) => this.setState({ isDropping })}
|
||||
onDrop={this._onDrop}
|
||||
>
|
||||
<div className="composer-drop-cover" style={{display: dropCoverDisplay}}>
|
||||
<div className="composer-drop-cover" style={{ display: dropCoverDisplay }}>
|
||||
<div className="centered">
|
||||
<RetinaImg
|
||||
name="composer-drop-to-attach.png"
|
||||
|
@ -605,9 +634,7 @@ export default class ComposerView extends React.Component {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="composer-content-wrap">
|
||||
{this._renderContentScrollRegion()}
|
||||
</div>
|
||||
<div className="composer-content-wrap">{this._renderContentScrollRegion()}</div>
|
||||
|
||||
<div className="composer-action-bar-workspace-wrap">
|
||||
{this._renderActionsWorkspaceRegion()}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
const Fields = {
|
||||
To: "textFieldTo",
|
||||
Cc: "textFieldCc",
|
||||
Bcc: "textFieldBcc",
|
||||
From: "fromField",
|
||||
Subject: "textFieldSubject",
|
||||
Body: "contentBody",
|
||||
To: 'textFieldTo',
|
||||
Cc: 'textFieldCc',
|
||||
Bcc: 'textFieldBcc',
|
||||
From: 'fromField',
|
||||
Subject: 'textFieldSubject',
|
||||
Body: 'contentBody',
|
||||
};
|
||||
|
||||
Fields.ParticipantFields = [Fields.To, Fields.Cc, Fields.Bcc];
|
||||
|
@ -18,4 +18,4 @@ Fields.Order = {
|
|||
contentBody: 6,
|
||||
};
|
||||
|
||||
export default Fields
|
||||
export default Fields;
|
||||
|
|
|
@ -1,34 +1,35 @@
|
|||
import {
|
||||
Actions,
|
||||
ComposerExtension,
|
||||
} from 'nylas-exports'
|
||||
import { Actions, ComposerExtension } from 'nylas-exports';
|
||||
|
||||
export default class InlineImageComposerExtension extends ComposerExtension {
|
||||
|
||||
static editingActions() {
|
||||
return [{
|
||||
action: Actions.insertAttachmentIntoDraft,
|
||||
callback: InlineImageComposerExtension._onInsertAttachmentIntoDraft,
|
||||
}, {
|
||||
action: Actions.removeAttachment,
|
||||
callback: InlineImageComposerExtension._onRemovedAttachment,
|
||||
}]
|
||||
return [
|
||||
{
|
||||
action: Actions.insertAttachmentIntoDraft,
|
||||
callback: InlineImageComposerExtension._onInsertAttachmentIntoDraft,
|
||||
},
|
||||
{
|
||||
action: Actions.removeAttachment,
|
||||
callback: InlineImageComposerExtension._onRemovedAttachment,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
static _onRemovedAttachment({editor, actionArg}) {
|
||||
static _onRemovedAttachment({ editor, actionArg }) {
|
||||
const file = actionArg;
|
||||
const el = editor.rootNode.querySelector(`.inline-container-${file.id}`)
|
||||
const el = editor.rootNode.querySelector(`.inline-container-${file.id}`);
|
||||
if (el) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
static _onInsertAttachmentIntoDraft({editor, actionArg}) {
|
||||
if (editor.headerMessageId === actionArg.headerMessageId) { return }
|
||||
static _onInsertAttachmentIntoDraft({ editor, actionArg }) {
|
||||
if (editor.headerMessageId === actionArg.headerMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.insertCustomComponent("InlineImageUploadContainer", {
|
||||
editor.insertCustomComponent('InlineImageUploadContainer', {
|
||||
className: `inline-container-${actionArg.fileId}`,
|
||||
fileId: actionArg.fileId,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React, {Component} from 'react';
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {Actions, AttachmentStore} from 'nylas-exports'
|
||||
import {ImageAttachmentItem} from 'nylas-component-kit'
|
||||
import { Actions, AttachmentStore } from 'nylas-exports';
|
||||
import { ImageAttachmentItem } from 'nylas-component-kit';
|
||||
|
||||
export default class InlineImageUploadContainer extends Component {
|
||||
static displayName = 'InlineImageUploadContainer';
|
||||
|
@ -16,11 +16,13 @@ export default class InlineImageUploadContainer extends Component {
|
|||
fileId: PropTypes.string.isRequired,
|
||||
session: PropTypes.object,
|
||||
isPreview: PropTypes.bool,
|
||||
}
|
||||
};
|
||||
|
||||
_onGoEdit = () => {
|
||||
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;
|
||||
}
|
||||
// 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`;
|
||||
editorEl.appendChild(editorCanvas);
|
||||
|
||||
const editorCtx = editorCanvas.getContext("2d");
|
||||
editorCtx.drawImage(el.querySelector('.file-preview img'), 0, 0, editorCanvas.width, editorCanvas.height);
|
||||
editorCtx.strokeStyle = "#df4b26";
|
||||
editorCtx.lineJoin = "round";
|
||||
const editorCtx = editorCanvas.getContext('2d');
|
||||
editorCtx.drawImage(
|
||||
el.querySelector('.file-preview img'),
|
||||
0,
|
||||
0,
|
||||
editorCanvas.width,
|
||||
editorCanvas.height
|
||||
);
|
||||
editorCtx.strokeStyle = '#df4b26';
|
||||
editorCtx.lineJoin = 'round';
|
||||
editorCtx.lineWidth = 3 * window.devicePixelRatio;
|
||||
|
||||
let penDown = false;
|
||||
let penXY = null;
|
||||
editorCanvas.addEventListener('mousedown', (event) => {
|
||||
editorCanvas.addEventListener('mousedown', event => {
|
||||
penDown = true;
|
||||
penXY = {
|
||||
x: event.offsetX,
|
||||
y: event.offsetY,
|
||||
}
|
||||
};
|
||||
});
|
||||
editorCanvas.addEventListener('mousemove', (event) => {
|
||||
editorCanvas.addEventListener('mousemove', event => {
|
||||
if (penDown) {
|
||||
const nextPenXY = {
|
||||
x: event.offsetX,
|
||||
y: event.offsetY,
|
||||
}
|
||||
};
|
||||
editorCtx.beginPath();
|
||||
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.stroke();
|
||||
penXY = nextPenXY;
|
||||
|
@ -87,22 +98,20 @@ export default class InlineImageUploadContainer extends Component {
|
|||
backgroundEl.style.bottom = '0px';
|
||||
backgroundEl.style.zIndex = 1999;
|
||||
backgroundEl.addEventListener('click', () => {
|
||||
editorCanvas.toBlob((blob) => {
|
||||
editorCanvas.toBlob(blob => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('loadend', () => {
|
||||
const {draft, session, fileId} = this.props;
|
||||
const { draft, session, fileId } = this.props;
|
||||
const buffer = new Buffer(new Uint8Array(reader.result));
|
||||
const file = draft.files.find(u =>
|
||||
u.id === fileId
|
||||
);
|
||||
const file = draft.files.find(u => u.id === fileId);
|
||||
|
||||
const filepath = AttachmentStore.pathForFile(file);
|
||||
const nextFileName = `edited-${Date.now()}.png`;
|
||||
const nextFilePath = path.join(path.dirname(filepath), nextFileName);
|
||||
|
||||
fs.writeFile(nextFilePath, buffer, (err) => {
|
||||
fs.writeFile(nextFilePath, buffer, err => {
|
||||
if (err) {
|
||||
NylasEnv.showErrorDialog(err.toString())
|
||||
NylasEnv.showErrorDialog(err.toString());
|
||||
return;
|
||||
}
|
||||
const img = el.querySelector('.file-preview img');
|
||||
|
@ -113,12 +122,12 @@ export default class InlineImageUploadContainer extends Component {
|
|||
fs.unlink(filepath, () => {});
|
||||
|
||||
const nextFiles = [].concat(draft.files);
|
||||
nextFiles.forEach((f) => {
|
||||
nextFiles.forEach(f => {
|
||||
if (f.id === file.id) {
|
||||
f.filename = nextFileName;
|
||||
}
|
||||
});
|
||||
session.changes.add({files: nextFiles});
|
||||
session.changes.add({ files: nextFiles });
|
||||
});
|
||||
});
|
||||
reader.readAsArrayBuffer(blob);
|
||||
|
@ -128,21 +137,17 @@ export default class InlineImageUploadContainer extends Component {
|
|||
});
|
||||
document.body.appendChild(backgroundEl);
|
||||
document.body.appendChild(editorEl);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {draft, fileId, isPreview} = this.props;
|
||||
const { draft, fileId, isPreview } = this.props;
|
||||
const file = draft.files.find(u => fileId === u.id);
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<span />
|
||||
);
|
||||
return <span />;
|
||||
}
|
||||
if (isPreview) {
|
||||
return (
|
||||
<img src={`cid:${file.id}`} alt={file.name} />
|
||||
);
|
||||
return <img src={`cid:${file.id}`} alt={file.name} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -159,6 +164,6 @@ export default class InlineImageUploadContainer extends Component {
|
|||
onRemoveAttachment={() => Actions.removeAttachment(draft.headerMessageId, file)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,12 +10,11 @@ import {
|
|||
InflatesDraftClientId,
|
||||
CustomContenteditableComponents,
|
||||
} from 'nylas-exports';
|
||||
import {OverlaidComposerExtension} from 'nylas-component-kit'
|
||||
import { OverlaidComposerExtension } from 'nylas-component-kit';
|
||||
import ComposeButton from './compose-button';
|
||||
import ComposerView from './composer-view';
|
||||
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);
|
||||
|
||||
|
@ -28,21 +27,23 @@ class ComposerWithWindowProps extends React.Component {
|
|||
|
||||
// We'll now always have windowProps by the time we construct this.
|
||||
const windowProps = NylasEnv.getWindowProps();
|
||||
const {draftJSON, headerMessageId} = windowProps;
|
||||
const { draftJSON, headerMessageId } = windowProps;
|
||||
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);
|
||||
DraftStore._createSession(headerMessageId, draft);
|
||||
this.state = windowProps
|
||||
this.state = windowProps;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._usub) { this._usub() }
|
||||
if (this._usub) {
|
||||
this._usub();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this._composerComponent.focus()
|
||||
this._composerComponent.focus();
|
||||
}
|
||||
|
||||
_onDraftReady = () => {
|
||||
|
@ -53,12 +54,14 @@ class ComposerWithWindowProps extends React.Component {
|
|||
this._showInitialErrorDialog(this.state.errorMessage, this.state.errorDetail);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ComposerViewForDraftClientId
|
||||
ref={(cm) => { this._composerComponent = cm; }}
|
||||
ref={cm => {
|
||||
this._composerComponent = cm;
|
||||
}}
|
||||
onDraftReady={this._onDraftReady}
|
||||
headerMessageId={this.state.headerMessageId}
|
||||
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
|
||||
// like it hasn't been restored or has been lost.
|
||||
_.delay(() => {
|
||||
NylasEnv.showErrorDialog({title: 'Error', message: msg}, {detail: detail})
|
||||
NylasEnv.showErrorDialog({ title: 'Error', message: msg }, { detail: detail });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
@ -95,9 +98,12 @@ export function activate() {
|
|||
});
|
||||
}
|
||||
|
||||
ExtensionRegistry.Composer.register(OverlaidComposerExtension, {priority: 1})
|
||||
ExtensionRegistry.Composer.register(OverlaidComposerExtension, { priority: 1 });
|
||||
ExtensionRegistry.Composer.register(InlineImageComposerExtension);
|
||||
CustomContenteditableComponents.register("InlineImageUploadContainer", InlineImageUploadContainer);
|
||||
CustomContenteditableComponents.register(
|
||||
'InlineImageUploadContainer',
|
||||
InlineImageUploadContainer
|
||||
);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
|
@ -108,9 +114,9 @@ export function deactivate() {
|
|||
ComponentRegistry.unregister(ComposerWithWindowProps);
|
||||
}
|
||||
|
||||
ExtensionRegistry.Composer.unregister(OverlaidComposerExtension)
|
||||
ExtensionRegistry.Composer.unregister(OverlaidComposerExtension);
|
||||
ExtensionRegistry.Composer.unregister(InlineImageComposerExtension);
|
||||
CustomContenteditableComponents.unregister("InlineImageUploadContainer");
|
||||
CustomContenteditableComponents.unregister('InlineImageUploadContainer');
|
||||
}
|
||||
|
||||
export function serialize() {
|
||||
|
|
|
@ -1,18 +1,16 @@
|
|||
import React from 'react'
|
||||
import {Actions, SendActionsStore} from 'nylas-exports'
|
||||
import {Menu, RetinaImg, ButtonDropdown, ListensToFluxStore} from 'nylas-component-kit'
|
||||
|
||||
import { React, PropTypes, Actions, SendActionsStore } from 'nylas-exports';
|
||||
import { Menu, RetinaImg, ButtonDropdown, ListensToFluxStore } from 'nylas-component-kit';
|
||||
|
||||
class SendActionButton extends React.Component {
|
||||
static displayName = "SendActionButton";
|
||||
static displayName = 'SendActionButton';
|
||||
|
||||
static containerRequired = false
|
||||
static containerRequired = false;
|
||||
|
||||
static propTypes = {
|
||||
draft: React.PropTypes.object,
|
||||
isValidDraft: React.PropTypes.func,
|
||||
sendActions: React.PropTypes.array,
|
||||
orderedSendActions: React.PropTypes.object,
|
||||
draft: PropTypes.object,
|
||||
isValidDraft: PropTypes.func,
|
||||
sendActions: PropTypes.array,
|
||||
orderedSendActions: PropTypes.object,
|
||||
};
|
||||
|
||||
primarySend() {
|
||||
|
@ -20,20 +18,20 @@ class SendActionButton extends React.Component {
|
|||
}
|
||||
|
||||
_onPrimaryClick = () => {
|
||||
const {orderedSendActions} = this.props
|
||||
const {preferred} = orderedSendActions
|
||||
const { orderedSendActions } = this.props;
|
||||
const { preferred } = orderedSendActions;
|
||||
this._onSendWithAction(preferred);
|
||||
}
|
||||
};
|
||||
|
||||
_onSendWithAction = (sendAction) => {
|
||||
const {isValidDraft, draft} = this.props
|
||||
_onSendWithAction = sendAction => {
|
||||
const { isValidDraft, draft } = this.props;
|
||||
if (isValidDraft()) {
|
||||
Actions.sendDraft(draft.headerMessageId, sendAction.configKey)
|
||||
Actions.sendDraft(draft.headerMessageId, sendAction.configKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_renderSendActionItem = ({iconUrl}) => {
|
||||
let plusHTML = "";
|
||||
_renderSendActionItem = ({ iconUrl }) => {
|
||||
let plusHTML = '';
|
||||
let additionalImg = false;
|
||||
|
||||
if (iconUrl) {
|
||||
|
@ -44,18 +42,19 @@ class SendActionButton extends React.Component {
|
|||
return (
|
||||
<span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_renderSingleButton() {
|
||||
const {sendActions} = this.props
|
||||
const { sendActions } = this.props;
|
||||
return (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className={"btn btn-toolbar btn-normal btn-emphasis btn-text btn-send"}
|
||||
style={{order: -100}}
|
||||
className={'btn btn-toolbar btn-normal btn-emphasis btn-text btn-send'}
|
||||
style={{ order: -100 }}
|
||||
onClick={this._onPrimaryClick}
|
||||
>
|
||||
{this._renderSendActionItem(sendActions[0])}
|
||||
|
@ -64,22 +63,22 @@ class SendActionButton extends React.Component {
|
|||
}
|
||||
|
||||
_renderButtonDropdown() {
|
||||
const {orderedSendActions} = this.props
|
||||
const {preferred, rest} = orderedSendActions
|
||||
const { orderedSendActions } = this.props;
|
||||
const { preferred, rest } = orderedSendActions;
|
||||
|
||||
const menu = (
|
||||
<Menu
|
||||
items={rest}
|
||||
itemKey={(actionConfig) => actionConfig.configKey}
|
||||
itemKey={actionConfig => actionConfig.configKey}
|
||||
itemContent={this._renderSendActionItem}
|
||||
onSelect={this._onSendWithAction}
|
||||
/>
|
||||
);
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonDropdown
|
||||
className={"btn-send btn-emphasis btn-text"}
|
||||
style={{order: -100}}
|
||||
className={'btn-send btn-emphasis btn-text'}
|
||||
style={{ order: -100 }}
|
||||
primaryItem={this._renderSendActionItem(preferred)}
|
||||
primaryTitle={preferred.title}
|
||||
primaryClick={this._onPrimaryClick}
|
||||
|
@ -90,7 +89,7 @@ class SendActionButton extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const {sendActions} = this.props
|
||||
const { sendActions } = this.props;
|
||||
if (sendActions.length === 1) {
|
||||
return this._renderSingleButton();
|
||||
}
|
||||
|
@ -101,13 +100,13 @@ class SendActionButton extends React.Component {
|
|||
const EnhancedSendActionButton = ListensToFluxStore(SendActionButton, {
|
||||
stores: [SendActionsStore],
|
||||
getStateFromStores(props) {
|
||||
const {draft} = props
|
||||
const { draft } = props;
|
||||
return {
|
||||
sendActions: SendActionsStore.availableSendActionsForDraft(draft),
|
||||
orderedSendActions: SendActionsStore.orderedSendActionsForDraft(draft),
|
||||
}
|
||||
};
|
||||
},
|
||||
})
|
||||
});
|
||||
// TODO this is a hack so that the send button can still expose
|
||||
// the `primarySend` method required by the ComposerView. Ideally, this
|
||||
// decorator mechanism should expose whatever instance methods are exposed
|
||||
|
@ -118,11 +117,11 @@ const EnhancedSendActionButton = ListensToFluxStore(SendActionButton, {
|
|||
Object.assign(EnhancedSendActionButton.prototype, {
|
||||
primarySend() {
|
||||
if (this._composedComponent) {
|
||||
this._composedComponent.primarySend()
|
||||
this._composedComponent.primarySend();
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
EnhancedSendActionButton.UndecoratedSendActionButton = SendActionButton
|
||||
EnhancedSendActionButton.UndecoratedSendActionButton = SendActionButton;
|
||||
|
||||
export default EnhancedSendActionButton
|
||||
export default EnhancedSendActionButton;
|
||||
|
|
|
@ -1,31 +1,33 @@
|
|||
import React, {Component} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class SubjectTextField extends Component {
|
||||
static displayName = 'SubjectTextField'
|
||||
static displayName = 'SubjectTextField';
|
||||
|
||||
static containerRequired = false
|
||||
static containerRequired = false;
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
onSubjectChange: PropTypes.func,
|
||||
}
|
||||
};
|
||||
|
||||
onInputChange = ({target: {value}}) => {
|
||||
this.props.onSubjectChange(value)
|
||||
}
|
||||
onInputChange = ({ target: { value } }) => {
|
||||
this.props.onSubjectChange(value);
|
||||
};
|
||||
|
||||
focus() {
|
||||
this._el.focus()
|
||||
this._el.focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {value} = this.props
|
||||
const { value } = this.props;
|
||||
|
||||
return (
|
||||
<div className="composer-subject subject-field">
|
||||
<input
|
||||
ref={el => { this._el = el; }}
|
||||
ref={el => {
|
||||
this._el = el;
|
||||
}}
|
||||
type="text"
|
||||
name="subject"
|
||||
placeholder="Subject"
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
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 Fields from '../lib/fields';
|
||||
|
||||
|
@ -10,24 +10,20 @@ const DRAFT_HEADER_MSG_ID = 'DRAFT_HEADER_MSG_ID';
|
|||
|
||||
describe('ComposerHeader', function composerHeader() {
|
||||
beforeEach(() => {
|
||||
this.createWithDraft = (draft) => {
|
||||
this.createWithDraft = draft => {
|
||||
const session = {
|
||||
changes: {
|
||||
add: jasmine.createSpy('changes.add'),
|
||||
},
|
||||
};
|
||||
this.component = ReactTestUtils.renderIntoDocument(
|
||||
<ComposerHeader
|
||||
draft={draft}
|
||||
initiallyFocused={false}
|
||||
session={session}
|
||||
/>
|
||||
)
|
||||
<ComposerHeader draft={draft} initiallyFocused={false} session={session} />
|
||||
);
|
||||
};
|
||||
advanceClock()
|
||||
advanceClock();
|
||||
});
|
||||
|
||||
describe("showAndFocusField", () => {
|
||||
describe('showAndFocusField', () => {
|
||||
beforeEach(() => {
|
||||
const draft = new Message({
|
||||
draft: true,
|
||||
|
@ -37,13 +33,22 @@ describe('ComposerHeader', function composerHeader() {
|
|||
this.createWithDraft(draft);
|
||||
});
|
||||
|
||||
it("should ensure the field is in enabledFields", () => {
|
||||
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField', 'textFieldSubject'])
|
||||
it('should ensure the field is in enabledFields', () => {
|
||||
expect(this.component.state.enabledFields).toEqual([
|
||||
'textFieldTo',
|
||||
'fromField',
|
||||
'textFieldSubject',
|
||||
]);
|
||||
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);
|
||||
this.component.showAndFocusField(Fields.Subject);
|
||||
expect(this.component.state.participantsFocused).toEqual(false);
|
||||
|
@ -51,7 +56,7 @@ describe('ComposerHeader', function composerHeader() {
|
|||
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);
|
||||
expect($el.querySelector('.bcc-field')).toBe(null);
|
||||
this.component.showAndFocusField(Fields.Bcc);
|
||||
|
@ -60,13 +65,17 @@ describe('ComposerHeader', function composerHeader() {
|
|||
});
|
||||
});
|
||||
|
||||
describe("hideField", () => {
|
||||
describe('hideField', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("should remove the field from enabledFields", () => {
|
||||
it('should remove the field from enabledFields', () => {
|
||||
const $el = ReactDOM.findDOMNode(this.component);
|
||||
|
||||
this.component.showAndFocusField(Fields.Bcc);
|
||||
|
@ -78,42 +87,80 @@ describe('ComposerHeader', function composerHeader() {
|
|||
});
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("should enable any fields that are populated", () => {
|
||||
describe('initial state', () => {
|
||||
it('should enable any fields that are populated', () => {
|
||||
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: true,
|
||||
cc: [new Contact({id: 'a', email: 'a'})],
|
||||
bcc: [new Contact({id: 'b', email: 'b'})],
|
||||
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: true,
|
||||
cc: [new Contact({ id: 'a', email: 'a' })],
|
||||
bcc: [new Contact({ id: 'b', email: 'b' })],
|
||||
headerMessageId: DRAFT_HEADER_MSG_ID,
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
});
|
||||
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", () => {
|
||||
it("should be enabled if it is empty", () => {
|
||||
const draft = new Message({draft: true, subject: '', accountId: TEST_ACCOUNT_ID, headerMessageId: DRAFT_HEADER_MSG_ID});
|
||||
describe('subject', () => {
|
||||
it('should be enabled if it is empty', () => {
|
||||
const draft = new Message({
|
||||
draft: true,
|
||||
subject: '',
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
headerMessageId: DRAFT_HEADER_MSG_ID,
|
||||
});
|
||||
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", () => {
|
||||
const draft = new Message({draft: true, subject: 'Fwd: 1234', accountId: TEST_ACCOUNT_ID, headerMessageId: DRAFT_HEADER_MSG_ID});
|
||||
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,
|
||||
});
|
||||
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", () => {
|
||||
const draft = new Message({draft: true, subject: 'Re: 1234', replyToHeaderMessageId: '123', accountId: TEST_ACCOUNT_ID, headerMessageId: DRAFT_HEADER_MSG_ID});
|
||||
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,
|
||||
});
|
||||
this.createWithDraft(draft);
|
||||
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField'])
|
||||
expect(this.component.state.enabledFields).toEqual(['textFieldTo', 'fromField']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,45 +1,45 @@
|
|||
import React from 'react';
|
||||
import {mount} from 'enzyme';
|
||||
import {ButtonDropdown, RetinaImg} from 'nylas-component-kit';
|
||||
import {Actions, Message, SendActionsStore} from 'nylas-exports';
|
||||
import { mount } from 'enzyme';
|
||||
import { ButtonDropdown, RetinaImg } from 'nylas-component-kit';
|
||||
import { Actions, Message, SendActionsStore } from 'nylas-exports';
|
||||
import SendActionButton from '../lib/send-action-button';
|
||||
|
||||
const {UndecoratedSendActionButton} = SendActionButton;
|
||||
const { UndecoratedSendActionButton } = SendActionButton;
|
||||
|
||||
const {DefaultSendAction} = SendActionsStore
|
||||
const { DefaultSendAction } = SendActionsStore;
|
||||
|
||||
const GoodSendAction = {
|
||||
title: "Good Send Action",
|
||||
title: 'Good Send Action',
|
||||
configKey: 'good-send-action',
|
||||
isAvailableForDraft: () => true,
|
||||
performSendAction: () => {},
|
||||
}
|
||||
};
|
||||
|
||||
const SecondSendAction = {
|
||||
title: "Second Send Action",
|
||||
title: 'Second Send Action',
|
||||
configKey: 'second-send-action',
|
||||
isAvailableForDraft: () => true,
|
||||
performSendAction: () => {},
|
||||
}
|
||||
};
|
||||
|
||||
const NoIconUrl = {
|
||||
title: "No Icon",
|
||||
title: 'No Icon',
|
||||
configKey: 'no-icon',
|
||||
iconUrl: null,
|
||||
isAvailableForDraft: () => true,
|
||||
performSendAction() {},
|
||||
}
|
||||
};
|
||||
|
||||
describe('SendActionButton', function describeBlock() {
|
||||
beforeEach(() => {
|
||||
spyOn(Actions, 'sendDraft')
|
||||
this.isValidDraft = jasmine.createSpy('isValidDraft')
|
||||
this.id = "client-23"
|
||||
this.draft = new Message({id: this.id, draft: true, headerMessageId: 'bla'})
|
||||
})
|
||||
spyOn(Actions, 'sendDraft');
|
||||
this.isValidDraft = jasmine.createSpy('isValidDraft');
|
||||
this.id = 'client-23';
|
||||
this.draft = new Message({ id: this.id, draft: true, headerMessageId: 'bla' });
|
||||
});
|
||||
|
||||
const render = (draft, {isValid = true, sendActions = [], ordered = {}} = {}) => {
|
||||
this.isValidDraft.andReturn(isValid)
|
||||
const render = (draft, { isValid = true, sendActions = [], ordered = {} } = {}) => {
|
||||
this.isValidDraft.andReturn(isValid);
|
||||
return mount(
|
||||
<UndecoratedSendActionButton
|
||||
draft={draft}
|
||||
|
@ -50,22 +50,22 @@ describe('SendActionButton', function describeBlock() {
|
|||
rest: ordered.rest || [],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
it("renders without error", () => {
|
||||
it('renders without error', () => {
|
||||
const sendActionButton = render(this.draft);
|
||||
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 button = sendActionButton.find('button').first();
|
||||
expect(button.text()).toEqual('Send');
|
||||
});
|
||||
|
||||
it("is a single button when there are no send actions", () => {
|
||||
const sendActionButton = render(this.draft, {sendActions: []});
|
||||
it('is a single button when there are no send actions', () => {
|
||||
const sendActionButton = render(this.draft, { sendActions: [] });
|
||||
const dropdowns = sendActionButton.find(ButtonDropdown);
|
||||
const buttons = sendActionButton.find('button');
|
||||
expect(buttons.length).toBe(1);
|
||||
|
@ -84,51 +84,51 @@ describe('SendActionButton', function describeBlock() {
|
|||
expect(dropdowns.first().prop('primaryTitle')).toBe('Send');
|
||||
});
|
||||
|
||||
it("has the correct primary item", () => {
|
||||
it('has the correct primary item', () => {
|
||||
const sendActionButton = render(this.draft, {
|
||||
sendActions: [GoodSendAction, SecondSendAction],
|
||||
ordered: {preferred: SecondSendAction, rest: [DefaultSendAction, GoodSendAction]},
|
||||
ordered: { preferred: SecondSendAction, rest: [DefaultSendAction, GoodSendAction] },
|
||||
});
|
||||
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", () => {
|
||||
const sendActionButton = render(this.draft, {
|
||||
sendActions: [NoIconUrl],
|
||||
ordered: {preferred: NoIconUrl, rest: [DefaultSendAction]},
|
||||
ordered: { preferred: NoIconUrl, rest: [DefaultSendAction] },
|
||||
});
|
||||
const dropdowns = sendActionButton.find(ButtonDropdown);
|
||||
const buttons = sendActionButton.find('button');
|
||||
const icons = sendActionButton.find(RetinaImg)
|
||||
const icons = sendActionButton.find(RetinaImg);
|
||||
expect(buttons.length).toBe(0);
|
||||
expect(dropdowns.length).toBe(1);
|
||||
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 button = sendActionButton.find('button').first();
|
||||
button.simulate('click')
|
||||
button.simulate('click');
|
||||
expect(this.isValidDraft).toHaveBeenCalled();
|
||||
expect(Actions.sendDraft).toHaveBeenCalledWith(this.draft.headerMessageId, 'send');
|
||||
});
|
||||
|
||||
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();
|
||||
button.simulate('click')
|
||||
button.simulate('click');
|
||||
expect(this.isValidDraft).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, {
|
||||
sendActions: [GoodSendAction],
|
||||
ordered: {preferred: GoodSendAction, rest: [DefaultSendAction]},
|
||||
ordered: { preferred: GoodSendAction, rest: [DefaultSendAction] },
|
||||
});
|
||||
const button = sendActionButton.find('.primary-item').first();
|
||||
button.simulate('click')
|
||||
button.simulate('click');
|
||||
expect(this.isValidDraft).toHaveBeenCalled();
|
||||
expect(Actions.sendDraft).toHaveBeenCalledWith(this.draft.headerMessageId, 'good-send-action');
|
||||
});
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
module.exports = {
|
||||
activate() {
|
||||
//
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import {SoundRegistry} from 'nylas-exports';
|
||||
import { SoundRegistry } from 'nylas-exports';
|
||||
|
||||
export function activate() {
|
||||
// FIXME: Use the mailspring:// protocol handlers once we upgrade Electron past
|
||||
// v30.0
|
||||
// See: https://github.com/atom/electron/issues/1123
|
||||
SoundRegistry.register({
|
||||
"send": ["internal_packages", "custom-sounds", "CUSTOM_UI_Send_v1.ogg"],
|
||||
"confirm": ["internal_packages", "custom-sounds", "CUSTOM_UI_Confirm_v1.ogg"],
|
||||
"hit-send": ["internal_packages", "custom-sounds", "CUSTOM_UI_HitSend_v1.ogg"],
|
||||
"new-mail": ["internal_packages", "custom-sounds", "CUSTOM_UI_NewMail_v1.ogg"],
|
||||
send: ['internal_packages', 'custom-sounds', 'CUSTOM_UI_Send_v1.ogg'],
|
||||
confirm: ['internal_packages', 'custom-sounds', 'CUSTOM_UI_Confirm_v1.ogg'],
|
||||
'hit-send': ['internal_packages', 'custom-sounds', 'CUSTOM_UI_HitSend_v1.ogg'],
|
||||
'new-mail': ['internal_packages', 'custom-sounds', 'CUSTOM_UI_NewMail_v1.ogg'],
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
SoundRegistry.unregister(["send", "confirm", "hit-send", "new-mail"]);
|
||||
SoundRegistry.unregister(['send', 'confirm', 'hit-send', 'new-mail']);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React, {Component} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {DateUtils} from 'nylas-exports'
|
||||
import {Flexbox} from 'nylas-component-kit'
|
||||
import SendingProgressBar from './sending-progress-bar'
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { DateUtils } from 'nylas-exports';
|
||||
import { Flexbox } from 'nylas-component-kit';
|
||||
import SendingProgressBar from './sending-progress-bar';
|
||||
|
||||
export default class DraftListSendStatus extends Component {
|
||||
static displayName = 'DraftListSendStatus';
|
||||
|
@ -14,17 +14,17 @@ export default class DraftListSendStatus extends Component {
|
|||
static containerRequired = false;
|
||||
|
||||
render() {
|
||||
const {draft} = this.props
|
||||
const { draft } = this.props;
|
||||
if (draft.uploadTaskId) {
|
||||
return (
|
||||
<Flexbox style={{width: 150, whiteSpace: 'nowrap'}}>
|
||||
<Flexbox style={{ width: 150, whiteSpace: 'nowrap' }}>
|
||||
<SendingProgressBar
|
||||
style={{flex: 1, marginRight: 10}}
|
||||
style={{ flex: 1, marginRight: 10 }}
|
||||
progress={draft.uploadProgress * 100}
|
||||
/>
|
||||
</Flexbox>
|
||||
)
|
||||
);
|
||||
}
|
||||
return <span className="timestamp">{DateUtils.shortTimeString(draft.date)}</span>
|
||||
return <span className="timestamp">{DateUtils.shortTimeString(draft.date)}</span>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
import React, {Component} from 'react'
|
||||
import {ListensToObservable, MultiselectToolbar, InjectedComponentSet} from 'nylas-component-kit'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import DraftListStore from './draft-list-store'
|
||||
import React, { Component } from 'react';
|
||||
import { ListensToObservable, MultiselectToolbar, InjectedComponentSet } from 'nylas-component-kit';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import DraftListStore from './draft-list-store';
|
||||
|
||||
function getObservable() {
|
||||
return DraftListStore.selectionObservable()
|
||||
return DraftListStore.selectionObservable();
|
||||
}
|
||||
|
||||
function getStateFromObservable(items) {
|
||||
if (!items) {
|
||||
return {items: []}
|
||||
return { items: [] };
|
||||
}
|
||||
return {items}
|
||||
return { items };
|
||||
}
|
||||
|
||||
class DraftListToolbar extends Component {
|
||||
|
@ -24,20 +23,20 @@ class DraftListToolbar extends Component {
|
|||
};
|
||||
|
||||
onClearSelection = () => {
|
||||
DraftListStore.dataSource().selection.clear()
|
||||
DraftListStore.dataSource().selection.clear();
|
||||
};
|
||||
|
||||
render() {
|
||||
const {selection} = DraftListStore.dataSource()
|
||||
const {items} = this.props
|
||||
const { selection } = DraftListStore.dataSource();
|
||||
const { items } = this.props;
|
||||
|
||||
// Keep all of the exposed props from deprecated regions that now map to this one
|
||||
const toolbarElement = (
|
||||
<InjectedComponentSet
|
||||
matching={{role: "DraftActionsToolbarButton"}}
|
||||
exposedProps={{selection, items}}
|
||||
matching={{ role: 'DraftActionsToolbarButton' }}
|
||||
exposedProps={{ selection, items }}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<MultiselectToolbar
|
||||
|
@ -46,8 +45,8 @@ class DraftListToolbar extends Component {
|
|||
toolbarElement={toolbarElement}
|
||||
onClearSelection={this.onClearSelection}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ListensToObservable(DraftListToolbar, {getObservable, getStateFromObservable})
|
||||
export default ListensToObservable(DraftListToolbar, { getObservable, getStateFromObservable });
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
React = require "react"
|
||||
{RetinaImg} = require 'nylas-component-kit'
|
||||
{Actions, FocusedContentStore} = require "nylas-exports"
|
||||
{React, PropTypes, Actions, FocusedContentStore} = require "nylas-exports"
|
||||
|
||||
class DraftDeleteButton extends React.Component
|
||||
@displayName: 'DraftDeleteButton'
|
||||
@containerRequired: false
|
||||
|
||||
@propTypes:
|
||||
selection: React.PropTypes.object.isRequired
|
||||
selection: PropTypes.object.isRequired
|
||||
|
||||
render: ->
|
||||
<button style={order:-100}
|
||||
|
|
|
@ -1,31 +1,29 @@
|
|||
import {WorkspaceStore, ComponentRegistry, Actions} from 'nylas-exports'
|
||||
import DraftList from './draft-list'
|
||||
import DraftListToolbar from './draft-list-toolbar'
|
||||
import DraftListSendStatus from './draft-list-send-status'
|
||||
import {DraftDeleteButton} from "./draft-toolbar-buttons"
|
||||
|
||||
import { WorkspaceStore, ComponentRegistry, Actions } from 'nylas-exports';
|
||||
import DraftList from './draft-list';
|
||||
import DraftListToolbar from './draft-list-toolbar';
|
||||
import DraftListSendStatus from './draft-list-send-status';
|
||||
import { DraftDeleteButton } from './draft-toolbar-buttons';
|
||||
|
||||
export function activate() {
|
||||
WorkspaceStore.defineSheet(
|
||||
'Drafts',
|
||||
{root: true},
|
||||
{list: ['RootSidebar', 'DraftList']}
|
||||
);
|
||||
if (NylasEnv.savedState.perspective &&
|
||||
NylasEnv.savedState.perspective.type === "DraftsMailboxPerspective") {
|
||||
WorkspaceStore.defineSheet('Drafts', { root: true }, { list: ['RootSidebar', 'DraftList'] });
|
||||
if (
|
||||
NylasEnv.savedState.perspective &&
|
||||
NylasEnv.savedState.perspective.type === 'DraftsMailboxPerspective'
|
||||
) {
|
||||
Actions.selectRootSheet(WorkspaceStore.Sheet.Drafts);
|
||||
}
|
||||
|
||||
ComponentRegistry.register(DraftList, {location: WorkspaceStore.Location.DraftList})
|
||||
ComponentRegistry.register(DraftListToolbar, {location: WorkspaceStore.Location.DraftList.Toolbar})
|
||||
ComponentRegistry.register(DraftDeleteButton, {role: 'DraftActionsToolbarButton'})
|
||||
ComponentRegistry.register(DraftListSendStatus, {role: 'DraftList:DraftStatus'})
|
||||
ComponentRegistry.register(DraftList, { location: WorkspaceStore.Location.DraftList });
|
||||
ComponentRegistry.register(DraftListToolbar, {
|
||||
location: WorkspaceStore.Location.DraftList.Toolbar,
|
||||
});
|
||||
ComponentRegistry.register(DraftDeleteButton, { role: 'DraftActionsToolbarButton' });
|
||||
ComponentRegistry.register(DraftListSendStatus, { role: 'DraftList:DraftStatus' });
|
||||
}
|
||||
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(DraftList)
|
||||
ComponentRegistry.unregister(DraftListToolbar)
|
||||
ComponentRegistry.unregister(DraftDeleteButton)
|
||||
ComponentRegistry.unregister(DraftListSendStatus)
|
||||
ComponentRegistry.unregister(DraftList);
|
||||
ComponentRegistry.unregister(DraftListToolbar);
|
||||
ComponentRegistry.unregister(DraftDeleteButton);
|
||||
ComponentRegistry.unregister(DraftListSendStatus);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
React = require 'react'
|
||||
{Actions} = require 'nylas-exports'
|
||||
{React, PropTypes, Actions} = require 'nylas-exports'
|
||||
{RetinaImg} = require 'nylas-component-kit'
|
||||
|
||||
class SendingCancelButton extends React.Component
|
||||
@displayName: 'SendingCancelButton'
|
||||
|
||||
@propTypes:
|
||||
taskId: React.PropTypes.string.isRequired
|
||||
taskId: PropTypes.string.isRequired
|
||||
|
||||
constructor: (@props) ->
|
||||
@state =
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
React = require 'react'
|
||||
{Utils} = require 'nylas-exports'
|
||||
{React, PropTypes, Utils} = require 'nylas-exports'
|
||||
|
||||
class SendingProgressBar extends React.Component
|
||||
@propTypes:
|
||||
progress: React.PropTypes.number.isRequired
|
||||
progress: PropTypes.number.isRequired
|
||||
|
||||
render: ->
|
||||
otherProps = Utils.fastOmit(@props, Object.keys(@constructor.propTypes))
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
_ = require 'underscore'
|
||||
path = require 'path'
|
||||
React = require 'react'
|
||||
{RetinaImg} = require 'nylas-component-kit'
|
||||
{Actions,
|
||||
React, PropTypes,
|
||||
DateUtils,
|
||||
Message,
|
||||
Event,
|
||||
|
@ -16,7 +16,7 @@ class EventHeader extends React.Component
|
|||
@displayName: 'EventHeader'
|
||||
|
||||
@propTypes:
|
||||
message: React.PropTypes.instanceOf(Message).isRequired
|
||||
message: PropTypes.instanceOf(Message).isRequired
|
||||
|
||||
constructor: (@props) ->
|
||||
@state =
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import {React} from 'nylas-exports';
|
||||
import GithubUserStore from "./github-user-store";
|
||||
import { React, PropTypes } from 'nylas-exports';
|
||||
import GithubUserStore from './github-user-store';
|
||||
|
||||
// Small React component that renders a single Github repository
|
||||
const GithubRepo = function GithubRepo(props) {
|
||||
const {repo} = props;
|
||||
const { repo } = props;
|
||||
|
||||
return (
|
||||
<div className="repo">
|
||||
|
@ -11,20 +11,20 @@ const GithubRepo = function GithubRepo(props) {
|
|||
<a href={repo.html_url}>{repo.full_name}</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
GithubRepo.propTypes = {
|
||||
// This component takes a `repo` object as a prop. Listing props is optional
|
||||
// but enables nice React warnings when our expectations aren't met
|
||||
repo: React.PropTypes.object.isRequired,
|
||||
repo: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
// Small React component that renders the user's Github profile.
|
||||
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
|
||||
const repoElements = profile.repos.map((repo) => {
|
||||
return <GithubRepo key={repo.id} repo={repo} />
|
||||
const repoElements = profile.repos.map(repo => {
|
||||
return <GithubRepo key={repo.id} repo={repo} />;
|
||||
});
|
||||
|
||||
// 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.
|
||||
return (
|
||||
<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>
|
||||
<div>{repoElements}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
GithubProfile.propTypes = {
|
||||
// This component takes a `profile` object as a prop. Listing props is optional
|
||||
// but enables nice React warnings when our expectations aren't met.
|
||||
profile: React.PropTypes.object.isRequired,
|
||||
}
|
||||
profile: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default class GithubContactCardSection extends React.Component {
|
||||
static displayName = 'GithubContactCardSection';
|
||||
|
||||
static containerStyles = {
|
||||
order: 10,
|
||||
}
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -72,27 +76,25 @@ export default class GithubContactCardSection extends React.Component {
|
|||
profile: GithubUserStore.profileForFocusedContact(),
|
||||
loading: GithubUserStore.loading(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// The data vended by the GithubUserStore has changed. Calling `setState:`
|
||||
// will cause React to re-render our view to reflect the new values.
|
||||
_onChange = () => {
|
||||
this.setState(this._getStateFromStores())
|
||||
}
|
||||
this.setState(this._getStateFromStores());
|
||||
};
|
||||
|
||||
_renderInner() {
|
||||
// Handle various loading states by returning early
|
||||
if (this.state.loading) {
|
||||
return (<div className="pending">Loading...</div>);
|
||||
return <div className="pending">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!this.state.profile) {
|
||||
return (<div className="pending">No Matching Profile</div>);
|
||||
return <div className="pending">No Matching Profile</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<GithubProfile profile={this.state.profile} />
|
||||
);
|
||||
return <GithubProfile profile={this.state.profile} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import _ from 'underscore';
|
||||
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
|
||||
// observes other parts of the application and vends data to our React
|
||||
|
@ -55,10 +55,10 @@ class GithubUserStore extends NylasStore {
|
|||
}
|
||||
|
||||
this.trigger(this);
|
||||
}
|
||||
};
|
||||
|
||||
async _githubFetchProfile(email) {
|
||||
this._loading = true
|
||||
this._loading = true;
|
||||
|
||||
try {
|
||||
const data = await this._githubRequest(`https://api.github.com/search/users?q=${email}`);
|
||||
|
@ -78,9 +78,11 @@ class GithubUserStore extends NylasStore {
|
|||
// repositories.
|
||||
if (profile !== false) {
|
||||
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)
|
||||
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
|
||||
// the updated data.
|
||||
this.trigger(this);
|
||||
|
@ -99,10 +101,10 @@ class GithubUserStore extends NylasStore {
|
|||
// parsed.
|
||||
async _githubRequest(url) {
|
||||
const headers = new Headers();
|
||||
headers.append("User-Agent", "fetch-request");
|
||||
const resp = await fetch(url, {headers});
|
||||
headers.append('User-Agent', 'fetch-request');
|
||||
const resp = await fetch(url, { headers });
|
||||
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();
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import {
|
||||
ComponentRegistry,
|
||||
} from "nylas-exports";
|
||||
import { 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
|
||||
|
@ -24,7 +22,7 @@ export function activate() {
|
|||
// This sidebar is to the right of the Message List in both split pane mode
|
||||
// and list mode.
|
||||
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
Loading…
Reference in a new issue