summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOla Bini <ola.bini@gmail.com>2014-07-31 19:29:33 -0300
committerOla Bini <ola.bini@gmail.com>2014-07-31 19:29:33 -0300
commit04cf441c5ae18400c6b4865b0b37a71718dc9d46 (patch)
treedd0b0d049ec00389e2d4561b226c46eb1682b997
parent639a663a4c37020003586438fdcd7ac529a00f10 (diff)
Add web-ui based on previous code
-rw-r--r--web-ui/.bowerrc3
-rw-r--r--web-ui/.jshintrc37
-rw-r--r--web-ui/.ruby-gemset1
-rw-r--r--web-ui/.ruby-version1
-rw-r--r--web-ui/Gemfile4
-rw-r--r--web-ui/Gemfile.lock18
-rw-r--r--web-ui/Gruntfile.js465
-rw-r--r--web-ui/README.md2
-rw-r--r--web-ui/app/404.html157
-rw-r--r--web-ui/app/favicon.ico0
-rw-r--r--web-ui/app/fonts/NewsCycleBold.ttfbin0 -> 73912 bytes
-rw-r--r--web-ui/app/fonts/NewsCycleRegular.ttfbin0 -> 193996 bytes
-rw-r--r--web-ui/app/fonts/OpenSans-Bold.woffbin0 -> 14504 bytes
-rw-r--r--web-ui/app/fonts/OpenSans-BoldItalic.woffbin0 -> 15488 bytes
-rw-r--r--web-ui/app/fonts/OpenSans-Extrabold.woffbin0 -> 15312 bytes
-rw-r--r--web-ui/app/fonts/OpenSans-ExtraboldItalic.woffbin0 -> 15932 bytes
-rw-r--r--web-ui/app/fonts/OpenSans-Italic.woffbin0 -> 15768 bytes
-rw-r--r--web-ui/app/fonts/OpenSans-Light.woffbin0 -> 15048 bytes
-rw-r--r--web-ui/app/fonts/OpenSans-Semibold.woffbin0 -> 15236 bytes
-rw-r--r--web-ui/app/fonts/OpenSans-SemiboldItalic.woffbin0 -> 15736 bytes
-rw-r--r--web-ui/app/fonts/OpenSans.woffbin0 -> 14604 bytes
-rw-r--r--web-ui/app/fonts/OpenSansLight-Italic.woffbin0 -> 15956 bytes
-rw-r--r--web-ui/app/index.html84
-rw-r--r--web-ui/app/js/dispatchers/left_pane_dispatcher.js51
-rw-r--r--web-ui/app/js/dispatchers/middle_pane_dispatcher.js36
-rw-r--r--web-ui/app/js/dispatchers/right_pane_dispatcher.js93
-rw-r--r--web-ui/app/js/foundation/off_canvas.js21
-rw-r--r--web-ui/app/js/helpers/contenttype.js164
-rw-r--r--web-ui/app/js/helpers/iterator.js43
-rw-r--r--web-ui/app/js/helpers/triggering.js13
-rw-r--r--web-ui/app/js/helpers/view_helper.js145
-rw-r--r--web-ui/app/js/lib/highlightRegex.js127
-rw-r--r--web-ui/app/js/lib/html-sanitizer.js1064
-rw-r--r--web-ui/app/js/lib/html4-defs.js640
-rw-r--r--web-ui/app/js/lib/html_whitelister.js70
-rw-r--r--web-ui/app/js/mail_list/domain/refresher.js25
-rw-r--r--web-ui/app/js/mail_list/ui/mail_item_factory.js49
-rw-r--r--web-ui/app/js/mail_list/ui/mail_items/draft_item.js55
-rw-r--r--web-ui/app/js/mail_list/ui/mail_items/generic_mail_item.js97
-rw-r--r--web-ui/app/js/mail_list/ui/mail_items/mail_item.js63
-rw-r--r--web-ui/app/js/mail_list/ui/mail_items/sent_item.js62
-rw-r--r--web-ui/app/js/mail_list/ui/mail_list.js185
-rw-r--r--web-ui/app/js/mail_list_actions/ui/compose_trigger.js31
-rw-r--r--web-ui/app/js/mail_list_actions/ui/delete_many_trigger.js31
-rw-r--r--web-ui/app/js/mail_list_actions/ui/mail_list_actions.js50
-rw-r--r--web-ui/app/js/mail_list_actions/ui/mark_as_unread_trigger.js31
-rw-r--r--web-ui/app/js/mail_list_actions/ui/mark_many_as_read_trigger.js31
-rw-r--r--web-ui/app/js/mail_list_actions/ui/pagination_trigger.js50
-rw-r--r--web-ui/app/js/mail_list_actions/ui/refresh_trigger.js28
-rw-r--r--web-ui/app/js/mail_list_actions/ui/toggle_check_all_trigger.js33
-rw-r--r--web-ui/app/js/mail_view/data/mail_builder.js79
-rw-r--r--web-ui/app/js/mail_view/data/mail_sender.js74
-rw-r--r--web-ui/app/js/mail_view/ui/compose_box.js63
-rw-r--r--web-ui/app/js/mail_view/ui/draft_box.js74
-rw-r--r--web-ui/app/js/mail_view/ui/draft_save_status.js25
-rw-r--r--web-ui/app/js/mail_view/ui/forward_box.js66
-rw-r--r--web-ui/app/js/mail_view/ui/mail_actions.js70
-rw-r--r--web-ui/app/js/mail_view/ui/mail_view.js242
-rw-r--r--web-ui/app/js/mail_view/ui/no_message_selected_pane.js25
-rw-r--r--web-ui/app/js/mail_view/ui/recipients/recipient.js36
-rw-r--r--web-ui/app/js/mail_view/ui/recipients/recipients.js127
-rw-r--r--web-ui/app/js/mail_view/ui/recipients/recipients_input.js140
-rw-r--r--web-ui/app/js/mail_view/ui/recipients/recipients_iterator.js41
-rw-r--r--web-ui/app/js/mail_view/ui/reply_box.js102
-rw-r--r--web-ui/app/js/mail_view/ui/reply_section.js102
-rw-r--r--web-ui/app/js/mail_view/ui/send_button.js85
-rw-r--r--web-ui/app/js/main.js58
-rw-r--r--web-ui/app/js/mixins/with_compose_inline.js63
-rw-r--r--web-ui/app/js/mixins/with_enable_disable_on_event.js34
-rw-r--r--web-ui/app/js/mixins/with_hide_and_show.js14
-rw-r--r--web-ui/app/js/mixins/with_mail_edit_base.js182
-rw-r--r--web-ui/app/js/monkey_patching/all.js1
-rw-r--r--web-ui/app/js/monkey_patching/array.js11
-rw-r--r--web-ui/app/js/monkey_patching/post_message.js16
-rw-r--r--web-ui/app/js/page/default.js87
-rw-r--r--web-ui/app/js/page/events.js168
-rw-r--r--web-ui/app/js/page/pane_contract_expand.js34
-rw-r--r--web-ui/app/js/page/router.js51
-rw-r--r--web-ui/app/js/page/router/url_params.js40
-rw-r--r--web-ui/app/js/search/results_highlighter.js53
-rw-r--r--web-ui/app/js/search/search_trigger.js68
-rw-r--r--web-ui/app/js/services/delete_service.js43
-rw-r--r--web-ui/app/js/services/mail_service.js254
-rw-r--r--web-ui/app/js/services/model/mail.js147
-rw-r--r--web-ui/app/js/tags/data/tags.js42
-rw-r--r--web-ui/app/js/tags/ui/tag.js94
-rw-r--r--web-ui/app/js/tags/ui/tag_base.js24
-rw-r--r--web-ui/app/js/tags/ui/tag_list.js93
-rw-r--r--web-ui/app/js/tags/ui/tag_shortcut.js68
-rw-r--r--web-ui/app/js/user_alerts/ui/user_alerts.js35
-rw-r--r--web-ui/app/js/views/i18n.js18
-rw-r--r--web-ui/app/js/views/recipientListFormatter.js16
-rw-r--r--web-ui/app/js/views/templates.js46
-rw-r--r--web-ui/app/locales/en/translation.json69
-rw-r--r--web-ui/app/locales/pt/translation.json20
-rw-r--r--web-ui/app/locales/sv/translation.json66
-rw-r--r--web-ui/app/robots.txt3
-rw-r--r--web-ui/app/scss/_alerts.scss14
-rw-r--r--web-ui/app/scss/_colors.scss13
-rw-r--r--web-ui/app/scss/_compose.scss92
-rw-r--r--web-ui/app/scss/_mascot.scss32
-rw-r--r--web-ui/app/scss/_mixins.scss205
-rw-r--r--web-ui/app/scss/_read.scss105
-rw-r--r--web-ui/app/scss/_reply.scss36
-rw-r--r--web-ui/app/scss/_security.scss47
-rw-r--r--web-ui/app/scss/foundation.scss2066
-rw-r--r--web-ui/app/scss/main.scss46
-rw-r--r--web-ui/app/scss/news-cycle.scss13
-rw-r--r--web-ui/app/scss/opensans.scss61
-rw-r--r--web-ui/app/scss/reset.scss421
-rw-r--r--web-ui/app/scss/styles.scss610
-rw-r--r--web-ui/app/templates/compose/compose_box.hbs23
-rw-r--r--web-ui/app/templates/compose/fixed_recipient.hbs6
-rw-r--r--web-ui/app/templates/compose/inline_box.hbs18
-rw-r--r--web-ui/app/templates/compose/recipient_input.hbs1
-rw-r--r--web-ui/app/templates/compose/recipients.hbs19
-rw-r--r--web-ui/app/templates/compose/reply_section.hbs6
-rw-r--r--web-ui/app/templates/mail_actions/actions_box.hbs6
-rw-r--r--web-ui/app/templates/mail_actions/compose_trigger.hbs3
-rw-r--r--web-ui/app/templates/mail_actions/pagination_trigger.hbs3
-rw-r--r--web-ui/app/templates/mail_actions/refresh_trigger.hbs3
-rw-r--r--web-ui/app/templates/mails/full_view.hbs87
-rw-r--r--web-ui/app/templates/mails/mail_actions.hbs6
-rw-r--r--web-ui/app/templates/mails/sent.hbs23
-rw-r--r--web-ui/app/templates/mails/single.hbs19
-rw-r--r--web-ui/app/templates/no_message_selected.hbs3
-rw-r--r--web-ui/app/templates/search/search_trigger.hbs3
-rw-r--r--web-ui/app/templates/tags/shortcut.hbs9
-rw-r--r--web-ui/app/templates/tags/tag.hbs3
-rw-r--r--web-ui/app/templates/tags/tag_inner.hbs4
-rw-r--r--web-ui/app/templates/tags/tag_list.hbs3
-rw-r--r--web-ui/app/templates/user_alerts/message.hbs1
-rw-r--r--web-ui/bower.json21
-rw-r--r--web-ui/control-tower.yml3
-rwxr-xr-xweb-ui/go3
-rw-r--r--web-ui/karma.conf.js85
-rw-r--r--web-ui/package.json58
-rw-r--r--web-ui/test/spec/dispatchers/left_pane_dispatcher.spec.js79
-rw-r--r--web-ui/test/spec/dispatchers/middle_pane_dispatchers.spec.js27
-rw-r--r--web-ui/test/spec/dispatchers/right_pane_dispatcher.spec.js90
-rw-r--r--web-ui/test/spec/helpers/view_helper.spec.js76
-rw-r--r--web-ui/test/spec/mail_list/ui/mail_items/generic_mail_item.spec.js136
-rw-r--r--web-ui/test/spec/mail_list/ui/mail_items/mail_item.spec.js40
-rw-r--r--web-ui/test/spec/mail_list/ui/mail_list.spec.js275
-rw-r--r--web-ui/test/spec/mail_list_actions/ui/compose_trigger.spec.js16
-rw-r--r--web-ui/test/spec/mail_list_actions/ui/mail_actions.spec.js9
-rw-r--r--web-ui/test/spec/mail_list_actions/ui/pagination_trigger.spec.js26
-rw-r--r--web-ui/test/spec/mail_view/data/mail_builder.spec.js110
-rw-r--r--web-ui/test/spec/mail_view/data/mail_sender.spec.js70
-rw-r--r--web-ui/test/spec/mail_view/ui/compose_box.spec.js132
-rw-r--r--web-ui/test/spec/mail_view/ui/draft_box.spec.js67
-rw-r--r--web-ui/test/spec/mail_view/ui/draft_save_status.spec.js26
-rw-r--r--web-ui/test/spec/mail_view/ui/forward_box.spec.js90
-rw-r--r--web-ui/test/spec/mail_view/ui/mail_actions.spec.js63
-rw-r--r--web-ui/test/spec/mail_view/ui/mail_view.spec.js247
-rw-r--r--web-ui/test/spec/mail_view/ui/recipients/recipients.spec.js36
-rw-r--r--web-ui/test/spec/mail_view/ui/recipients/recipients_input.spec.js104
-rw-r--r--web-ui/test/spec/mail_view/ui/recipients/recipients_iterator.spec.js101
-rw-r--r--web-ui/test/spec/mail_view/ui/reply_box.spec.js105
-rw-r--r--web-ui/test/spec/mail_view/ui/reply_section.spec.js97
-rw-r--r--web-ui/test/spec/mail_view/ui/send_button.spec.js91
-rw-r--r--web-ui/test/spec/mixins/with_mail_edit_base.spec.js94
-rw-r--r--web-ui/test/spec/page/pane_contract_expand.spec.js67
-rw-r--r--web-ui/test/spec/page/router.spec.js70
-rw-r--r--web-ui/test/spec/page/router/url_params.spec.js68
-rw-r--r--web-ui/test/spec/search/search_trigger.spec.js78
-rw-r--r--web-ui/test/spec/services/delete_service.spec.js54
-rw-r--r--web-ui/test/spec/services/mail_service.spec.js307
-rw-r--r--web-ui/test/spec/services/model/mail.spec.js116
-rw-r--r--web-ui/test/spec/tags/data/tags.spec.js39
-rw-r--r--web-ui/test/spec/tags/ui/tag.spec.js151
-rw-r--r--web-ui/test/spec/tags/ui/tag_list.spec.js75
-rw-r--r--web-ui/test/spec/tags/ui/tag_shortcut.spec.js35
-rw-r--r--web-ui/test/spec/user_alerts/ui/user_alerts.spec.js23
-rw-r--r--web-ui/test/test-main.js57
-rw-r--r--web-ui/test/test_data.js170
176 files changed, 15026 insertions, 0 deletions
diff --git a/web-ui/.bowerrc b/web-ui/.bowerrc
new file mode 100644
index 00000000..5773025b
--- /dev/null
+++ b/web-ui/.bowerrc
@@ -0,0 +1,3 @@
+{
+ "directory": "app/bower_components"
+}
diff --git a/web-ui/.jshintrc b/web-ui/.jshintrc
new file mode 100644
index 00000000..a3714ba9
--- /dev/null
+++ b/web-ui/.jshintrc
@@ -0,0 +1,37 @@
+{
+ "node": true,
+ "browser": true,
+ "esnext": true,
+ "bitwise": true,
+ "camelcase": true,
+ "curly": true,
+ "eqeqeq": true,
+ "immed": true,
+ "indent": 2,
+ "latedef": false,
+ "newcap": true,
+ "noarg": true,
+ "quotmark": "single",
+ "regexp": true,
+ "smarttabs": true,
+ "strict": true,
+ "trailing": true,
+ "undef": true,
+ "validthis": true,
+ "predef": [
+ "$",
+ "jQuery",
+ "before",
+ "beforeEach",
+ "define",
+ "describe",
+ "describeComponent",
+ "describeMixin",
+ "expect",
+ "it",
+ "requirejs",
+ "setupComponent",
+ "spyOn",
+ "spyOnEvent"
+ ]
+}
diff --git a/web-ui/.ruby-gemset b/web-ui/.ruby-gemset
new file mode 100644
index 00000000..66924d5a
--- /dev/null
+++ b/web-ui/.ruby-gemset
@@ -0,0 +1 @@
+smail-front
diff --git a/web-ui/.ruby-version b/web-ui/.ruby-version
new file mode 100644
index 00000000..eca07e4c
--- /dev/null
+++ b/web-ui/.ruby-version
@@ -0,0 +1 @@
+2.1.2
diff --git a/web-ui/Gemfile b/web-ui/Gemfile
new file mode 100644
index 00000000..e9cb427d
--- /dev/null
+++ b/web-ui/Gemfile
@@ -0,0 +1,4 @@
+source 'https://rubygems.org'
+
+gem 'compass'
+gem 'flight-control-tower'
diff --git a/web-ui/Gemfile.lock b/web-ui/Gemfile.lock
new file mode 100644
index 00000000..f926d679
--- /dev/null
+++ b/web-ui/Gemfile.lock
@@ -0,0 +1,18 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ chunky_png (1.3.1)
+ compass (0.12.6)
+ chunky_png (~> 1.2)
+ fssm (>= 0.2.7)
+ sass (~> 3.2.19)
+ flight-control-tower (0.2.1)
+ fssm (0.2.10)
+ sass (3.2.19)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ compass
+ flight-control-tower
diff --git a/web-ui/Gruntfile.js b/web-ui/Gruntfile.js
new file mode 100644
index 00000000..01c53007
--- /dev/null
+++ b/web-ui/Gruntfile.js
@@ -0,0 +1,465 @@
+'use strict';
+
+// # Globbing
+// for performance reasons we're only matching one level down:
+// 'test/spec/{,*/}*.js'
+// use this if you want to recursively match all subfolders:
+// 'test/spec/**/*.js'
+
+module.exports = function (grunt) {
+ var exec = require('child_process').exec;
+
+ // Load grunt tasks automatically
+ require('load-grunt-tasks')(grunt);
+
+ // Time how long tasks take. Can help when optimizing build times
+ require('time-grunt')(grunt);
+
+ // Define the configuration for all the tasks
+ grunt.initConfig({
+ compress: {
+ main: {
+ options: {
+ archive: 'archive.zip'
+ },
+ files: [
+ {src: ['dist/**/*']} // includes files in path
+ ]
+ }
+ },
+
+ // Project settings
+ yeoman: {
+ // configurable paths
+ app: require('./bower.json').appPath || 'app',
+ dist: 'dist'
+ },
+
+ // Watches files for changes and runs tasks based on the changed files
+ watch: {
+ js: {
+ files: ['{.tmp,<%= yeoman.app %>}/js/{,*/}*.js'],
+ tasks: ['newer:jshint:all', 'update-control-tower']
+ },
+ jsTest: {
+ files: ['test/spec/{,*/}*.js'],
+ tasks: ['newer:jshint:test', 'karma']
+ },
+ compass: {
+ files: ['<%= yeoman.app %>/scss/{,*/}*.scss'],
+ tasks: ['compass:dev']
+ },
+ html: {
+ files: ['<%= yeoman.app %>/index.html'],
+ livereload: true
+ },
+ handlebars: {
+ files: ['<%= yeoman.app %>/templates/**/*.hbs'],
+ tasks: ['handlebars:dev']
+ },
+ gruntfile: {
+ files: ['Gruntfile.js']
+ },
+ options: {
+ livereload: true
+ }
+ },
+
+ compass: {
+ dist: {
+ options: {
+ sassDir: '<%= yeoman.app %>/scss',
+ cssDir: '<%= yeoman.dist %>/css',
+ environment: 'production'
+ }
+ },
+ dev: {
+ options: {
+ sassDir: '<%= yeoman.app %>/scss',
+ cssDir: '.tmp/css'
+ }
+ }
+ },
+
+ // The actual grunt server settings
+ connect: {
+ options: {
+ port: 9000,
+ // Change this from '0.0.0.0' to 'localhost' to limit access from outside.
+ hostname: '0.0.0.0',
+ livereload: true
+ },
+ dev: {
+ options: {
+ base: [
+ '.tmp',
+ '<%= yeoman.app %>'
+ ]
+ },
+ livereload: true
+ },
+ test: {
+ options: {
+ port: 9001,
+ base: [
+ '.tmp',
+ 'test',
+ '<%= yeoman.app %>'
+ ]
+ }
+ },
+ dist: {
+ options: {
+ base: '<%= yeoman.dist %>'
+ }
+ }
+ },
+
+ // Make sure code styles are up to par and there are no obvious mistakes
+ jshint: {
+ options: {
+ jshintrc: '.jshintrc',
+ reporter: require('jshint-stylish')
+ },
+ all: [
+ 'Gruntfile.js',
+ '<%= yeoman.app %>/scripts/{,*/}*.js'
+ ],
+ test: {
+ options: {
+ jshintrc: '.jshintrc'
+ },
+ src: ['test/spec/{,*/}*.js']
+ }
+ },
+
+ // Empties folders to start fresh
+ clean: {
+ dist: {
+ files: [{
+ dot: true,
+ src: [
+ '.tmp',
+ '<%= yeoman.dist %>/*',
+ ]
+ }]
+ },
+ server: ['.tmp', '<%= yeoman.app %>/js/generated']
+ },
+
+ uglify: {
+ dist: {
+ files: [{
+ expand: true,
+ cwd: '.tmp',
+ src: 'app.min.js',
+ dest: 'dist'
+ }]
+ }
+ },
+
+ cssmin: {
+ minify: {
+ expand: true,
+ cwd: '<%= yeoman.app %>/css/',
+ src: ['*.css', '!*.min.css'],
+ dest: '<%= yeoman.dist %>/css/'
+ }
+ },
+
+ // The following *-min tasks produce minified files in the dist folder
+ imagemin: {
+ dist: {
+ files: [{
+ expand: true,
+ cwd: '<%= yeoman.app %>/images',
+ src: '{,*/}*.{png,jpg,jpeg,gif}',
+ dest: '<%= yeoman.dist %>/images'
+ }]
+ }
+ },
+
+ svgmin: {
+ dist: {
+ files: [{
+ expand: true,
+ cwd: '<%= yeoman.app %>/images',
+ src: '{,*/}*.svg',
+ dest: '<%= yeoman.dist %>/images'
+ }]
+ }
+ },
+
+ htmlmin: {
+ dist: {
+ options: {
+ // Optional configurations that you can uncomment to use
+ // removeCommentsFromCDATA: true,
+ // collapseBooleanAttributes: true,
+ // removeAttributeQuotes: true,
+ // removeRedundantAttributes: true,
+ // useShortDoctype: true,
+ // removeEmptyAttributes: true,
+ // removeOptionalTags: true*/
+ },
+ files: [{
+ expand: true,
+ cwd: '<%= yeoman.app %>',
+ src: ['*.html', 'templates/*.html'],
+ dest: '<%= yeoman.dist %>'
+ }]
+ }
+ },
+
+ // Allow the use of non-minsafe AngularJS files. Automatically makes it
+ // minsafe compatible so Uglify does not destroy the ng references
+ // Replace Google CDN references
+ cdnify: {
+ dist: {
+ html: ['<%= yeoman.dist %>/*.html']
+ }
+ },
+
+ // Copies remaining files to places other tasks can use
+ copy: {
+ dist: {
+ files: [{
+ expand: true,
+ dot: true,
+ cwd: '<%= yeoman.app %>',
+ dest: '<%= yeoman.dist %>',
+ src: [
+ '*.{ico,png,txt}',
+ '.htaccess',
+ 'images/{,*/}*.{webp}',
+ 'fonts/*',
+ 'templates/*',
+ 'locales/**/*',
+ 'bower_components/font-awesome/css/font-awesome.min.css',
+ 'bower_components/font-awesome/fonts/*'
+ ]
+ }, {
+ expand: true,
+ cwd: '.tmp/images',
+ dest: '<%= yeoman.dist %>/images',
+ src: [
+ 'generated/*'
+ ]
+ }]
+ },
+ styles: {
+ expand: true,
+ cwd: '<%= yeoman.app %>/styles',
+ dest: '.tmp/css/',
+ src: '{,*/}*.css'
+ }
+ },
+
+ // Run some tasks in parallel to speed up the build process
+ concurrent: {
+ server: [
+ 'copy:styles'
+ ],
+ test: [
+ 'copy:styles'
+ ],
+ dist: [
+ 'copy:styles',
+ 'imagemin',
+ 'svgmin',
+ 'htmlmin'
+ ]
+ },
+ requirejs: {
+ compile: {
+ options: {
+ baseUrl: 'app',
+ wrap: true,
+ almond: true,
+ optimize: 'none',
+ mainConfigFile: 'app/js/main.js',
+ out: '.tmp/app.concatenated.js',
+ include: ['js/main'],
+ name: 'bower_components/almond/almond'
+
+ }
+ }
+ },
+ concat: {
+ options: {
+ separator: ';',
+ },
+ dist: {
+ src: [
+ 'app/bower_components/modernizr/modernizr.js',
+ 'app/bower_components/lodash/dist/lodash.js',
+ 'app/bower_components/jquery/dist/jquery.js',
+ 'app/js/lib/highlightRegex.js',
+ 'app/bower_components/handlebars/handlebars.min.js',
+ 'app/bower_components/typeahead.js/dist/typeahead.bundle.min.js',
+ 'app/bower_components/foundation/js/foundation.js',
+ 'app/bower_components/foundation/js/foundation/foundation.reveal.js',
+ 'app/bower_components/foundation/js/foundation/foundation.offcanvas.js',
+ '.tmp/app.concatenated.js',
+ ],
+ dest: '.tmp/app.min.js',
+ },
+ },
+ useminPrepare: {
+ html: 'dist/index.html',
+ options: {
+ dest: 'dist'
+ }
+ },
+ usemin: {
+ html: 'dist/index.html'
+ },
+ 'regex-replace': {
+ dist: {
+ src: ['dist/index.html'],
+ actions: [
+ {
+ name: 'remove-requirejs-from-index',
+ search: 'remove-in-build.*end-remove-in-build',
+ replace: function(match){
+ return '';
+ },
+ flags: 'g'
+ }
+ ]
+ }
+ },
+ handlebars: {
+ dist: {
+ options: {
+ namespace: 'Smail'
+ },
+ files: {
+ '<%= yeoman.dist %>/js/templates.js': '<%= yeoman.app %>/templates/**/*.hbs'
+ }
+ },
+ dev: {
+ options: {
+ namespace: 'Smail'
+ },
+ files: {
+ '<%= yeoman.app %>/js/generated/hbs/templates.js': '<%= yeoman.app %>/templates/**/*.hbs'
+ }
+ }
+ },
+
+ // Test settings
+ karma: {
+ options: {
+ configFile: 'karma.conf.js'
+ },
+
+ ci: {
+ singleRun: true,
+ autoWatch: false,
+ colors: false,
+ reporters: ['junit']
+ },
+
+ single: {
+ singleRun: true,
+ autoWatch: false
+ },
+
+ dev: {
+ singleRun: false,
+ browsers: ['PhantomJS']
+ },
+ debug: {
+ singleRun: false,
+ browsers: ['Chrome']
+ }
+
+ }
+
+ });
+
+ grunt.loadNpmTasks('grunt-contrib-requirejs');
+ grunt.loadNpmTasks('grunt-contrib-concat');
+
+ grunt.registerTask('serve', function (target) {
+ if (target === 'dist') {
+ return grunt.task.run(['package', 'connect:dist:keepalive']);
+ }
+
+ grunt.task.run([
+ 'clean:server',
+ 'compass:dev',
+ 'handlebars:dev',
+ 'concurrent:server',
+ 'update-control-tower',
+ 'connect:dev',
+ 'watch'
+ ]);
+ });
+
+ grunt.registerTask('test-watch', [
+ 'clean:server',
+ 'handlebars:dev',
+ 'connect:test',
+ 'karma:dev'
+ ]);
+
+ grunt.registerTask('debug', [
+ 'clean:server',
+ 'handlebars:dev',
+ 'connect:test',
+ 'karma:debug'
+ ]);
+
+ grunt.registerTask('test-ci', [
+ 'clean:server',
+ 'stopbrowsers',
+ 'handlebars:dev',
+ 'concurrent:test',
+ 'connect:test',
+ 'karma:ci'
+ ]);
+
+ grunt.registerTask('test', [
+ 'clean:server',
+ 'handlebars:dev',
+ 'connect:test',
+ 'karma:single'
+ ]);
+
+ grunt.registerTask('package', [
+ 'clean:dist',
+ 'useminPrepare',
+ 'compass:dist',
+ 'handlebars:dist',
+ 'concurrent:dist',
+ 'copy:dist',
+ 'cdnify',
+ 'cssmin',
+ 'requirejs',
+ 'concat:dist',
+ 'uglify',
+ 'usemin',
+ 'regex-replace:dist',
+ 'compress'
+ ]);
+
+ grunt.registerTask('default', [
+ 'newer:jshint',
+ 'test',
+ 'package'
+ ]);
+
+ grunt.registerTask('stopbrowsers', function () {
+ ['phantom', 'firefox'].forEach(function (browser) {
+ console.log('killing all ' + browser + ' instances');
+ exec('pgrep -f "' + browser + '" | xargs kill');
+ console.log('... done');
+ });
+ });
+
+ grunt.registerTask('update-control-tower', function () {
+ exec('bash -c "flight-control-tower control-tower.yml && mv control_tower.html .tmp/"');
+ });
+};
diff --git a/web-ui/README.md b/web-ui/README.md
new file mode 100644
index 00000000..74dadb95
--- /dev/null
+++ b/web-ui/README.md
@@ -0,0 +1,2 @@
+smail-front
+===========
diff --git a/web-ui/app/404.html b/web-ui/app/404.html
new file mode 100644
index 00000000..fdace4ab
--- /dev/null
+++ b/web-ui/app/404.html
@@ -0,0 +1,157 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <title>Page Not Found :(</title>
+ <style>
+ ::-moz-selection {
+ background: #b3d4fc;
+ text-shadow: none;
+ }
+
+ ::selection {
+ background: #b3d4fc;
+ text-shadow: none;
+ }
+
+ html {
+ padding: 30px 10px;
+ font-size: 20px;
+ line-height: 1.4;
+ color: #737373;
+ background: #f0f0f0;
+ -webkit-text-size-adjust: 100%;
+ -ms-text-size-adjust: 100%;
+ }
+
+ html,
+ input {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ }
+
+ body {
+ max-width: 500px;
+ _width: 500px;
+ padding: 30px 20px 50px;
+ border: 1px solid #b3b3b3;
+ border-radius: 4px;
+ margin: 0 auto;
+ box-shadow: 0 1px 10px #a7a7a7, inset 0 1px 0 #fff;
+ background: #fcfcfc;
+ }
+
+ h1 {
+ margin: 0 10px;
+ font-size: 50px;
+ text-align: center;
+ }
+
+ h1 span {
+ color: #bbb;
+ }
+
+ h3 {
+ margin: 1.5em 0 0.5em;
+ }
+
+ p {
+ margin: 1em 0;
+ }
+
+ ul {
+ padding: 0 0 0 40px;
+ margin: 1em 0;
+ }
+
+ .container {
+ max-width: 380px;
+ _width: 380px;
+ margin: 0 auto;
+ }
+
+ /* google search */
+
+ #goog-fixurl ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ #goog-fixurl form {
+ margin: 0;
+ }
+
+ #goog-wm-qt,
+ #goog-wm-sb {
+ border: 1px solid #bbb;
+ font-size: 16px;
+ line-height: normal;
+ vertical-align: top;
+ color: #444;
+ border-radius: 2px;
+ }
+
+ #goog-wm-qt {
+ width: 220px;
+ height: 20px;
+ padding: 5px;
+ margin: 5px 10px 0 0;
+ box-shadow: inset 0 1px 1px #ccc;
+ }
+
+ #goog-wm-sb {
+ display: inline-block;
+ height: 32px;
+ padding: 0 10px;
+ margin: 5px 0 0;
+ white-space: nowrap;
+ cursor: pointer;
+ background-color: #f5f5f5;
+ background-image: -webkit-linear-gradient(rgba(255,255,255,0), #f1f1f1);
+ background-image: -moz-linear-gradient(rgba(255,255,255,0), #f1f1f1);
+ background-image: -ms-linear-gradient(rgba(255,255,255,0), #f1f1f1);
+ background-image: -o-linear-gradient(rgba(255,255,255,0), #f1f1f1);
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ *overflow: visible;
+ *display: inline;
+ *zoom: 1;
+ }
+
+ #goog-wm-sb:hover,
+ #goog-wm-sb:focus {
+ border-color: #aaa;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
+ background-color: #f8f8f8;
+ }
+
+ #goog-wm-qt:hover,
+ #goog-wm-qt:focus {
+ border-color: #105cb6;
+ outline: 0;
+ color: #222;
+ }
+
+ input::-moz-focus-inner {
+ padding: 0;
+ border: 0;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="container">
+ <h1>Not found <span>:(</span></h1>
+ <p>Sorry, but the page you were trying to view does not exist.</p>
+ <p>It looks like this was the result of either:</p>
+ <ul>
+ <li>a mistyped address</li>
+ <li>an out-of-date link</li>
+ </ul>
+ <script>
+ var GOOG_FIXURL_LANG = (navigator.language || '').slice(0,2),GOOG_FIXURL_SITE = location.host;
+ </script>
+ <script src="//linkhelp.clients.google.com/tbproxy/lh/wm/fixurl.js"></script>
+ </div>
+ </body>
+</html>
diff --git a/web-ui/app/favicon.ico b/web-ui/app/favicon.ico
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/web-ui/app/favicon.ico
diff --git a/web-ui/app/fonts/NewsCycleBold.ttf b/web-ui/app/fonts/NewsCycleBold.ttf
new file mode 100644
index 00000000..8265217f
--- /dev/null
+++ b/web-ui/app/fonts/NewsCycleBold.ttf
Binary files differ
diff --git a/web-ui/app/fonts/NewsCycleRegular.ttf b/web-ui/app/fonts/NewsCycleRegular.ttf
new file mode 100644
index 00000000..9fbfd346
--- /dev/null
+++ b/web-ui/app/fonts/NewsCycleRegular.ttf
Binary files differ
diff --git a/web-ui/app/fonts/OpenSans-Bold.woff b/web-ui/app/fonts/OpenSans-Bold.woff
new file mode 100644
index 00000000..dacf3c9c
--- /dev/null
+++ b/web-ui/app/fonts/OpenSans-Bold.woff
Binary files differ
diff --git a/web-ui/app/fonts/OpenSans-BoldItalic.woff b/web-ui/app/fonts/OpenSans-BoldItalic.woff
new file mode 100644
index 00000000..a4e29c0f
--- /dev/null
+++ b/web-ui/app/fonts/OpenSans-BoldItalic.woff
Binary files differ
diff --git a/web-ui/app/fonts/OpenSans-Extrabold.woff b/web-ui/app/fonts/OpenSans-Extrabold.woff
new file mode 100644
index 00000000..7a2e352b
--- /dev/null
+++ b/web-ui/app/fonts/OpenSans-Extrabold.woff
Binary files differ
diff --git a/web-ui/app/fonts/OpenSans-ExtraboldItalic.woff b/web-ui/app/fonts/OpenSans-ExtraboldItalic.woff
new file mode 100644
index 00000000..ce3ab2e7
--- /dev/null
+++ b/web-ui/app/fonts/OpenSans-ExtraboldItalic.woff
Binary files differ
diff --git a/web-ui/app/fonts/OpenSans-Italic.woff b/web-ui/app/fonts/OpenSans-Italic.woff
new file mode 100644
index 00000000..c5f6bac1
--- /dev/null
+++ b/web-ui/app/fonts/OpenSans-Italic.woff
Binary files differ
diff --git a/web-ui/app/fonts/OpenSans-Light.woff b/web-ui/app/fonts/OpenSans-Light.woff
new file mode 100644
index 00000000..eb601d70
--- /dev/null
+++ b/web-ui/app/fonts/OpenSans-Light.woff
Binary files differ
diff --git a/web-ui/app/fonts/OpenSans-Semibold.woff b/web-ui/app/fonts/OpenSans-Semibold.woff
new file mode 100644
index 00000000..56c44944
--- /dev/null
+++ b/web-ui/app/fonts/OpenSans-Semibold.woff
Binary files differ
diff --git a/web-ui/app/fonts/OpenSans-SemiboldItalic.woff b/web-ui/app/fonts/OpenSans-SemiboldItalic.woff
new file mode 100644
index 00000000..3a439fc3
--- /dev/null
+++ b/web-ui/app/fonts/OpenSans-SemiboldItalic.woff
Binary files differ
diff --git a/web-ui/app/fonts/OpenSans.woff b/web-ui/app/fonts/OpenSans.woff
new file mode 100644
index 00000000..77706fa6
--- /dev/null
+++ b/web-ui/app/fonts/OpenSans.woff
Binary files differ
diff --git a/web-ui/app/fonts/OpenSansLight-Italic.woff b/web-ui/app/fonts/OpenSansLight-Italic.woff
new file mode 100644
index 00000000..3f9f088f
--- /dev/null
+++ b/web-ui/app/fonts/OpenSansLight-Italic.woff
Binary files differ
diff --git a/web-ui/app/index.html b/web-ui/app/index.html
new file mode 100644
index 00000000..568cff8a
--- /dev/null
+++ b/web-ui/app/index.html
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+<title>Pixelated Mail</title>
+<meta name="description" content="">
+<meta name="viewport" content="width=device-width">
+<link href="bower_components/font-awesome/css/font-awesome.min.css" rel="stylesheet" type="text/css">
+<link href="css/opensans.css" rel="stylesheet" type="text/css">
+<link href="css/news-cycle.css" rel="stylesheet" type="text/css"/>
+<link rel="stylesheet" href="/css/main.css">
+</head>
+
+<body>
+
+<div class="off-canvas-wrap" data-offcanvas>
+ <header id="main">
+ <div id="logo" class="small-4 large-2 columns"><strong>Pixelated</strong> <i class="fa fa-angle-right"></i> Messages</div>
+ <div id="user-alerts"></div>
+ </header>
+
+ <div class="inner-wrap">
+ <div class="column collapsed-nav no-padding">
+ <a class="left-off-canvas-toggle" href="#"><i class="fa fa-navicon"></i></a>
+ <ul id="tags-shortcuts" class="shortcuts">
+ </ul>
+ <ul id="custom-tags-shortcuts" class="shortcuts">
+ <li>
+ <a class="left-off-canvas-toggle" href="#" title="View your tags"><i class="fa fa-tags"></i><div class="shortcut-label">Tags</div></a>
+ </li>
+ </ul>
+ </div>
+
+ <section id="left-pane" class="left-off-canvas-menu">
+ <nav id="tag-list"></nav>
+ </section>
+
+ <article id='middle-pane-container' class="small-5 medium-5 large-5 columns no-padding">
+ <section id="top-pane" class="small-12 large-12 no-padding">
+ <div id="compose-search-trigger">
+ <div id="compose" class="column small-12 large-4 no-padding">
+ <div id="compose-trigger"></div>
+ </div>
+ <div id="search-trigger" class="small-12 large-8 columns no-padding">
+ </div>
+ </div>
+ <ul id="list-actions"></ul>
+ </section>
+
+ <section id="middle-pane" class="small-9 medium-12 large-12 columns no-padding">
+ <ul id="mail-list">
+ </ul>
+ </section>
+ </article>
+
+ <section id="right-pane" class="small-7 medium-7 large-7 columns">
+ </section>
+
+ </div>
+</div>
+
+<!-- build:js app.min.js -->
+<script src="/bower_components/modernizr/modernizr.js"></script>
+<script src="/bower_components/lodash/dist/lodash.js"></script>
+<script src="/bower_components/jquery/dist/jquery.js"></script>
+<script src="/js/lib/highlightRegex.js"></script>
+<script src="/bower_components/handlebars/handlebars.min.js"></script>
+<script src="/bower_components/typeahead.js/dist/typeahead.bundle.min.js"></script>
+<script src="/bower_components/foundation/js/foundation.js" ></script>
+<script src="/bower_components/foundation/js/foundation/foundation.reveal.js" ></script>
+<script src="/bower_components/foundation/js/foundation/foundation.offcanvas.js"></script>
+<!-- endbuild-->
+
+<!-- remove-in-build --><script src="/bower_components/requirejs/require.js" data-main="js/main.js"></script><!-- end-remove-in-build -->
+
+
+<script>
+$(document).foundation();
+</script>
+
+
+</body>
+</html>
diff --git a/web-ui/app/js/dispatchers/left_pane_dispatcher.js b/web-ui/app/js/dispatchers/left_pane_dispatcher.js
new file mode 100644
index 00000000..8fd2b81d
--- /dev/null
+++ b/web-ui/app/js/dispatchers/left_pane_dispatcher.js
@@ -0,0 +1,51 @@
+define(
+ [
+ 'flight/lib/component',
+ 'page/router/url_params',
+ 'page/events'
+ ],
+
+ function(defineComponent, urlParams, events) {
+ 'use strict';
+
+ return defineComponent(leftPaneDispatcher);
+
+ function leftPaneDispatcher() {
+ var initialized = false;
+
+ this.refreshTagList = function () {
+ this.trigger(document, events.tags.want, { caller: this.$node });
+ };
+
+ this.loadTags = function (ev, data) {
+ this.trigger(document, events.ui.tagList.load, data);
+ };
+
+ this.selectTag = function (ev, data) {
+ var tag = (data && data.tag) || urlParams.getTag();
+ this.trigger(document, events.ui.tag.select, { tag: tag });
+ };
+
+ this.pushUrlState = function (ev, data) {
+ if (initialized) {
+ this.trigger(document, events.router.pushState, data);
+ }
+ initialized = true;
+
+ if (data.skipMailListRefresh) {
+ return;
+ }
+
+ this.trigger(document, events.ui.mails.fetchByTag, data);
+ };
+
+ this.after('initialize', function () {
+ this.on(this.$node, events.tags.received, this.loadTags);
+ this.on(document, events.dispatchers.tags.refreshTagList, this.refreshTagList);
+ this.on(document, events.ui.tags.loaded, this.selectTag);
+ this.on(document, events.ui.tag.selected, this.pushUrlState);
+ this.trigger(document, events.tags.want, { caller: this.$node } );
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/dispatchers/middle_pane_dispatcher.js b/web-ui/app/js/dispatchers/middle_pane_dispatcher.js
new file mode 100644
index 00000000..26c32235
--- /dev/null
+++ b/web-ui/app/js/dispatchers/middle_pane_dispatcher.js
@@ -0,0 +1,36 @@
+define(['flight/lib/component', 'page/events', 'helpers/triggering'], function(defineComponent, events, triggering) {
+ 'use strict';
+
+ return defineComponent(function() {
+ this.defaultAttrs({
+ middlePane: '#middle-pane'
+ });
+
+ this.refreshMailList = function (ev, data) {
+ this.trigger(document, events.ui.mails.fetchByTag, data);
+ };
+
+ this.cleanSelected = function(ev, data) {
+ this.trigger(document, events.ui.mails.cleanSelected);
+ };
+
+ this.resetScroll = function() {
+ this.select('middlePane').scrollTop(0);
+ };
+
+ this.updateMiddlePaneHeight = function() {
+ var vh = $(window).height();
+ var top = $("#main").outerHeight() + $("#top-pane").outerHeight();
+ this.select('middlePane').css({height: (vh - top) + 'px'});
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.dispatchers.middlePane.refreshMailList, this.refreshMailList);
+ this.on(document, events.dispatchers.middlePane.cleanSelected, this.cleanSelected);
+ this.on(document, events.dispatchers.middlePane.resetScroll, this.resetScroll);
+
+ this.updateMiddlePaneHeight();
+ $(window).on('resize', this.updateMiddlePaneHeight.bind(this));
+ });
+ });
+});
diff --git a/web-ui/app/js/dispatchers/right_pane_dispatcher.js b/web-ui/app/js/dispatchers/right_pane_dispatcher.js
new file mode 100644
index 00000000..3e62e581
--- /dev/null
+++ b/web-ui/app/js/dispatchers/right_pane_dispatcher.js
@@ -0,0 +1,93 @@
+/*global Smail */
+
+define(
+ [
+ 'flight/lib/component',
+ 'mail_view/ui/compose_box',
+ 'mail_view/ui/mail_view',
+ 'mail_view/ui/reply_section',
+ 'mail_view/ui/draft_box',
+ 'mail_view/ui/no_message_selected_pane',
+ 'page/events'
+ ],
+
+ function(defineComponent, ComposeBox, MailView, ReplySection, DraftBox, NoMessageSelectedPane, events) {
+ 'use strict';
+
+ return defineComponent(rightPaneDispatcher);
+
+ function rightPaneDispatcher() {
+ this.defaultAttrs({
+ rightPane: '#right-pane',
+ composeBox: 'compose-box',
+ mailView: 'mail-view',
+ noMessageSelectedPane: 'no-message-selected-pane',
+ replySection: 'reply-section',
+ draftBox: 'draft-box',
+ currentTag: ''
+ });
+
+ this.createAndAttach = function(newContainer) {
+ var stage = $('<div>', { id: newContainer });
+ this.select('rightPane').append(stage);
+ return stage;
+ };
+
+ this.reset = function (newContainer) {
+ this.trigger(document, events.dispatchers.rightPane.clear);
+ this.select('rightPane').empty();
+ var stage = this.createAndAttach(newContainer);
+ return stage;
+ };
+
+ this.openComposeBox = function() {
+ var stage = this.reset(this.attr.composeBox);
+ ComposeBox.attachTo(stage, {currentTag: this.attr.currentTag});
+ };
+
+ this.openMail = function(ev, data) {
+ var stage = this.reset(this.attr.mailView);
+ MailView.attachTo(stage, data);
+
+ var replySectionContainer = this.createAndAttach(this.attr.replySection);
+ ReplySection.attachTo(replySectionContainer, { ident: data.ident });
+ };
+
+ this.initializeNoMessageSelectedPane = function () {
+ var stage = this.reset(this.attr.noMessageSelectedPane);
+ NoMessageSelectedPane.attachTo(stage);
+ this.trigger(document, events.dispatchers.middlePane.cleanSelected);
+ };
+
+ this.openNoMessageSelectedPane = function(ev, data) {
+ this.initializeNoMessageSelectedPane();
+
+ this.trigger(document, events.router.pushState, { tag: this.attr.currentTag, isDisplayNoMessageSelected: true });
+ };
+
+ this.openDraft = function (ev, data) {
+ var stage = this.reset(this.attr.draftBox);
+ DraftBox.attachTo(stage, { mailIdent: data.ident, currentTag: this.attr.currentTag });
+ };
+
+ this.selectTag = function(ev, data) {
+ this.trigger(document, events.ui.tags.loaded, {tag: data.tag});
+ };
+
+ this.saveTag = function(ev, data) {
+ this.attr.currentTag = data.tag;
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.dispatchers.rightPane.openComposeBox, this.openComposeBox);
+ this.on(document, events.dispatchers.rightPane.openDraft, this.openDraft);
+ this.on(document, events.ui.mail.open, this.openMail);
+ this.on(document, events.dispatchers.rightPane.openNoMessageSelected, this.openNoMessageSelectedPane);
+ this.on(document, events.dispatchers.rightPane.selectTag, this.selectTag);
+ this.on(document, events.ui.tag.selected, this.saveTag);
+ this.on(document, events.dispatchers.rightPane.openNoMessageSelectedWithoutPushState, this.initializeNoMessageSelectedPane);
+ this.initializeNoMessageSelectedPane();
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/foundation/off_canvas.js b/web-ui/app/js/foundation/off_canvas.js
new file mode 100644
index 00000000..7dfd6f34
--- /dev/null
+++ b/web-ui/app/js/foundation/off_canvas.js
@@ -0,0 +1,21 @@
+define(['flight/lib/component', 'page/events'], function (defineComponent, events) {
+
+ return defineComponent(function() {
+
+ this.toggleSlider = function (){
+ $('.off-canvas-wrap').foundation('offcanvas', 'toggle', 'move-right');
+ };
+
+ this.closeSlider = function (){
+ if ($('.off-canvas-wrap').attr('class').indexOf('move-right') > -1) {
+ $('.off-canvas-wrap').foundation('offcanvas', 'toggle', 'move-right');
+ }
+ };
+
+ this.after('initialize', function () {
+ this.on($('.left-off-canvas-toggle'), 'click', this.toggleSlider);
+ this.on($('#middle-pane-container'), 'click', this.closeSlider);
+ this.on($('#right-pane'), 'click', this.closeSlider);
+ });
+ });
+});
diff --git a/web-ui/app/js/helpers/contenttype.js b/web-ui/app/js/helpers/contenttype.js
new file mode 100644
index 00000000..81519452
--- /dev/null
+++ b/web-ui/app/js/helpers/contenttype.js
@@ -0,0 +1,164 @@
+define([], function () {
+ var exports = {};
+
+ // Licence: PUBLIC DOMAIN <http://unlicense.org/>
+ // Author: Austin Wright <http://github.com/Acubed>
+
+ function MediaType(s, p){
+ this.type = '';
+ this.params = {};
+ if(typeof s=='string'){
+ var c = splitQuotedString(s);
+ this.type = c.shift();
+ for(var i=0; i<c.length; i++){
+ this.parseParameter(c[i]);
+ }
+ }else if(s instanceof MediaType){
+ this.type = s.type;
+ this.q = s.q;
+ for(var n in s.params) this.params[n]=s.params[n];
+ }
+ if(typeof p=='string'){
+ var c = splitQuotedString(p);
+ for(var i=0; i<c.length; i++){
+ this.parseParameter(c[i]);
+ }
+ }else if(typeof p=='object'){
+ for(var n in p) this.params[n]=p[n];
+ }
+ }
+ MediaType.prototype.parseParameter = function parseParameter(s){
+ var param = s.split('=',1);
+ var name = param[0].trim();
+ var value = s.substr(param[0].length+1).trim();
+ if(!value || !name) return;
+ if(name=='q' && this.q===undefined){
+ this.q=parseFloat(value);
+ }else{
+ if(value[0]=='"' && value[value.length-1]=='"'){
+ value = value.substr(1, value.length-2);
+ value = value.replace(/\\(.)/g, function(a,b){return b;});
+ }
+ this.params[name]=value;
+ }
+ }
+ MediaType.prototype.toString = function toString(){
+ var str = this.type + ';q='+this.q;
+ for(var n in this.params){
+ str += ';'+n+'=';
+ if(this.params[n].match(/["=;<>\[\]\(\) ,\-]/)){
+ str += '"' + this.params[n].replace(/["\\]/g, function(a){return '\\'+a;}) + '"';
+ }else{
+ str += this.params[n];
+ }
+ }
+ return str;
+ }
+ exports.MediaType = MediaType;
+
+ // Split a string by character, but ignore quoted parts and backslash-escaped characters
+ function splitQuotedString(str, delim, quote){
+ delim = delim || ';';
+ quote = quote || '"';
+ var res = [];
+ var start = 0;
+ var offset = 0;
+ function findNextChar(v, c, i, a){
+ var p = str.indexOf(c, offset+1);
+ return (p<0)?v:Math.min(p,v);
+ }
+ while(offset>=0){
+ offset = [delim,quote].reduce(findNextChar, 1/0);
+ if(offset===1/0) break;
+ switch(str[offset]){
+ case quote:
+ // Skip to end of quoted string
+ while(1){
+ offset=str.indexOf(quote, offset+1);
+ if(offset<0) break;
+ if(str[offset-1]==='\\') continue;
+ break;
+ }
+ continue;
+ case delim:
+ res.push(str.substr(start, offset-start).trim());
+ start = ++offset;
+ break;
+ }
+ }
+ res.push(str.substr(start).trim());
+ return res;
+ }
+ exports.splitQuotedString = splitQuotedString;
+
+ // Split a list of content types found in an Accept header
+ // Maybe use it like: splitContentTypes(request.headers.accept).map(parseMedia)
+ function splitContentTypes(str){
+ return splitQuotedString(str, ',');
+ }
+ exports.splitContentTypes = splitContentTypes;
+
+ function parseMedia(str){
+ var o = new MediaType(str);
+ if(o.q===undefined) o.q=1;
+ return o;
+ }
+ exports.parseMedia = parseMedia;
+
+ // Pick an ideal representation to send given a list of representations to choose from and the client-preferred list
+ function select(reps, accept){
+ var cr = {q:0};
+ var ca = {q:0};
+ var cq = 0;
+ for(var i=0; i<reps.length; i++){
+ var r = reps[i];
+ var rq = r.q || 1;
+ for(var j=0; j<accept.length; j++){
+ var a=accept[j];
+ var aq = a.q || 1;
+ var cmp = mediaCmp(a, r);
+ if(cmp!==null && cmp>=0){
+ if(aq*rq>cq){
+ ca = a;
+ cr = r;
+ cq = ca.q*cr.q;
+ if(cq===1 && cr.type) return cr;
+ }
+ }
+ }
+ }
+ return cr.type&&cr;
+ }
+ exports.select = select;
+
+ // Determine if one media type is a subset of another
+ // If a is a superset of b (b is smaller than a), return 1
+ // If b is a superset of a, return -1
+ // If they are the exact same, return 0
+ // If they are disjoint, return null
+ function mediaCmp(a, b){
+ if(a.type==='*/*' && b.type!=='*/*') return 1;
+ else if(a.type!=='*/*' && b.type==='*/*') return -1;
+ var ac = (a.type||'').split('/');
+ var bc = (b.type||'').split('/');
+ if(ac[0]=='*' && bc[0]!='*') return 1;
+ if(ac[0]!='*' && bc[0]=='*') return -1;
+ if(a.type!==b.type) return null;
+ var ap = a.params || {};
+ var bp = b.params || {};
+ var ak = Object.keys(ap);
+ var bk = Object.keys(bp);
+ if(ak.length < bk.length) return 1;
+ if(ak.length > bk.length) return -1;
+ var k = ak.concat(bk).sort();
+ var dir = 0;
+ for(var n in ap){
+ if(ap[n] && !bp[n]){ if(dir<0) return null; else dir=1; }
+ if(!ap[n] && bp[n]){ if(dir>0) return null; else dir=-1; }
+ }
+ return dir;
+ }
+ exports.mediaCmp = mediaCmp;
+
+ return exports;
+});
diff --git a/web-ui/app/js/helpers/iterator.js b/web-ui/app/js/helpers/iterator.js
new file mode 100644
index 00000000..9d8358a7
--- /dev/null
+++ b/web-ui/app/js/helpers/iterator.js
@@ -0,0 +1,43 @@
+define(function () {
+
+ return Iterator;
+
+ function Iterator(elems, startingIndex) {
+
+ this.index = startingIndex || 0;
+ this.elems = elems;
+
+ this.hasPrevious = function () {
+ return this.index != 0;
+ };
+
+ this.hasNext = function () {
+ return this.index < this.elems.length - 1;
+ };
+
+ this.previous = function () {
+ return this.elems[--this.index];
+ };
+
+ this.next = function () {
+ return this.elems[++this.index];
+ };
+
+ this.current = function () {
+ return this.elems[this.index];
+ };
+
+ this.hasElements = function () {
+ return this.elems.length > 0;
+ };
+
+ this.removeCurrent = function () {
+ var removed = this.current(),
+ toRemove = this.index;
+
+ !this.hasNext() && this.index--;
+ this.elems.remove(toRemove);
+ return removed;
+ };
+ }
+}); \ No newline at end of file
diff --git a/web-ui/app/js/helpers/triggering.js b/web-ui/app/js/helpers/triggering.js
new file mode 100644
index 00000000..7c8ae136
--- /dev/null
+++ b/web-ui/app/js/helpers/triggering.js
@@ -0,0 +1,13 @@
+define([], function() {
+ 'use strict';
+
+ return function(that, event, data, on) {
+ return function() {
+ if(on) {
+ that.trigger(on, event, data || {});
+ } else {
+ that.trigger(event, data || {});
+ }
+ };
+ };
+});
diff --git a/web-ui/app/js/helpers/view_helper.js b/web-ui/app/js/helpers/view_helper.js
new file mode 100644
index 00000000..3fa9edc1
--- /dev/null
+++ b/web-ui/app/js/helpers/view_helper.js
@@ -0,0 +1,145 @@
+define(
+ [
+ 'helpers/contenttype',
+ 'lib/html_whitelister',
+ 'views/i18n',
+ 'quoted-printable/quoted-printable'
+ ],
+ function(contentType, htmlWhitelister, i18n_lib, quotedPrintable) {
+ 'use strict';
+
+ function formatStatusClasses(ss) {
+ return _.map(ss, function(s) {
+ return 'status-' + s;
+ }).join(' ');
+ }
+
+ function addParagraphsToPlainText(plainTextBodyPart) {
+ return _.map(plainTextBodyPart.split('\n'), function (paragraph) {
+ return '<p>' + paragraph + '</p>';
+ }).join('');
+ }
+
+ function isQuotedPrintableBodyPart (bodyPart) {
+ return bodyPart.headers['Content-Transfer-Encoding'] && bodyPart.headers['Content-Transfer-Encoding'] === 'quoted-printable';
+ }
+
+ function getHtmlContentType (mail) {
+ return _.find(mail.availableBodyPartsContentType(), function (contentType) {
+ return contentType.indexOf('text/html') >= 0;
+ });
+ }
+
+ function getSanitizedAndDecodedMailBody (bodyPart) {
+ var body;
+
+ if (isQuotedPrintableBodyPart(bodyPart)) {
+ body = quotedPrintable.decode(bodyPart.body);
+ } else {
+ body = bodyPart.body;
+ }
+
+ return htmlWhitelister.sanitize(body, htmlWhitelister.tagPolicy);
+ }
+
+ function formatMailBody (mail) {
+ if (mail.isMailMultipartAlternative()) {
+ var htmlContentType;
+
+ htmlContentType = getHtmlContentType(mail);
+
+ if (htmlContentType) {
+ return $(getSanitizedAndDecodedMailBody(mail.getMailPartByContentType(htmlContentType)));
+ }
+
+ return $(addParagraphsToPlainText(mail.getMailMultiParts[0]));
+ }
+
+ return $(addParagraphsToPlainText(mail.body));
+
+ /*
+ var body;
+ // probably parse MIME parts and ugliness here
+ // content_type: "multipart/alternative; boundary="----=_Part_1115_17865397.1370312509342""
+ var mediaType = new contentType.MediaType(mail.header.content_type);
+ if(mediaType.type === 'multipart/alternative') {
+ var parsedBodyParts = getMailMultiParts(mail.body, mediaType);
+ var selectedBodyPart = getHtmlMailPart(parsedBodyParts) || getPlainTextMailPart(parsedBodyParts) || parsedBodyParts[0];
+ body = selectedBodyPart.body;
+
+ if (isQuotedPrintableBodyPart(selectedBodyPart)) {
+ body = quotedPrintable.decode(body);
+ }
+ } else {
+ body = addParagraphsToPlainText(mail.body);
+ }
+ return $(htmlWhitelister.sanitize(body, htmlWhitelister.tagPolicy));
+ */
+ }
+
+ function moveCaretToEnd(el) {
+ if (typeof el.selectionStart == "number") {
+ el.selectionStart = el.selectionEnd = el.value.length;
+ } else if (typeof el.createTextRange != "undefined") {
+ el.focus();
+ var range = el.createTextRange();
+ range.collapse(false);
+ range.select();
+ }
+ }
+
+ function fixedSizeNumber(num, size) {
+ var res = num.toString();
+ while(res.length < size) {
+ res = "0" + res;
+ }
+ return res;
+ }
+
+ function getFormattedDate(date){
+ var today = createTodayDate();
+ if (date.getTime() > today.getTime()) {
+ return fixedSizeNumber(date.getHours(), 2) + ":" + fixedSizeNumber(date.getMinutes(), 2);
+ } else {
+ return "" + date.getFullYear() + "-" + fixedSizeNumber(date.getMonth() + 1, 2) + "-" + fixedSizeNumber(date.getDate(), 2);
+ }
+ }
+
+ function createTodayDate() {
+ var today = new Date();
+ today.setHours(0);
+ today.setMinutes(0);
+ today.setSeconds(0);
+ return today;
+ }
+
+ function moveCaretToEndOfText() {
+ var self = this;
+
+ moveCaretToEnd(self);
+ window.setTimeout(function() {
+ moveCaretToEnd(self);
+ }, 1);
+ }
+
+ function quoteMail(mail) {
+ var quotedLines = _.map(mail.body.split('\n'), function (line) {
+ return '> ' + line;
+ });
+
+ return '\n\n' + quotedLines.join('\n');
+ }
+
+ function i18n(text) {
+ return i18n_lib.get(text);
+ }
+
+ return {
+ formatStatusClasses: formatStatusClasses,
+ formatMailBody: formatMailBody,
+ moveCaretToEndOfText: moveCaretToEndOfText,
+ getFormattedDate: getFormattedDate,
+ quoteMail: quoteMail,
+ i18n: i18n
+ };
+});
diff --git a/web-ui/app/js/lib/highlightRegex.js b/web-ui/app/js/lib/highlightRegex.js
new file mode 100644
index 00000000..17caaa23
--- /dev/null
+++ b/web-ui/app/js/lib/highlightRegex.js
@@ -0,0 +1,127 @@
+/*
+ * jQuery Highlight Regex Plugin v0.1.2
+ *
+ * Based on highlight v3 by Johann Burkard
+ * http://johannburkard.de/blog/programming/javascript/highlight-javascript-text-higlighting-jquery-plugin.html
+ *
+ * (c) 2009-13 Jacob Rothstein
+ * MIT license
+ */
+
+;(function( $ ) {
+
+
+
+ var normalize = function( node ) {
+ if ( ! ( node && node.childNodes )) return
+
+ var children = $.makeArray( node.childNodes )
+ , prevTextNode = null
+
+ $.each( children, function( i, child ) {
+ if ( child.nodeType === 3 ) {
+ if ( child.nodeValue === "" ) {
+
+ node.removeChild( child )
+
+ } else if ( prevTextNode !== null ) {
+
+ prevTextNode.nodeValue += child.nodeValue;
+ node.removeChild( child )
+
+ } else {
+
+ prevTextNode = child
+
+ }
+ } else {
+ prevTextNode = null
+
+ if ( child.childNodes ) {
+ normalize( child )
+ }
+ }
+ })
+ }
+
+
+
+
+ $.fn.highlightRegex = function( regex, options ) {
+
+ if ( typeof regex === 'object' && !(regex.constructor.name == 'RegExp' || regex instanceof RegExp ) ) {
+ options = regex
+ regex = undefined
+ }
+
+ if ( typeof options === 'undefined' ) options = {}
+
+ options.className = options.className || 'highlight'
+ options.tagType = options.tagType || 'span'
+ options.attrs = options.attrs || {}
+
+ if ( typeof regex === 'undefined' || regex.source === '' ) {
+
+ $( this ).find( options.tagType + '.' + options.className ).each( function() {
+
+ $( this ).replaceWith( $( this ).text() )
+
+ normalize( $( this ).parent().get( 0 ))
+
+ })
+
+ } else {
+
+ $( this ).each( function() {
+
+ var elt = $( this ).get( 0 )
+
+ normalize( elt )
+
+ $.each( $.makeArray( elt.childNodes ), function( i, searchnode ) {
+
+ var spannode, middlebit, middleclone, pos, match, parent
+
+ normalize( searchnode )
+
+ if ( searchnode.nodeType == 3 ) {
+
+ // don't re-highlight the same node over and over
+ if ( $(searchnode).parent(options.tagType + '.' + options.className).length ) {
+ return;
+ }
+
+ while ( searchnode.data &&
+ ( pos = searchnode.data.search( regex )) >= 0 ) {
+
+ match = searchnode.data.slice( pos ).match( regex )[ 0 ]
+
+ if ( match.length > 0 ) {
+
+ spannode = document.createElement( options.tagType )
+ spannode.className = options.className
+ $(spannode).attr(options.attrs)
+
+ parent = searchnode.parentNode
+ middlebit = searchnode.splitText( pos )
+ searchnode = middlebit.splitText( match.length )
+ middleclone = middlebit.cloneNode( true )
+
+ spannode.appendChild( middleclone )
+ parent.replaceChild( spannode, middlebit )
+
+ } else break
+ }
+
+ } else {
+
+ $( searchnode ).highlightRegex( regex, options )
+
+ }
+ })
+ })
+ }
+
+ return $( this )
+ }
+})( jQuery );
diff --git a/web-ui/app/js/lib/html-sanitizer.js b/web-ui/app/js/lib/html-sanitizer.js
new file mode 100644
index 00000000..80fb0041
--- /dev/null
+++ b/web-ui/app/js/lib/html-sanitizer.js
@@ -0,0 +1,1064 @@
+// Copyright (C) 2006 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * @fileoverview
+ * An HTML sanitizer that can satisfy a variety of security policies.
+ *
+ * <p>
+ * The HTML sanitizer is built around a SAX parser and HTML element and
+ * attributes schemas.
+ *
+ * If the cssparser is loaded, inline styles are sanitized using the
+ * css property and value schemas. Else they are remove during
+ * sanitization.
+ *
+ * If it exists, uses parseCssDeclarations, sanitizeCssProperty, cssSchema
+ *
+ * @author mikesamuel@gmail.com
+ * @author jasvir@gmail.com
+ * \@requires html4, URI
+ * \@overrides window
+ * \@provides html, html_sanitize
+ */
+
+// The Turkish i seems to be a non-issue, but abort in case it is.
+if ('I'.toLowerCase() !== 'i') { throw 'I/i problem'; }
+
+/**
+ * \@namespace
+ */
+define(['lib/html4-defs'], function (html4) {
+var html = (function(html4) {
+
+ // For closure compiler
+ var parseCssDeclarations, sanitizeCssProperty, cssSchema;
+ if ('undefined' !== typeof window) {
+ parseCssDeclarations = window['parseCssDeclarations'];
+ sanitizeCssProperty = window['sanitizeCssProperty'];
+ cssSchema = window['cssSchema'];
+ }
+
+ // The keys of this object must be 'quoted' or JSCompiler will mangle them!
+ // This is a partial list -- lookupEntity() uses the host browser's parser
+ // (when available) to implement full entity lookup.
+ // Note that entities are in general case-sensitive; the uppercase ones are
+ // explicitly defined by HTML5 (presumably as compatibility).
+ var ENTITIES = {
+ 'lt': '<',
+ 'LT': '<',
+ 'gt': '>',
+ 'GT': '>',
+ 'amp': '&',
+ 'AMP': '&',
+ 'quot': '"',
+ 'apos': '\'',
+ 'nbsp': '\240'
+ };
+
+ // Patterns for types of entity/character reference names.
+ var decimalEscapeRe = /^#(\d+)$/;
+ var hexEscapeRe = /^#x([0-9A-Fa-f]+)$/;
+ // contains every entity per http://www.w3.org/TR/2011/WD-html5-20110113/named-character-references.html
+ var safeEntityNameRe = /^[A-Za-z][A-za-z0-9]+$/;
+ // Used as a hook to invoke the browser's entity parsing. <textarea> is used
+ // because its content is parsed for entities but not tags.
+ // TODO(kpreid): This retrieval is a kludge and leads to silent loss of
+ // functionality if the document isn't available.
+ var entityLookupElement =
+ ('undefined' !== typeof window && window['document'])
+ ? window['document'].createElement('textarea') : null;
+ /**
+ * Decodes an HTML entity.
+ *
+ * {\@updoc
+ * $ lookupEntity('lt')
+ * # '<'
+ * $ lookupEntity('GT')
+ * # '>'
+ * $ lookupEntity('amp')
+ * # '&'
+ * $ lookupEntity('nbsp')
+ * # '\xA0'
+ * $ lookupEntity('apos')
+ * # "'"
+ * $ lookupEntity('quot')
+ * # '"'
+ * $ lookupEntity('#xa')
+ * # '\n'
+ * $ lookupEntity('#10')
+ * # '\n'
+ * $ lookupEntity('#x0a')
+ * # '\n'
+ * $ lookupEntity('#010')
+ * # '\n'
+ * $ lookupEntity('#x00A')
+ * # '\n'
+ * $ lookupEntity('Pi') // Known failure
+ * # '\u03A0'
+ * $ lookupEntity('pi') // Known failure
+ * # '\u03C0'
+ * }
+ *
+ * @param {string} name the content between the '&' and the ';'.
+ * @return {string} a single unicode code-point as a string.
+ */
+ function lookupEntity(name) {
+ // TODO: entity lookup as specified by HTML5 actually depends on the
+ // presence of the ";".
+ if (ENTITIES.hasOwnProperty(name)) { return ENTITIES[name]; }
+ var m = name.match(decimalEscapeRe);
+ if (m) {
+ return String.fromCharCode(parseInt(m[1], 10));
+ } else if (!!(m = name.match(hexEscapeRe))) {
+ return String.fromCharCode(parseInt(m[1], 16));
+ } else if (entityLookupElement && safeEntityNameRe.test(name)) {
+ entityLookupElement.innerHTML = '&' + name + ';';
+ var text = entityLookupElement.textContent;
+ ENTITIES[name] = text;
+ return text;
+ } else {
+ return '&' + name + ';';
+ }
+ }
+
+ function decodeOneEntity(_, name) {
+ return lookupEntity(name);
+ }
+
+ var nulRe = /\0/g;
+ function stripNULs(s) {
+ return s.replace(nulRe, '');
+ }
+
+ var ENTITY_RE_1 = /&(#[0-9]+|#[xX][0-9A-Fa-f]+|\w+);/g;
+ var ENTITY_RE_2 = /^(#[0-9]+|#[xX][0-9A-Fa-f]+|\w+);/;
+ /**
+ * The plain text of a chunk of HTML CDATA which possibly containing.
+ *
+ * {\@updoc
+ * $ unescapeEntities('')
+ * # ''
+ * $ unescapeEntities('hello World!')
+ * # 'hello World!'
+ * $ unescapeEntities('1 &lt; 2 &amp;&AMP; 4 &gt; 3&#10;')
+ * # '1 < 2 && 4 > 3\n'
+ * $ unescapeEntities('&lt;&lt <- unfinished entity&gt;')
+ * # '<&lt <- unfinished entity>'
+ * $ unescapeEntities('/foo?bar=baz&copy=true') // & often unescaped in URLS
+ * # '/foo?bar=baz&copy=true'
+ * $ unescapeEntities('pi=&pi;&#x3c0;, Pi=&Pi;\u03A0') // FIXME: known failure
+ * # 'pi=\u03C0\u03c0, Pi=\u03A0\u03A0'
+ * }
+ *
+ * @param {string} s a chunk of HTML CDATA. It must not start or end inside
+ * an HTML entity.
+ */
+ function unescapeEntities(s) {
+ return s.replace(ENTITY_RE_1, decodeOneEntity);
+ }
+
+ var ampRe = /&/g;
+ var looseAmpRe = /&([^a-z#]|#(?:[^0-9x]|x(?:[^0-9a-f]|$)|$)|$)/gi;
+ var ltRe = /[<]/g;
+ var gtRe = />/g;
+ var quotRe = /\"/g;
+
+ /**
+ * Escapes HTML special characters in attribute values.
+ *
+ * {\@updoc
+ * $ escapeAttrib('')
+ * # ''
+ * $ escapeAttrib('"<<&==&>>"') // Do not just escape the first occurrence.
+ * # '&#34;&lt;&lt;&amp;&#61;&#61;&amp;&gt;&gt;&#34;'
+ * $ escapeAttrib('Hello <World>!')
+ * # 'Hello &lt;World&gt;!'
+ * }
+ */
+ function escapeAttrib(s) {
+ return ('' + s).replace(ampRe, '&amp;').replace(ltRe, '&lt;')
+ .replace(gtRe, '&gt;').replace(quotRe, '&#34;');
+ }
+
+ /**
+ * Escape entities in RCDATA that can be escaped without changing the meaning.
+ * {\@updoc
+ * $ normalizeRCData('1 < 2 &&amp; 3 > 4 &amp;& 5 &lt; 7&8')
+ * # '1 &lt; 2 &amp;&amp; 3 &gt; 4 &amp;&amp; 5 &lt; 7&amp;8'
+ * }
+ */
+ function normalizeRCData(rcdata) {
+ return rcdata
+ .replace(looseAmpRe, '&amp;$1')
+ .replace(ltRe, '&lt;')
+ .replace(gtRe, '&gt;');
+ }
+
+ // TODO(felix8a): validate sanitizer regexs against the HTML5 grammar at
+ // http://www.whatwg.org/specs/web-apps/current-work/multipage/syntax.html
+ // http://www.whatwg.org/specs/web-apps/current-work/multipage/parsing.html
+ // http://www.whatwg.org/specs/web-apps/current-work/multipage/tokenization.html
+ // http://www.whatwg.org/specs/web-apps/current-work/multipage/tree-construction.html
+
+ // We initially split input so that potentially meaningful characters
+ // like '<' and '>' are separate tokens, using a fast dumb process that
+ // ignores quoting. Then we walk that token stream, and when we see a
+ // '<' that's the start of a tag, we use ATTR_RE to extract tag
+ // attributes from the next token. That token will never have a '>'
+ // character. However, it might have an unbalanced quote character, and
+ // when we see that, we combine additional tokens to balance the quote.
+
+ var ATTR_RE = new RegExp(
+ '^\\s*' +
+ '([-.:\\w]+)' + // 1 = Attribute name
+ '(?:' + (
+ '\\s*(=)\\s*' + // 2 = Is there a value?
+ '(' + ( // 3 = Attribute value
+ // TODO(felix8a): maybe use backref to match quotes
+ '(\")[^\"]*(\"|$)' + // 4, 5 = Double-quoted string
+ '|' +
+ '(\')[^\']*(\'|$)' + // 6, 7 = Single-quoted string
+ '|' +
+ // Positive lookahead to prevent interpretation of
+ // <foo a= b=c> as <foo a='b=c'>
+ // TODO(felix8a): might be able to drop this case
+ '(?=[a-z][-\\w]*\\s*=)' +
+ '|' +
+ // Unquoted value that isn't an attribute name
+ // (since we didn't match the positive lookahead above)
+ '[^\"\'\\s]*' ) +
+ ')' ) +
+ ')?',
+ 'i');
+
+ // false on IE<=8, true on most other browsers
+ var splitWillCapture = ('a,b'.split(/(,)/).length === 3);
+
+ // bitmask for tags with special parsing, like <script> and <textarea>
+ var EFLAGS_TEXT = html4.eflags['CDATA'] | html4.eflags['RCDATA'];
+
+ /**
+ * Given a SAX-like event handler, produce a function that feeds those
+ * events and a parameter to the event handler.
+ *
+ * The event handler has the form:{@code
+ * {
+ * // Name is an upper-case HTML tag name. Attribs is an array of
+ * // alternating upper-case attribute names, and attribute values. The
+ * // attribs array is reused by the parser. Param is the value passed to
+ * // the saxParser.
+ * startTag: function (name, attribs, param) { ... },
+ * endTag: function (name, param) { ... },
+ * pcdata: function (text, param) { ... },
+ * rcdata: function (text, param) { ... },
+ * cdata: function (text, param) { ... },
+ * startDoc: function (param) { ... },
+ * endDoc: function (param) { ... }
+ * }}
+ *
+ * @param {Object} handler a record containing event handlers.
+ * @return {function(string, Object)} A function that takes a chunk of HTML
+ * and a parameter. The parameter is passed on to the handler methods.
+ */
+ function makeSaxParser(handler) {
+ // Accept quoted or unquoted keys (Closure compat)
+ var hcopy = {
+ cdata: handler.cdata || handler['cdata'],
+ comment: handler.comment || handler['comment'],
+ endDoc: handler.endDoc || handler['endDoc'],
+ endTag: handler.endTag || handler['endTag'],
+ pcdata: handler.pcdata || handler['pcdata'],
+ rcdata: handler.rcdata || handler['rcdata'],
+ startDoc: handler.startDoc || handler['startDoc'],
+ startTag: handler.startTag || handler['startTag']
+ };
+ return function(htmlText, param) {
+ return parse(htmlText, hcopy, param);
+ };
+ }
+
+ // Parsing strategy is to split input into parts that might be lexically
+ // meaningful (every ">" becomes a separate part), and then recombine
+ // parts if we discover they're in a different context.
+
+ // TODO(felix8a): Significant performance regressions from -legacy,
+ // tested on
+ // Chrome 18.0
+ // Firefox 11.0
+ // IE 6, 7, 8, 9
+ // Opera 11.61
+ // Safari 5.1.3
+ // Many of these are unusual patterns that are linearly slower and still
+ // pretty fast (eg 1ms to 5ms), so not necessarily worth fixing.
+
+ // TODO(felix8a): "<script> && && && ... <\/script>" is slower on all
+ // browsers. The hotspot is htmlSplit.
+
+ // TODO(felix8a): "<p title='>>>>...'><\/p>" is slower on all browsers.
+ // This is partly htmlSplit, but the hotspot is parseTagAndAttrs.
+
+ // TODO(felix8a): "<a><\/a><a><\/a>..." is slower on IE9.
+ // "<a>1<\/a><a>1<\/a>..." is faster, "<a><\/a>2<a><\/a>2..." is faster.
+
+ // TODO(felix8a): "<p<p<p..." is slower on IE[6-8]
+
+ var continuationMarker = {};
+ function parse(htmlText, handler, param) {
+ var m, p, tagName;
+ var parts = htmlSplit(htmlText);
+ var state = {
+ noMoreGT: false,
+ noMoreEndComments: false
+ };
+ parseCPS(handler, parts, 0, state, param);
+ }
+
+ function continuationMaker(h, parts, initial, state, param) {
+ return function () {
+ parseCPS(h, parts, initial, state, param);
+ };
+ }
+
+ function parseCPS(h, parts, initial, state, param) {
+ try {
+ if (h.startDoc && initial == 0) { h.startDoc(param); }
+ var m, p, tagName;
+ for (var pos = initial, end = parts.length; pos < end;) {
+ var current = parts[pos++];
+ var next = parts[pos];
+ switch (current) {
+ case '&':
+ if (ENTITY_RE_2.test(next)) {
+ if (h.pcdata) {
+ h.pcdata('&' + next, param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ pos++;
+ } else {
+ if (h.pcdata) { h.pcdata("&amp;", param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ }
+ break;
+ case '<\/':
+ if ((m = /^([-\w:]+)[^\'\"]*/.exec(next))) {
+ if (m[0].length === next.length && parts[pos + 1] === '>') {
+ // fast case, no attribute parsing needed
+ pos += 2;
+ tagName = m[1].toLowerCase();
+ if (h.endTag) {
+ h.endTag(tagName, param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ } else {
+ // slow case, need to parse attributes
+ // TODO(felix8a): do we really care about misparsing this?
+ pos = parseEndTag(
+ parts, pos, h, param, continuationMarker, state);
+ }
+ } else {
+ if (h.pcdata) {
+ h.pcdata('&lt;/', param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ }
+ break;
+ case '<':
+ if (m = /^([-\w:]+)\s*\/?/.exec(next)) {
+ if (m[0].length === next.length && parts[pos + 1] === '>') {
+ // fast case, no attribute parsing needed
+ pos += 2;
+ tagName = m[1].toLowerCase();
+ if (h.startTag) {
+ h.startTag(tagName, [], param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ // tags like <script> and <textarea> have special parsing
+ var eflags = html4.ELEMENTS[tagName];
+ if (eflags & EFLAGS_TEXT) {
+ var tag = { name: tagName, next: pos, eflags: eflags };
+ pos = parseText(
+ parts, tag, h, param, continuationMarker, state);
+ }
+ } else {
+ // slow case, need to parse attributes
+ pos = parseStartTag(
+ parts, pos, h, param, continuationMarker, state);
+ }
+ } else {
+ if (h.pcdata) {
+ h.pcdata('&lt;', param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ }
+ break;
+ case '<\!--':
+ // The pathological case is n copies of '<\!--' without '-->', and
+ // repeated failure to find '-->' is quadratic. We avoid that by
+ // remembering when search for '-->' fails.
+ if (!state.noMoreEndComments) {
+ // A comment <\!--x--> is split into three tokens:
+ // '<\!--', 'x--', '>'
+ // We want to find the next '>' token that has a preceding '--'.
+ // pos is at the 'x--'.
+ for (p = pos + 1; p < end; p++) {
+ if (parts[p] === '>' && /--$/.test(parts[p - 1])) { break; }
+ }
+ if (p < end) {
+ if (h.comment) {
+ var comment = parts.slice(pos, p).join('');
+ h.comment(
+ comment.substr(0, comment.length - 2), param,
+ continuationMarker,
+ continuationMaker(h, parts, p + 1, state, param));
+ }
+ pos = p + 1;
+ } else {
+ state.noMoreEndComments = true;
+ }
+ }
+ if (state.noMoreEndComments) {
+ if (h.pcdata) {
+ h.pcdata('&lt;!--', param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ }
+ break;
+ case '<\!':
+ if (!/^\w/.test(next)) {
+ if (h.pcdata) {
+ h.pcdata('&lt;!', param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ } else {
+ // similar to noMoreEndComment logic
+ if (!state.noMoreGT) {
+ for (p = pos + 1; p < end; p++) {
+ if (parts[p] === '>') { break; }
+ }
+ if (p < end) {
+ pos = p + 1;
+ } else {
+ state.noMoreGT = true;
+ }
+ }
+ if (state.noMoreGT) {
+ if (h.pcdata) {
+ h.pcdata('&lt;!', param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ }
+ }
+ break;
+ case '<?':
+ // similar to noMoreEndComment logic
+ if (!state.noMoreGT) {
+ for (p = pos + 1; p < end; p++) {
+ if (parts[p] === '>') { break; }
+ }
+ if (p < end) {
+ pos = p + 1;
+ } else {
+ state.noMoreGT = true;
+ }
+ }
+ if (state.noMoreGT) {
+ if (h.pcdata) {
+ h.pcdata('&lt;?', param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ }
+ break;
+ case '>':
+ if (h.pcdata) {
+ h.pcdata("&gt;", param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ break;
+ case '':
+ break;
+ default:
+ if (h.pcdata) {
+ h.pcdata(current, param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ break;
+ }
+ }
+ if (h.endDoc) { h.endDoc(param); }
+ } catch (e) {
+ if (e !== continuationMarker) { throw e; }
+ }
+ }
+
+ // Split str into parts for the html parser.
+ function htmlSplit(str) {
+ // can't hoist this out of the function because of the re.exec loop.
+ var re = /(<\/|<\!--|<[!?]|[&<>])/g;
+ str += '';
+ if (splitWillCapture) {
+ return str.split(re);
+ } else {
+ var parts = [];
+ var lastPos = 0;
+ var m;
+ while ((m = re.exec(str)) !== null) {
+ parts.push(str.substring(lastPos, m.index));
+ parts.push(m[0]);
+ lastPos = m.index + m[0].length;
+ }
+ parts.push(str.substring(lastPos));
+ return parts;
+ }
+ }
+
+ function parseEndTag(parts, pos, h, param, continuationMarker, state) {
+ var tag = parseTagAndAttrs(parts, pos);
+ // drop unclosed tags
+ if (!tag) { return parts.length; }
+ if (h.endTag) {
+ h.endTag(tag.name, param, continuationMarker,
+ continuationMaker(h, parts, pos, state, param));
+ }
+ return tag.next;
+ }
+
+ function parseStartTag(parts, pos, h, param, continuationMarker, state) {
+ var tag = parseTagAndAttrs(parts, pos);
+ // drop unclosed tags
+ if (!tag) { return parts.length; }
+ if (h.startTag) {
+ h.startTag(tag.name, tag.attrs, param, continuationMarker,
+ continuationMaker(h, parts, tag.next, state, param));
+ }
+ // tags like <script> and <textarea> have special parsing
+ if (tag.eflags & EFLAGS_TEXT) {
+ return parseText(parts, tag, h, param, continuationMarker, state);
+ } else {
+ return tag.next;
+ }
+ }
+
+ var endTagRe = {};
+
+ // Tags like <script> and <textarea> are flagged as CDATA or RCDATA,
+ // which means everything is text until we see the correct closing tag.
+ function parseText(parts, tag, h, param, continuationMarker, state) {
+ var end = parts.length;
+ if (!endTagRe.hasOwnProperty(tag.name)) {
+ endTagRe[tag.name] = new RegExp('^' + tag.name + '(?:[\\s\\/]|$)', 'i');
+ }
+ var re = endTagRe[tag.name];
+ var first = tag.next;
+ var p = tag.next + 1;
+ for (; p < end; p++) {
+ if (parts[p - 1] === '<\/' && re.test(parts[p])) { break; }
+ }
+ if (p < end) { p -= 1; }
+ var buf = parts.slice(first, p).join('');
+ if (tag.eflags & html4.eflags['CDATA']) {
+ if (h.cdata) {
+ h.cdata(buf, param, continuationMarker,
+ continuationMaker(h, parts, p, state, param));
+ }
+ } else if (tag.eflags & html4.eflags['RCDATA']) {
+ if (h.rcdata) {
+ h.rcdata(normalizeRCData(buf), param, continuationMarker,
+ continuationMaker(h, parts, p, state, param));
+ }
+ } else {
+ throw new Error('bug');
+ }
+ return p;
+ }
+
+ // at this point, parts[pos-1] is either "<" or "<\/".
+ function parseTagAndAttrs(parts, pos) {
+ var m = /^([-\w:]+)/.exec(parts[pos]);
+ var tag = {};
+ tag.name = m[1].toLowerCase();
+ tag.eflags = html4.ELEMENTS[tag.name];
+ var buf = parts[pos].substr(m[0].length);
+ // Find the next '>'. We optimistically assume this '>' is not in a
+ // quoted context, and further down we fix things up if it turns out to
+ // be quoted.
+ var p = pos + 1;
+ var end = parts.length;
+ for (; p < end; p++) {
+ if (parts[p] === '>') { break; }
+ buf += parts[p];
+ }
+ if (end <= p) { return void 0; }
+ var attrs = [];
+ while (buf !== '') {
+ m = ATTR_RE.exec(buf);
+ if (!m) {
+ // No attribute found: skip garbage
+ buf = buf.replace(/^[\s\S][^a-z\s]*/, '');
+
+ } else if ((m[4] && !m[5]) || (m[6] && !m[7])) {
+ // Unterminated quote: slurp to the next unquoted '>'
+ var quote = m[4] || m[6];
+ var sawQuote = false;
+ var abuf = [buf, parts[p++]];
+ for (; p < end; p++) {
+ if (sawQuote) {
+ if (parts[p] === '>') { break; }
+ } else if (0 <= parts[p].indexOf(quote)) {
+ sawQuote = true;
+ }
+ abuf.push(parts[p]);
+ }
+ // Slurp failed: lose the garbage
+ if (end <= p) { break; }
+ // Otherwise retry attribute parsing
+ buf = abuf.join('');
+ continue;
+
+ } else {
+ // We have an attribute
+ var aName = m[1].toLowerCase();
+ var aValue = m[2] ? decodeValue(m[3]) : '';
+ attrs.push(aName, aValue);
+ buf = buf.substr(m[0].length);
+ }
+ }
+ tag.attrs = attrs;
+ tag.next = p + 1;
+ return tag;
+ }
+
+ function decodeValue(v) {
+ var q = v.charCodeAt(0);
+ if (q === 0x22 || q === 0x27) { // " or '
+ v = v.substr(1, v.length - 2);
+ }
+ return unescapeEntities(stripNULs(v));
+ }
+
+ /**
+ * Returns a function that strips unsafe tags and attributes from html.
+ * @param {function(string, Array.<string>): ?Array.<string>} tagPolicy
+ * A function that takes (tagName, attribs[]), where tagName is a key in
+ * html4.ELEMENTS and attribs is an array of alternating attribute names
+ * and values. It should return a record (as follows), or null to delete
+ * the element. It's okay for tagPolicy to modify the attribs array,
+ * but the same array is reused, so it should not be held between calls.
+ * Record keys:
+ * attribs: (required) Sanitized attributes array.
+ * tagName: Replacement tag name.
+ * @return {function(string, Array)} A function that sanitizes a string of
+ * HTML and appends result strings to the second argument, an array.
+ */
+ function makeHtmlSanitizer(tagPolicy) {
+ var stack;
+ var ignoring;
+ var emit = function (text, out) {
+ if (!ignoring) { out.push(text); }
+ };
+ return makeSaxParser({
+ 'startDoc': function(_) {
+ stack = [];
+ ignoring = false;
+ },
+ 'startTag': function(tagNameOrig, attribs, out) {
+ if (ignoring) { return; }
+ if (!html4.ELEMENTS.hasOwnProperty(tagNameOrig)) { return; }
+ var eflagsOrig = html4.ELEMENTS[tagNameOrig];
+ if (eflagsOrig & html4.eflags['FOLDABLE']) {
+ return;
+ }
+
+ var decision = tagPolicy(tagNameOrig, attribs);
+ if (!decision) {
+ ignoring = !(eflagsOrig & html4.eflags['EMPTY']);
+ return;
+ } else if (typeof decision !== 'object') {
+ throw new Error('tagPolicy did not return object (old API?)');
+ }
+ if ('attribs' in decision) {
+ attribs = decision['attribs'];
+ } else {
+ throw new Error('tagPolicy gave no attribs');
+ }
+ var eflagsRep;
+ var tagNameRep;
+ if ('tagName' in decision) {
+ tagNameRep = decision['tagName'];
+ eflagsRep = html4.ELEMENTS[tagNameRep];
+ } else {
+ tagNameRep = tagNameOrig;
+ eflagsRep = eflagsOrig;
+ }
+ // TODO(mikesamuel): relying on tagPolicy not to insert unsafe
+ // attribute names.
+
+ // If this is an optional-end-tag element and either this element or its
+ // previous like sibling was rewritten, then insert a close tag to
+ // preserve structure.
+ if (eflagsOrig & html4.eflags['OPTIONAL_ENDTAG']) {
+ var onStack = stack[stack.length - 1];
+ if (onStack && onStack.orig === tagNameOrig &&
+ (onStack.rep !== tagNameRep || tagNameOrig !== tagNameRep)) {
+ out.push('<\/', onStack.rep, '>');
+ }
+ }
+
+ if (!(eflagsOrig & html4.eflags['EMPTY'])) {
+ stack.push({orig: tagNameOrig, rep: tagNameRep});
+ }
+
+ out.push('<', tagNameRep);
+ for (var i = 0, n = attribs.length; i < n; i += 2) {
+ var attribName = attribs[i],
+ value = attribs[i + 1];
+ if (value !== null && value !== void 0) {
+ out.push(' ', attribName, '="', escapeAttrib(value), '"');
+ }
+ }
+ out.push('>');
+
+ if ((eflagsOrig & html4.eflags['EMPTY'])
+ && !(eflagsRep & html4.eflags['EMPTY'])) {
+ // replacement is non-empty, synthesize end tag
+ out.push('<\/', tagNameRep, '>');
+ }
+ },
+ 'endTag': function(tagName, out) {
+ if (ignoring) {
+ ignoring = false;
+ return;
+ }
+ if (!html4.ELEMENTS.hasOwnProperty(tagName)) { return; }
+ var eflags = html4.ELEMENTS[tagName];
+ if (!(eflags & (html4.eflags['EMPTY'] | html4.eflags['FOLDABLE']))) {
+ var index;
+ if (eflags & html4.eflags['OPTIONAL_ENDTAG']) {
+ for (index = stack.length; --index >= 0;) {
+ var stackElOrigTag = stack[index].orig;
+ if (stackElOrigTag === tagName) { break; }
+ if (!(html4.ELEMENTS[stackElOrigTag] &
+ html4.eflags['OPTIONAL_ENDTAG'])) {
+ // Don't pop non optional end tags looking for a match.
+ return;
+ }
+ }
+ } else {
+ for (index = stack.length; --index >= 0;) {
+ if (stack[index].orig === tagName) { break; }
+ }
+ }
+ if (index < 0) { return; } // Not opened.
+ for (var i = stack.length; --i > index;) {
+ var stackElRepTag = stack[i].rep;
+ if (!(html4.ELEMENTS[stackElRepTag] &
+ html4.eflags['OPTIONAL_ENDTAG'])) {
+ out.push('<\/', stackElRepTag, '>');
+ }
+ }
+ if (index < stack.length) {
+ tagName = stack[index].rep;
+ }
+ stack.length = index;
+ out.push('<\/', tagName, '>');
+ }
+ },
+ 'pcdata': emit,
+ 'rcdata': emit,
+ 'cdata': emit,
+ 'endDoc': function(out) {
+ for (; stack.length; stack.length--) {
+ out.push('<\/', stack[stack.length - 1].rep, '>');
+ }
+ }
+ });
+ }
+
+ var ALLOWED_URI_SCHEMES = /^(?:https?|mailto)$/i;
+
+ function safeUri(uri, effect, ltype, hints, naiveUriRewriter) {
+ if (!naiveUriRewriter) { return null; }
+ try {
+ var parsed = URI.parse('' + uri);
+ if (parsed) {
+ if (!parsed.hasScheme() ||
+ ALLOWED_URI_SCHEMES.test(parsed.getScheme())) {
+ var safe = naiveUriRewriter(parsed, effect, ltype, hints);
+ return safe ? safe.toString() : null;
+ }
+ }
+ } catch (e) {
+ return null;
+ }
+ return null;
+ }
+
+ function log(logger, tagName, attribName, oldValue, newValue) {
+ if (!attribName) {
+ logger(tagName + " removed", {
+ change: "removed",
+ tagName: tagName
+ });
+ }
+ if (oldValue !== newValue) {
+ var changed = "changed";
+ if (oldValue && !newValue) {
+ changed = "removed";
+ } else if (!oldValue && newValue) {
+ changed = "added";
+ }
+ logger(tagName + "." + attribName + " " + changed, {
+ change: changed,
+ tagName: tagName,
+ attribName: attribName,
+ oldValue: oldValue,
+ newValue: newValue
+ });
+ }
+ }
+
+ function lookupAttribute(map, tagName, attribName) {
+ var attribKey;
+ attribKey = tagName + '::' + attribName;
+ if (map.hasOwnProperty(attribKey)) {
+ return map[attribKey];
+ }
+ attribKey = '*::' + attribName;
+ if (map.hasOwnProperty(attribKey)) {
+ return map[attribKey];
+ }
+ return void 0;
+ }
+ function getAttributeType(tagName, attribName) {
+ return lookupAttribute(html4.ATTRIBS, tagName, attribName);
+ }
+ function getLoaderType(tagName, attribName) {
+ return lookupAttribute(html4.LOADERTYPES, tagName, attribName);
+ }
+ function getUriEffect(tagName, attribName) {
+ return lookupAttribute(html4.URIEFFECTS, tagName, attribName);
+ }
+
+ /**
+ * Sanitizes attributes on an HTML tag.
+ * @param {string} tagName An HTML tag name in lowercase.
+ * @param {Array.<?string>} attribs An array of alternating names and values.
+ * @param {?function(?string): ?string} opt_naiveUriRewriter A transform to
+ * apply to URI attributes; it can return a new string value, or null to
+ * delete the attribute. If unspecified, URI attributes are deleted.
+ * @param {function(?string): ?string} opt_nmTokenPolicy A transform to apply
+ * to attributes containing HTML names, element IDs, and space-separated
+ * lists of classes; it can return a new string value, or null to delete
+ * the attribute. If unspecified, these attributes are kept unchanged.
+ * @return {Array.<?string>} The sanitized attributes as a list of alternating
+ * names and values, where a null value means to omit the attribute.
+ */
+ function sanitizeAttribs(tagName, attribs,
+ opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger) {
+ // TODO(felix8a): it's obnoxious that domado duplicates much of this
+ // TODO(felix8a): maybe consistently enforce constraints like target=
+ for (var i = 0; i < attribs.length; i += 2) {
+ var attribName = attribs[i];
+ var value = attribs[i + 1];
+ var oldValue = value;
+ var atype = null, attribKey;
+ if ((attribKey = tagName + '::' + attribName,
+ html4.ATTRIBS.hasOwnProperty(attribKey)) ||
+ (attribKey = '*::' + attribName,
+ html4.ATTRIBS.hasOwnProperty(attribKey))) {
+ atype = html4.ATTRIBS[attribKey];
+ }
+ if (atype !== null) {
+ switch (atype) {
+ case html4.atype['NONE']: break;
+ case html4.atype['SCRIPT']:
+ value = null;
+ if (opt_logger) {
+ log(opt_logger, tagName, attribName, oldValue, value);
+ }
+ break;
+ case html4.atype['STYLE']:
+ if ('undefined' === typeof parseCssDeclarations) {
+ value = null;
+ if (opt_logger) {
+ log(opt_logger, tagName, attribName, oldValue, value);
+ }
+ break;
+ }
+ var sanitizedDeclarations = [];
+ parseCssDeclarations(
+ value,
+ {
+ 'declaration': function (property, tokens) {
+ var normProp = property.toLowerCase();
+ sanitizeCssProperty(
+ normProp, tokens,
+ opt_naiveUriRewriter
+ ? function (url) {
+ return safeUri(
+ url, html4.ueffects.SAME_DOCUMENT,
+ html4.ltypes.SANDBOXED,
+ {
+ "TYPE": "CSS",
+ "CSS_PROP": normProp
+ }, opt_naiveUriRewriter);
+ }
+ : null);
+ if (tokens.length) {
+ sanitizedDeclarations.push(
+ normProp + ': ' + tokens.join(' '));
+ }
+ }
+ });
+ value = sanitizedDeclarations.length > 0 ?
+ sanitizedDeclarations.join(' ; ') : null;
+ if (opt_logger) {
+ log(opt_logger, tagName, attribName, oldValue, value);
+ }
+ break;
+ case html4.atype['ID']:
+ case html4.atype['IDREF']:
+ case html4.atype['IDREFS']:
+ case html4.atype['GLOBAL_NAME']:
+ case html4.atype['LOCAL_NAME']:
+ case html4.atype['CLASSES']:
+ value = opt_nmTokenPolicy ? opt_nmTokenPolicy(value) : value;
+ if (opt_logger) {
+ log(opt_logger, tagName, attribName, oldValue, value);
+ }
+ break;
+ case html4.atype['URI']:
+ value = safeUri(value,
+ getUriEffect(tagName, attribName),
+ getLoaderType(tagName, attribName),
+ {
+ "TYPE": "MARKUP",
+ "XML_ATTR": attribName,
+ "XML_TAG": tagName
+ }, opt_naiveUriRewriter);
+ if (opt_logger) {
+ log(opt_logger, tagName, attribName, oldValue, value);
+ }
+ break;
+ case html4.atype['URI_FRAGMENT']:
+ if (value && '#' === value.charAt(0)) {
+ value = value.substring(1); // remove the leading '#'
+ value = opt_nmTokenPolicy ? opt_nmTokenPolicy(value) : value;
+ if (value !== null && value !== void 0) {
+ value = '#' + value; // restore the leading '#'
+ }
+ } else {
+ value = null;
+ }
+ if (opt_logger) {
+ log(opt_logger, tagName, attribName, oldValue, value);
+ }
+ break;
+ default:
+ value = null;
+ if (opt_logger) {
+ log(opt_logger, tagName, attribName, oldValue, value);
+ }
+ break;
+ }
+ } else {
+ value = null;
+ if (opt_logger) {
+ log(opt_logger, tagName, attribName, oldValue, value);
+ }
+ }
+ attribs[i + 1] = value;
+ }
+ return attribs;
+ }
+
+ /**
+ * Creates a tag policy that omits all tags marked UNSAFE in html4-defs.js
+ * and applies the default attribute sanitizer with the supplied policy for
+ * URI attributes and NMTOKEN attributes.
+ * @param {?function(?string): ?string} opt_naiveUriRewriter A transform to
+ * apply to URI attributes. If not given, URI attributes are deleted.
+ * @param {function(?string): ?string} opt_nmTokenPolicy A transform to apply
+ * to attributes containing HTML names, element IDs, and space-separated
+ * lists of classes. If not given, such attributes are left unchanged.
+ * @return {function(string, Array.<?string>)} A tagPolicy suitable for
+ * passing to html.sanitize.
+ */
+ function makeTagPolicy(
+ opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger) {
+ return function(tagName, attribs) {
+ if (!(html4.ELEMENTS[tagName] & html4.eflags['UNSAFE'])) {
+ return {
+ 'attribs': sanitizeAttribs(tagName, attribs,
+ opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger)
+ };
+ } else {
+ if (opt_logger) {
+ log(opt_logger, tagName, undefined, undefined, undefined);
+ }
+ }
+ };
+ }
+
+ /**
+ * Sanitizes HTML tags and attributes according to a given policy.
+ * @param {string} inputHtml The HTML to sanitize.
+ * @param {function(string, Array.<?string>)} tagPolicy A function that
+ * decides which tags to accept and sanitizes their attributes (see
+ * makeHtmlSanitizer above for details).
+ * @return {string} The sanitized HTML.
+ */
+ function sanitizeWithPolicy(inputHtml, tagPolicy) {
+ var outputArray = [];
+ makeHtmlSanitizer(tagPolicy)(inputHtml, outputArray);
+ return outputArray.join('');
+ }
+
+ /**
+ * Strips unsafe tags and attributes from HTML.
+ * @param {string} inputHtml The HTML to sanitize.
+ * @param {?function(?string): ?string} opt_naiveUriRewriter A transform to
+ * apply to URI attributes. If not given, URI attributes are deleted.
+ * @param {function(?string): ?string} opt_nmTokenPolicy A transform to apply
+ * to attributes containing HTML names, element IDs, and space-separated
+ * lists of classes. If not given, such attributes are left unchanged.
+ */
+ function sanitize(inputHtml,
+ opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger) {
+ var tagPolicy = makeTagPolicy(
+ opt_naiveUriRewriter, opt_nmTokenPolicy, opt_logger);
+ return sanitizeWithPolicy(inputHtml, tagPolicy);
+ }
+
+ // Export both quoted and unquoted names for Closure linkage.
+ var html = {};
+ html.escapeAttrib = html['escapeAttrib'] = escapeAttrib;
+ html.makeHtmlSanitizer = html['makeHtmlSanitizer'] = makeHtmlSanitizer;
+ html.makeSaxParser = html['makeSaxParser'] = makeSaxParser;
+ html.makeTagPolicy = html['makeTagPolicy'] = makeTagPolicy;
+ html.normalizeRCData = html['normalizeRCData'] = normalizeRCData;
+ html.sanitize = html['sanitize'] = sanitize;
+ html.sanitizeAttribs = html['sanitizeAttribs'] = sanitizeAttribs;
+ html.sanitizeWithPolicy = html['sanitizeWithPolicy'] = sanitizeWithPolicy;
+ html.unescapeEntities = html['unescapeEntities'] = unescapeEntities;
+ return html;
+})(html4);
+
+var html_sanitize = html['sanitize'];
+
+return {
+ html: html
+};
+});
diff --git a/web-ui/app/js/lib/html4-defs.js b/web-ui/app/js/lib/html4-defs.js
new file mode 100644
index 00000000..1ec575da
--- /dev/null
+++ b/web-ui/app/js/lib/html4-defs.js
@@ -0,0 +1,640 @@
+// Copyright Google Inc.
+// Licensed under the Apache Licence Version 2.0
+// Autogenerated at Mon Jul 14 18:51:33 BRT 2014
+// @overrides window
+// @provides html4
+define([], function() {
+var html4 = {};
+html4.atype = {
+ 'NONE': 0,
+ 'URI': 1,
+ 'URI_FRAGMENT': 11,
+ 'SCRIPT': 2,
+ 'STYLE': 3,
+ 'HTML': 12,
+ 'ID': 4,
+ 'IDREF': 5,
+ 'IDREFS': 6,
+ 'GLOBAL_NAME': 7,
+ 'LOCAL_NAME': 8,
+ 'CLASSES': 9,
+ 'FRAME_TARGET': 10,
+ 'MEDIA_QUERY': 13
+};
+html4[ 'atype' ] = html4.atype;
+html4.ATTRIBS = {
+ '*::class': 9,
+ '*::dir': 0,
+ '*::draggable': 0,
+ '*::hidden': 0,
+ '*::id': 4,
+ '*::inert': 0,
+ '*::itemprop': 0,
+ '*::itemref': 6,
+ '*::itemscope': 0,
+ '*::lang': 0,
+ '*::onblur': 2,
+ '*::onchange': 2,
+ '*::onclick': 2,
+ '*::ondblclick': 2,
+ '*::onerror': 2,
+ '*::onfocus': 2,
+ '*::onkeydown': 2,
+ '*::onkeypress': 2,
+ '*::onkeyup': 2,
+ '*::onload': 2,
+ '*::onmousedown': 2,
+ '*::onmousemove': 2,
+ '*::onmouseout': 2,
+ '*::onmouseover': 2,
+ '*::onmouseup': 2,
+ '*::onreset': 2,
+ '*::onscroll': 2,
+ '*::onselect': 2,
+ '*::onsubmit': 2,
+ '*::ontouchcancel': 2,
+ '*::ontouchend': 2,
+ '*::ontouchenter': 2,
+ '*::ontouchleave': 2,
+ '*::ontouchmove': 2,
+ '*::ontouchstart': 2,
+ '*::onunload': 2,
+ '*::spellcheck': 0,
+ '*::style': 3,
+ '*::tabindex': 0,
+ '*::title': 0,
+ '*::translate': 0,
+ 'a::accesskey': 0,
+ 'a::coords': 0,
+ 'a::href': 1,
+ 'a::hreflang': 0,
+ 'a::name': 7,
+ 'a::onblur': 2,
+ 'a::onfocus': 2,
+ 'a::shape': 0,
+ 'a::target': 10,
+ 'a::type': 0,
+ 'area::accesskey': 0,
+ 'area::alt': 0,
+ 'area::coords': 0,
+ 'area::href': 1,
+ 'area::nohref': 0,
+ 'area::onblur': 2,
+ 'area::onfocus': 2,
+ 'area::shape': 0,
+ 'area::target': 10,
+ 'audio::controls': 0,
+ 'audio::loop': 0,
+ 'audio::mediagroup': 5,
+ 'audio::muted': 0,
+ 'audio::preload': 0,
+ 'audio::src': 1,
+ 'bdo::dir': 0,
+ 'blockquote::cite': 1,
+ 'br::clear': 0,
+ 'button::accesskey': 0,
+ 'button::disabled': 0,
+ 'button::name': 8,
+ 'button::onblur': 2,
+ 'button::onfocus': 2,
+ 'button::type': 0,
+ 'button::value': 0,
+ 'canvas::height': 0,
+ 'canvas::width': 0,
+ 'caption::align': 0,
+ 'col::align': 0,
+ 'col::char': 0,
+ 'col::charoff': 0,
+ 'col::span': 0,
+ 'col::valign': 0,
+ 'col::width': 0,
+ 'colgroup::align': 0,
+ 'colgroup::char': 0,
+ 'colgroup::charoff': 0,
+ 'colgroup::span': 0,
+ 'colgroup::valign': 0,
+ 'colgroup::width': 0,
+ 'command::checked': 0,
+ 'command::command': 5,
+ 'command::disabled': 0,
+ 'command::icon': 1,
+ 'command::label': 0,
+ 'command::radiogroup': 0,
+ 'command::type': 0,
+ 'data::value': 0,
+ 'del::cite': 1,
+ 'del::datetime': 0,
+ 'details::open': 0,
+ 'dir::compact': 0,
+ 'div::align': 0,
+ 'dl::compact': 0,
+ 'fieldset::disabled': 0,
+ 'font::color': 0,
+ 'font::face': 0,
+ 'font::size': 0,
+ 'form::accept': 0,
+ 'form::action': 1,
+ 'form::autocomplete': 0,
+ 'form::enctype': 0,
+ 'form::method': 0,
+ 'form::name': 7,
+ 'form::novalidate': 0,
+ 'form::onreset': 2,
+ 'form::onsubmit': 2,
+ 'form::target': 10,
+ 'h1::align': 0,
+ 'h2::align': 0,
+ 'h3::align': 0,
+ 'h4::align': 0,
+ 'h5::align': 0,
+ 'h6::align': 0,
+ 'hr::align': 0,
+ 'hr::noshade': 0,
+ 'hr::size': 0,
+ 'hr::width': 0,
+ 'iframe::align': 0,
+ 'iframe::frameborder': 0,
+ 'iframe::height': 0,
+ 'iframe::marginheight': 0,
+ 'iframe::marginwidth': 0,
+ 'iframe::width': 0,
+ 'img::align': 0,
+ 'img::alt': 0,
+ 'img::border': 0,
+ 'img::height': 0,
+ 'img::hspace': 0,
+ 'img::ismap': 0,
+ 'img::name': 7,
+ 'img::src': 1,
+ 'img::usemap': 11,
+ 'img::vspace': 0,
+ 'img::width': 0,
+ 'input::accept': 0,
+ 'input::accesskey': 0,
+ 'input::align': 0,
+ 'input::alt': 0,
+ 'input::autocomplete': 0,
+ 'input::checked': 0,
+ 'input::disabled': 0,
+ 'input::inputmode': 0,
+ 'input::ismap': 0,
+ 'input::list': 5,
+ 'input::max': 0,
+ 'input::maxlength': 0,
+ 'input::min': 0,
+ 'input::multiple': 0,
+ 'input::name': 8,
+ 'input::onblur': 2,
+ 'input::onchange': 2,
+ 'input::onfocus': 2,
+ 'input::onselect': 2,
+ 'input::pattern': 0,
+ 'input::placeholder': 0,
+ 'input::readonly': 0,
+ 'input::required': 0,
+ 'input::size': 0,
+ 'input::src': 1,
+ 'input::step': 0,
+ 'input::type': 0,
+ 'input::usemap': 11,
+ 'input::value': 0,
+ 'ins::cite': 1,
+ 'ins::datetime': 0,
+ 'label::accesskey': 0,
+ 'label::for': 5,
+ 'label::onblur': 2,
+ 'label::onfocus': 2,
+ 'legend::accesskey': 0,
+ 'legend::align': 0,
+ 'li::type': 0,
+ 'li::value': 0,
+ 'map::name': 7,
+ 'menu::compact': 0,
+ 'menu::label': 0,
+ 'menu::type': 0,
+ 'meter::high': 0,
+ 'meter::low': 0,
+ 'meter::max': 0,
+ 'meter::min': 0,
+ 'meter::value': 0,
+ 'ol::compact': 0,
+ 'ol::reversed': 0,
+ 'ol::start': 0,
+ 'ol::type': 0,
+ 'optgroup::disabled': 0,
+ 'optgroup::label': 0,
+ 'option::disabled': 0,
+ 'option::label': 0,
+ 'option::selected': 0,
+ 'option::value': 0,
+ 'output::for': 6,
+ 'output::name': 8,
+ 'p::align': 0,
+ 'pre::width': 0,
+ 'progress::max': 0,
+ 'progress::min': 0,
+ 'progress::value': 0,
+ 'q::cite': 1,
+ 'select::autocomplete': 0,
+ 'select::disabled': 0,
+ 'select::multiple': 0,
+ 'select::name': 8,
+ 'select::onblur': 2,
+ 'select::onchange': 2,
+ 'select::onfocus': 2,
+ 'select::required': 0,
+ 'select::size': 0,
+ 'source::type': 0,
+ 'table::align': 0,
+ 'table::bgcolor': 0,
+ 'table::border': 0,
+ 'table::cellpadding': 0,
+ 'table::cellspacing': 0,
+ 'table::frame': 0,
+ 'table::rules': 0,
+ 'table::summary': 0,
+ 'table::width': 0,
+ 'tbody::align': 0,
+ 'tbody::char': 0,
+ 'tbody::charoff': 0,
+ 'tbody::valign': 0,
+ 'td::abbr': 0,
+ 'td::align': 0,
+ 'td::axis': 0,
+ 'td::bgcolor': 0,
+ 'td::char': 0,
+ 'td::charoff': 0,
+ 'td::colspan': 0,
+ 'td::headers': 6,
+ 'td::height': 0,
+ 'td::nowrap': 0,
+ 'td::rowspan': 0,
+ 'td::scope': 0,
+ 'td::valign': 0,
+ 'td::width': 0,
+ 'textarea::accesskey': 0,
+ 'textarea::autocomplete': 0,
+ 'textarea::cols': 0,
+ 'textarea::disabled': 0,
+ 'textarea::inputmode': 0,
+ 'textarea::name': 8,
+ 'textarea::onblur': 2,
+ 'textarea::onchange': 2,
+ 'textarea::onfocus': 2,
+ 'textarea::onselect': 2,
+ 'textarea::placeholder': 0,
+ 'textarea::readonly': 0,
+ 'textarea::required': 0,
+ 'textarea::rows': 0,
+ 'textarea::wrap': 0,
+ 'tfoot::align': 0,
+ 'tfoot::char': 0,
+ 'tfoot::charoff': 0,
+ 'tfoot::valign': 0,
+ 'th::abbr': 0,
+ 'th::align': 0,
+ 'th::axis': 0,
+ 'th::bgcolor': 0,
+ 'th::char': 0,
+ 'th::charoff': 0,
+ 'th::colspan': 0,
+ 'th::headers': 6,
+ 'th::height': 0,
+ 'th::nowrap': 0,
+ 'th::rowspan': 0,
+ 'th::scope': 0,
+ 'th::valign': 0,
+ 'th::width': 0,
+ 'thead::align': 0,
+ 'thead::char': 0,
+ 'thead::charoff': 0,
+ 'thead::valign': 0,
+ 'tr::align': 0,
+ 'tr::bgcolor': 0,
+ 'tr::char': 0,
+ 'tr::charoff': 0,
+ 'tr::valign': 0,
+ 'track::default': 0,
+ 'track::kind': 0,
+ 'track::label': 0,
+ 'track::srclang': 0,
+ 'ul::compact': 0,
+ 'ul::type': 0,
+ 'video::controls': 0,
+ 'video::height': 0,
+ 'video::loop': 0,
+ 'video::mediagroup': 5,
+ 'video::muted': 0,
+ 'video::poster': 1,
+ 'video::preload': 0,
+ 'video::src': 1,
+ 'video::width': 0
+};
+html4[ 'ATTRIBS' ] = html4.ATTRIBS;
+html4.eflags = {
+ 'OPTIONAL_ENDTAG': 1,
+ 'EMPTY': 2,
+ 'CDATA': 4,
+ 'RCDATA': 8,
+ 'UNSAFE': 16,
+ 'FOLDABLE': 32,
+ 'SCRIPT': 64,
+ 'STYLE': 128,
+ 'VIRTUALIZED': 256
+};
+html4[ 'eflags' ] = html4.eflags;
+html4.ELEMENTS = {
+ 'a': 0,
+ 'abbr': 0,
+ 'acronym': 0,
+ 'address': 0,
+ 'applet': 272,
+ 'area': 2,
+ 'article': 0,
+ 'aside': 0,
+ 'audio': 0,
+ 'b': 0,
+ 'base': 274,
+ 'basefont': 274,
+ 'bdi': 0,
+ 'bdo': 0,
+ 'big': 0,
+ 'blockquote': 0,
+ 'body': 305,
+ 'br': 2,
+ 'button': 0,
+ 'canvas': 0,
+ 'caption': 0,
+ 'center': 0,
+ 'cite': 0,
+ 'code': 0,
+ 'col': 2,
+ 'colgroup': 1,
+ 'command': 2,
+ 'data': 0,
+ 'datalist': 0,
+ 'dd': 1,
+ 'del': 0,
+ 'details': 0,
+ 'dfn': 0,
+ 'dialog': 272,
+ 'dir': 0,
+ 'div': 0,
+ 'dl': 0,
+ 'dt': 1,
+ 'em': 0,
+ 'fieldset': 0,
+ 'figcaption': 0,
+ 'figure': 0,
+ 'font': 0,
+ 'footer': 0,
+ 'form': 0,
+ 'frame': 274,
+ 'frameset': 272,
+ 'h1': 0,
+ 'h2': 0,
+ 'h3': 0,
+ 'h4': 0,
+ 'h5': 0,
+ 'h6': 0,
+ 'head': 305,
+ 'header': 0,
+ 'hgroup': 0,
+ 'hr': 2,
+ 'html': 305,
+ 'i': 0,
+ 'iframe': 4,
+ 'img': 2,
+ 'input': 2,
+ 'ins': 0,
+ 'isindex': 274,
+ 'kbd': 0,
+ 'keygen': 274,
+ 'label': 0,
+ 'legend': 0,
+ 'li': 1,
+ 'link': 274,
+ 'map': 0,
+ 'mark': 0,
+ 'menu': 0,
+ 'meta': 274,
+ 'meter': 0,
+ 'nav': 0,
+ 'nobr': 0,
+ 'noembed': 276,
+ 'noframes': 276,
+ 'noscript': 276,
+ 'object': 272,
+ 'ol': 0,
+ 'optgroup': 0,
+ 'option': 1,
+ 'output': 0,
+ 'p': 1,
+ 'param': 274,
+ 'pre': 0,
+ 'progress': 0,
+ 'q': 0,
+ 's': 0,
+ 'samp': 0,
+ 'script': 84,
+ 'section': 0,
+ 'select': 0,
+ 'small': 0,
+ 'source': 2,
+ 'span': 0,
+ 'strike': 0,
+ 'strong': 0,
+ 'style': 148,
+ 'sub': 0,
+ 'summary': 0,
+ 'sup': 0,
+ 'table': 0,
+ 'tbody': 1,
+ 'td': 1,
+ 'textarea': 8,
+ 'tfoot': 1,
+ 'th': 1,
+ 'thead': 1,
+ 'time': 0,
+ 'title': 280,
+ 'tr': 1,
+ 'track': 2,
+ 'tt': 0,
+ 'u': 0,
+ 'ul': 0,
+ 'var': 0,
+ 'video': 0,
+ 'wbr': 2
+};
+html4[ 'ELEMENTS' ] = html4.ELEMENTS;
+html4.ELEMENT_DOM_INTERFACES = {
+ 'a': 'HTMLAnchorElement',
+ 'abbr': 'HTMLElement',
+ 'acronym': 'HTMLElement',
+ 'address': 'HTMLElement',
+ 'applet': 'HTMLAppletElement',
+ 'area': 'HTMLAreaElement',
+ 'article': 'HTMLElement',
+ 'aside': 'HTMLElement',
+ 'audio': 'HTMLAudioElement',
+ 'b': 'HTMLElement',
+ 'base': 'HTMLBaseElement',
+ 'basefont': 'HTMLBaseFontElement',
+ 'bdi': 'HTMLElement',
+ 'bdo': 'HTMLElement',
+ 'big': 'HTMLElement',
+ 'blockquote': 'HTMLQuoteElement',
+ 'body': 'HTMLBodyElement',
+ 'br': 'HTMLBRElement',
+ 'button': 'HTMLButtonElement',
+ 'canvas': 'HTMLCanvasElement',
+ 'caption': 'HTMLTableCaptionElement',
+ 'center': 'HTMLElement',
+ 'cite': 'HTMLElement',
+ 'code': 'HTMLElement',
+ 'col': 'HTMLTableColElement',
+ 'colgroup': 'HTMLTableColElement',
+ 'command': 'HTMLCommandElement',
+ 'data': 'HTMLElement',
+ 'datalist': 'HTMLDataListElement',
+ 'dd': 'HTMLElement',
+ 'del': 'HTMLModElement',
+ 'details': 'HTMLDetailsElement',
+ 'dfn': 'HTMLElement',
+ 'dialog': 'HTMLDialogElement',
+ 'dir': 'HTMLDirectoryElement',
+ 'div': 'HTMLDivElement',
+ 'dl': 'HTMLDListElement',
+ 'dt': 'HTMLElement',
+ 'em': 'HTMLElement',
+ 'fieldset': 'HTMLFieldSetElement',
+ 'figcaption': 'HTMLElement',
+ 'figure': 'HTMLElement',
+ 'font': 'HTMLFontElement',
+ 'footer': 'HTMLElement',
+ 'form': 'HTMLFormElement',
+ 'frame': 'HTMLFrameElement',
+ 'frameset': 'HTMLFrameSetElement',
+ 'h1': 'HTMLHeadingElement',
+ 'h2': 'HTMLHeadingElement',
+ 'h3': 'HTMLHeadingElement',
+ 'h4': 'HTMLHeadingElement',
+ 'h5': 'HTMLHeadingElement',
+ 'h6': 'HTMLHeadingElement',
+ 'head': 'HTMLHeadElement',
+ 'header': 'HTMLElement',
+ 'hgroup': 'HTMLElement',
+ 'hr': 'HTMLHRElement',
+ 'html': 'HTMLHtmlElement',
+ 'i': 'HTMLElement',
+ 'iframe': 'HTMLIFrameElement',
+ 'img': 'HTMLImageElement',
+ 'input': 'HTMLInputElement',
+ 'ins': 'HTMLModElement',
+ 'isindex': 'HTMLUnknownElement',
+ 'kbd': 'HTMLElement',
+ 'keygen': 'HTMLKeygenElement',
+ 'label': 'HTMLLabelElement',
+ 'legend': 'HTMLLegendElement',
+ 'li': 'HTMLLIElement',
+ 'link': 'HTMLLinkElement',
+ 'map': 'HTMLMapElement',
+ 'mark': 'HTMLElement',
+ 'menu': 'HTMLMenuElement',
+ 'meta': 'HTMLMetaElement',
+ 'meter': 'HTMLMeterElement',
+ 'nav': 'HTMLElement',
+ 'nobr': 'HTMLElement',
+ 'noembed': 'HTMLElement',
+ 'noframes': 'HTMLElement',
+ 'noscript': 'HTMLElement',
+ 'object': 'HTMLObjectElement',
+ 'ol': 'HTMLOListElement',
+ 'optgroup': 'HTMLOptGroupElement',
+ 'option': 'HTMLOptionElement',
+ 'output': 'HTMLOutputElement',
+ 'p': 'HTMLParagraphElement',
+ 'param': 'HTMLParamElement',
+ 'pre': 'HTMLPreElement',
+ 'progress': 'HTMLProgressElement',
+ 'q': 'HTMLQuoteElement',
+ 's': 'HTMLElement',
+ 'samp': 'HTMLElement',
+ 'script': 'HTMLScriptElement',
+ 'section': 'HTMLElement',
+ 'select': 'HTMLSelectElement',
+ 'small': 'HTMLElement',
+ 'source': 'HTMLSourceElement',
+ 'span': 'HTMLSpanElement',
+ 'strike': 'HTMLElement',
+ 'strong': 'HTMLElement',
+ 'style': 'HTMLStyleElement',
+ 'sub': 'HTMLElement',
+ 'summary': 'HTMLElement',
+ 'sup': 'HTMLElement',
+ 'table': 'HTMLTableElement',
+ 'tbody': 'HTMLTableSectionElement',
+ 'td': 'HTMLTableDataCellElement',
+ 'textarea': 'HTMLTextAreaElement',
+ 'tfoot': 'HTMLTableSectionElement',
+ 'th': 'HTMLTableHeaderCellElement',
+ 'thead': 'HTMLTableSectionElement',
+ 'time': 'HTMLTimeElement',
+ 'title': 'HTMLTitleElement',
+ 'tr': 'HTMLTableRowElement',
+ 'track': 'HTMLTrackElement',
+ 'tt': 'HTMLElement',
+ 'u': 'HTMLElement',
+ 'ul': 'HTMLUListElement',
+ 'var': 'HTMLElement',
+ 'video': 'HTMLVideoElement',
+ 'wbr': 'HTMLElement'
+};
+html4[ 'ELEMENT_DOM_INTERFACES' ] = html4.ELEMENT_DOM_INTERFACES;
+html4.ueffects = {
+ 'NOT_LOADED': 0,
+ 'SAME_DOCUMENT': 1,
+ 'NEW_DOCUMENT': 2
+};
+html4[ 'ueffects' ] = html4.ueffects;
+html4.URIEFFECTS = {
+ 'a::href': 2,
+ 'area::href': 2,
+ 'audio::src': 1,
+ 'blockquote::cite': 0,
+ 'command::icon': 1,
+ 'del::cite': 0,
+ 'form::action': 2,
+ 'img::src': 1,
+ 'input::src': 1,
+ 'ins::cite': 0,
+ 'q::cite': 0,
+ 'video::poster': 1,
+ 'video::src': 1
+};
+html4[ 'URIEFFECTS' ] = html4.URIEFFECTS;
+html4.ltypes = {
+ 'UNSANDBOXED': 2,
+ 'SANDBOXED': 1,
+ 'DATA': 0
+};
+html4[ 'ltypes' ] = html4.ltypes;
+html4.LOADERTYPES = {
+ 'a::href': 2,
+ 'area::href': 2,
+ 'audio::src': 2,
+ 'blockquote::cite': 2,
+ 'command::icon': 1,
+ 'del::cite': 2,
+ 'form::action': 2,
+ 'img::src': 1,
+ 'input::src': 1,
+ 'ins::cite': 2,
+ 'q::cite': 2,
+ 'video::poster': 1,
+ 'video::src': 2
+};
+html4[ 'LOADERTYPES' ] = html4.LOADERTYPES;
+
+return html4
+});
diff --git a/web-ui/app/js/lib/html_whitelister.js b/web-ui/app/js/lib/html_whitelister.js
new file mode 100644
index 00000000..6d414077
--- /dev/null
+++ b/web-ui/app/js/lib/html_whitelister.js
@@ -0,0 +1,70 @@
+/*global _ */
+
+'use strict';
+
+define(['lib/html-sanitizer'], function (htmlSanitizer) {
+ var tagAndAttributeWhitelist = {
+ 'p': ['style'],
+ 'div': ['style'],
+ 'a': ['href', 'style'],
+ 'span': ['style'],
+ 'font': ['face', 'size', 'style'],
+ 'img': ['title'],
+ 'em': [],
+ 'b': [],
+ 'strong': ['style'],
+ 'table': ['style'],
+ 'tr': ['style'],
+ 'td': ['style'],
+ 'th': ['style'],
+ 'tbody': ['style'],
+ 'thead': ['style'],
+ 'dt': ['style'],
+ 'dd': ['style'],
+ 'dl': ['style'],
+ 'h1': ['style'],
+ 'h2': ['style'],
+ 'h3': ['style'],
+ 'h4': ['style'],
+ 'h5': ['style'],
+ 'h6': ['style'],
+ 'br': [],
+ 'blockquote': ['style'],
+ 'label': ['style'],
+ 'form': ['style'],
+ 'ol': ['style'],
+ 'ul': ['style'],
+ 'li': ['style'],
+ 'input': ['style', 'type', 'name', 'value']
+ };
+
+ function filterAllowedAttributes (tagName, attributes) {
+ var i, attributesAndValues = [];
+
+ for (i = 0; i < attributes.length; i++) {
+ if (tagAndAttributeWhitelist[tagName] &&
+ _.contains(tagAndAttributeWhitelist[tagName], attributes[i])) {
+ attributesAndValues.push(attributes[i]);
+ attributesAndValues.push(attributes[i+1]);
+ }
+ };
+
+ return attributesAndValues;
+ };
+
+ function tagPolicy (tagName, attributes) {
+ if (!tagAndAttributeWhitelist[tagName]) {
+ return null;
+ }
+
+ return {
+ tagName: tagName,
+ attribs: filterAllowedAttributes(tagName, attributes)
+ };
+ }
+
+ return {
+ tagPolicy: tagPolicy,
+ sanitize: htmlSanitizer.html.sanitizeWithPolicy
+ };
+});
diff --git a/web-ui/app/js/mail_list/domain/refresher.js b/web-ui/app/js/mail_list/domain/refresher.js
new file mode 100644
index 00000000..f1fe504d
--- /dev/null
+++ b/web-ui/app/js/mail_list/domain/refresher.js
@@ -0,0 +1,25 @@
+define(['flight/lib/component', 'page/events'], function(defineComponent, events) {
+ 'use strict';
+
+ return defineComponent(refresher);
+
+ function refresher() {
+ this.defaultAttrs({
+ interval: 20000
+ });
+
+ this.setupRefresher = function() {
+ setTimeout(this.doRefresh.bind(this), this.attr.interval);
+ };
+
+ this.doRefresh = function() {
+ this.trigger(document, events.ui.mails.refresh);
+ this.setupRefresher();
+ };
+
+ this.after('initialize', function () {
+ this.setupRefresher();
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list/ui/mail_item_factory.js b/web-ui/app/js/mail_list/ui/mail_item_factory.js
new file mode 100644
index 00000000..0a20e58c
--- /dev/null
+++ b/web-ui/app/js/mail_list/ui/mail_item_factory.js
@@ -0,0 +1,49 @@
+'use strict';
+
+define(
+ [
+ 'mail_list/ui/mail_items/generic_mail_item',
+ 'mail_list/ui/mail_items/draft_item',
+ 'mail_list/ui/mail_items/sent_item'
+ ],
+ function (GenericMailItem, DraftItem, SentItem) {
+
+ var MAIL_ITEM_TYPE = {
+ 'drafts': DraftItem,
+ 'sent': SentItem
+ };
+
+ var createAndAttach = function (nodeToAttachTo, mail, currentMailIdent, currentTag, isChecked) {
+ var mailItemContainer = $('<li>', { id: 'mail-' + mail.ident});
+ nodeToAttachTo.append(mailItemContainer);
+
+ var mailToCreate;
+ if(currentTag === 'all'){
+ mailToCreate = detectMailType(mail);
+ } else {
+ mailToCreate = MAIL_ITEM_TYPE[currentTag] || GenericMailItem;
+ }
+ mailToCreate.attachTo(mailItemContainer, {
+ mail: mail,
+ selected: mail.ident === currentMailIdent,
+ tag: currentTag,
+ isChecked: isChecked
+ });
+
+ };
+
+ var detectMailType = function(mail) {
+ if(_.include(mail.tags, 'drafts')) {
+ return MAIL_ITEM_TYPE['drafts'];
+ } else if(_.include(mail.tags, 'sent')) {
+ return MAIL_ITEM_TYPE['sent'];
+ } else {
+ return GenericMailItem;
+ };
+ };
+
+ return {
+ createAndAttach: createAndAttach
+ };
+ }
+);
diff --git a/web-ui/app/js/mail_list/ui/mail_items/draft_item.js b/web-ui/app/js/mail_list/ui/mail_items/draft_item.js
new file mode 100644
index 00000000..7a93af21
--- /dev/null
+++ b/web-ui/app/js/mail_list/ui/mail_items/draft_item.js
@@ -0,0 +1,55 @@
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'helpers/view_helper',
+ 'mail_list/ui/mail_items/mail_item',
+ 'page/events'
+ ],
+
+ function (defineComponent, templates, viewHelpers, mailItem, events) {
+ 'use strict';
+
+ return defineComponent(draftItem, mailItem);
+
+ function draftItem() {
+ function isOpeningOnANewTab(ev) {
+ return ev.metaKey || ev.ctrlKey || ev.which === 2;
+ }
+
+ this.triggerOpenMail = function (ev) {
+ if (isOpeningOnANewTab(ev)) {
+ return;
+ }
+ this.trigger(document, events.dispatchers.rightPane.openDraft, { ident: this.attr.ident });
+ this.trigger(document, events.ui.mail.updateSelected, { ident: this.attr.ident });
+ this.trigger(document, events.router.pushState, { mailIdent: this.attr.ident });
+ ev.preventDefault(); // don't let the hashchange trigger a popstate
+ };
+
+ this.render = function () {
+ var mailItemHtml = templates.mails.sent(this.attr);
+ this.$node.html(mailItemHtml);
+ this.$node.addClass(this.attr.statuses);
+ if(this.attr.selected) { this.select(); }
+ this.on(this.$node.find('a'), 'click', this.triggerOpenMail);
+ };
+
+ this.after('initialize', function () {
+ this.initializeAttributes();
+ this.render();
+ this.attachListeners();
+
+ if (this.attr.isChecked) {
+ this.checkCheckbox();
+ }
+
+ this.on(document, events.ui.composeBox.newMessage, this.unselect);
+ this.on(document, events.ui.mail.updateSelected, this.updateSelected);
+ this.on(document, events.mails.teardown, this.teardown);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list/ui/mail_items/generic_mail_item.js b/web-ui/app/js/mail_list/ui/mail_items/generic_mail_item.js
new file mode 100644
index 00000000..0f9157a7
--- /dev/null
+++ b/web-ui/app/js/mail_list/ui/mail_items/generic_mail_item.js
@@ -0,0 +1,97 @@
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'helpers/view_helper',
+ 'mail_list/ui/mail_items/mail_item',
+ 'page/events'
+ ],
+
+ function (defineComponent, templates, viewHelpers, mailItem, events) {
+ 'use strict';
+
+ return defineComponent(genericMailItem, mailItem);
+
+ function genericMailItem() {
+ this.status = {
+ READ: 'read'
+ };
+
+ function isOpeningOnANewTab(ev) {
+ return ev.metaKey || ev.ctrlKey || ev.which === 2;
+ }
+
+ this.triggerOpenMail = function (ev) {
+ if (isOpeningOnANewTab(ev)) {
+ updateMailStatusToRead.call(this);
+ return;
+ }
+ this.trigger(document, events.ui.mail.open, { ident: this.attr.ident });
+ this.trigger(document, events.router.pushState, { mailIdent: this.attr.ident });
+ ev.preventDefault(); // don't let the hashchange trigger a popstate
+ };
+
+ function updateMailStatusToRead() {
+ if (!_.contains(this.attr.mail.status, this.status.READ)) {
+ this.trigger(document, events.mail.read, { ident: this.attr.ident, tags: this.attr.mail.tags });
+ this.attr.mail.status.push(this.status.READ);
+ this.$node.addClass(viewHelpers.formatStatusClasses(this.attr.mail.status));
+ }
+ }
+
+ this.openMail = function (ev, data) {
+ if (data.ident !== this.attr.ident) {
+ return;
+ }
+ updateMailStatusToRead.call(this);
+
+ this.trigger(document, events.ui.mail.updateSelected, { ident: this.attr.ident });
+ };
+
+ this.updateTags = function(ev, data) {
+ if(data.ident === this.attr.ident){
+ this.attr.tags = data.tags;
+ if(!_.contains(this.attr.tags, this.attr.tag)) {
+ this.teardown();
+ } else {
+ this.render();
+ }
+ }
+ };
+
+ this.deleteMail = function(ev, data) {
+ if(data.mail.ident === this.attr.ident){
+ this.teardown();
+ }
+ };
+
+ this.render = function () {
+ this.attr.tagsForListView = _.without(this.attr.tags, this.attr.tag);
+ var mailItemHtml = templates.mails.single(this.attr);
+ this.$node.html(mailItemHtml);
+ this.$node.addClass(this.attr.statuses);
+ this.attr.selected && this.select();
+ this.on(this.$node.find('a'), 'click', this.triggerOpenMail);
+ };
+
+ this.after('initialize', function () {
+ this.initializeAttributes();
+ this.render();
+ this.attachListeners();
+
+ if (this.attr.isChecked) {
+ this.checkCheckbox();
+ }
+
+ this.on(document, events.ui.composeBox.newMessage, this.unselect);
+ this.on(document, events.ui.mail.open, this.openMail);
+ this.on(document, events.ui.mail.updateSelected, this.updateSelected);
+ this.on(document, events.mails.teardown, this.teardown);
+ this.on(document, events.mail.tags.update, this.updateTags);
+ this.on(document, events.mail.delete, this.deleteMail);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list/ui/mail_items/mail_item.js b/web-ui/app/js/mail_list/ui/mail_items/mail_item.js
new file mode 100644
index 00000000..c0628984
--- /dev/null
+++ b/web-ui/app/js/mail_list/ui/mail_items/mail_item.js
@@ -0,0 +1,63 @@
+'use strict';
+
+define(
+ ['helpers/view_helper',
+ 'page/events'], function (viewHelper, events) {
+
+ function mailItem() {
+ this.updateSelected = function (ev, data) {
+ if(data.ident === this.attr.ident) { this.select(); }
+ else { this.unselect(); }
+ };
+
+ this.formattedDate = function (date) {
+ return viewHelper.getFormattedDate(new Date(date));
+ };
+
+ this.select = function () {
+ this.$node.addClass('selected');
+ };
+
+ this.unselect = function () {
+ this.$node.removeClass('selected');
+ };
+
+ this.triggerMailChecked = function (ev, data) {
+ var eventToTrigger = ev.target.checked ? events.ui.mail.checked : events.ui.mail.unchecked;
+ this.trigger(document, eventToTrigger, { mail: this.attr.mail});
+ };
+
+ this.checkboxElement = function () {
+ return this.$node.find('input[type=checkbox]');
+ };
+
+ this.checkCheckbox = function () {
+ this.checkboxElement().prop('checked', true);
+ this.triggerMailChecked({'target': {'checked': true}});
+ };
+
+ this.uncheckCheckbox = function () {
+ this.checkboxElement().prop('checked', false);
+ this.triggerMailChecked({'target': {'checked': false}});
+ };
+
+ this.initializeAttributes = function () {
+ var mail = this.attr.mail;
+ this.attr.ident = mail.ident;
+ this.attr.header = mail.header;
+ this.attr.ident = mail.ident;
+ this.attr.statuses = viewHelper.formatStatusClasses(mail.status);
+ this.attr.tags = mail.tags;
+ this.attr.header.formattedDate = this.formattedDate(mail.header.date);
+ };
+
+ this.attachListeners = function () {
+ this.on(this.$node.find('input[type=checkbox]'), 'change', this.triggerMailChecked);
+ this.on(document, events.ui.mails.cleanSelected, this.unselect);
+ this.on(document, events.ui.mails.uncheckAll, this.uncheckCheckbox);
+ this.on(document, events.ui.mails.checkAll, this.checkCheckbox);
+ };
+ }
+
+ return mailItem;
+});
diff --git a/web-ui/app/js/mail_list/ui/mail_items/sent_item.js b/web-ui/app/js/mail_list/ui/mail_items/sent_item.js
new file mode 100644
index 00000000..8cdb8dd4
--- /dev/null
+++ b/web-ui/app/js/mail_list/ui/mail_items/sent_item.js
@@ -0,0 +1,62 @@
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mail_list/ui/mail_items/mail_item',
+ 'page/events'
+ ],
+
+ function (defineComponent, templates, mailItem, events) {
+ 'use strict';
+
+ return defineComponent(sentItem, mailItem);
+
+ function sentItem() {
+ function isOpeningOnANewTab(ev) {
+ return ev.metaKey || ev.ctrlKey || ev.which == 2;
+ }
+
+ this.triggerOpenMail = function (ev) {
+ if (isOpeningOnANewTab(ev)) {
+ return;
+ }
+ this.trigger(document, events.ui.mail.open, { ident: this.attr.ident });
+ this.trigger(document, events.router.pushState, { mailIdent: this.attr.ident });
+ ev.preventDefault(); // don't let the hashchange trigger a popstate
+ };
+
+ this.openMail = function (ev, data) {
+ if (data.ident !== this.attr.ident) {
+ return;
+ }
+ this.trigger(document, events.ui.mail.updateSelected, { ident: this.attr.ident });
+ };
+
+ this.render = function () {
+ this.attr.tagsForListView = _.without(this.attr.tags, this.attr.tag);
+ var mailItemHtml = templates.mails.sent(this.attr);
+ this.$node.html(mailItemHtml);
+ this.$node.addClass(this.attr.statuses);
+ this.attr.selected && this.select();
+ this.on(this.$node.find('a'), 'click', this.triggerOpenMail);
+ };
+
+ this.after('initialize', function () {
+ this.initializeAttributes();
+ this.render();
+ this.attachListeners();
+
+ if (this.attr.isChecked) {
+ this.checkCheckbox();
+ }
+
+ this.on(document, events.ui.composeBox.newMessage, this.unselect);
+ this.on(document, events.ui.mail.open, this.openMail);
+ this.on(document, events.ui.mail.updateSelected, this.updateSelected);
+ this.on(document, events.mails.teardown, this.teardown);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list/ui/mail_list.js b/web-ui/app/js/mail_list/ui/mail_list.js
new file mode 100644
index 00000000..a12c365d
--- /dev/null
+++ b/web-ui/app/js/mail_list/ui/mail_list.js
@@ -0,0 +1,185 @@
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'flight/lib/utils',
+ 'mail_list/ui/mail_item_factory',
+ 'page/router/url_params',
+ 'page/events'
+ ],
+
+ function (defineComponent, utils, MailItemFactory, urlParams, events) {
+ 'use strict';
+
+ return defineComponent(mailList);
+
+ function mailList() {
+ var self;
+
+ var openMailEventFor = function (tag) {
+ return tag === 'drafts' ? events.dispatchers.rightPane.openDraft : events.ui.mail.open;
+ };
+
+ this.defaultAttrs({
+ mail: '.mail',
+ currentMailIdent: '',
+ urlParams: urlParams,
+ initialized: false,
+ checkedMails: {}
+ });
+
+ function appendMail(mail) {
+ var isChecked = mail.ident in self.attr.checkedMails;
+ MailItemFactory.createAndAttach(self.$node, mail, self.attr.currentMailIdent, self.attr.currentTag, isChecked);
+ }
+
+ function resetMailList() {
+ self.trigger(document, events.mails.teardown);
+ self.$node.empty();
+ }
+
+ function triggerMailOpenForPopState(data) {
+ if(data.mailIdent) {
+ self.trigger(document, openMailEventFor(data.tag), { ident: data.mailIdent });
+ }
+ }
+
+ function shouldSelectEmailFromUrlMailIdent() {
+ return self.attr.urlParams.hasMailIdent();
+ }
+
+ function selectMailBasedOnUrlMailIdent() {
+ var mailIdent = self.attr.urlParams.getMailIdent();
+ self.trigger(document, openMailEventFor(self.attr.currentTag), { ident: mailIdent });
+ self.trigger(document, events.router.pushState, { tag: self.attr.currentTag, mailIdent: mailIdent });
+ }
+
+ function updateCurrentTagAndMail(data) {
+ if (data.ident) {
+ self.attr.currentMailIdent = data.ident;
+ }
+ self.attr.currentTag = data.tag || self.attr.currentTag;
+ }
+
+ function renderMails(mails) {
+ _.each(mails, appendMail);
+ self.trigger(document, events.search.highlightResults, {where: '#mail-list'});
+ self.trigger(document, events.search.highlightResults, {where: '.bodyArea'});
+ self.trigger(document, events.search.highlightResults, {where: '.subjectArea'});
+ self.trigger(document, events.search.highlightResults, {where: '.msg-header .recipients'});
+
+ }
+
+ this.triggerScrollReset = function() {
+ this.trigger(document, events.dispatchers.middlePane.resetScroll);
+ };
+
+ this.showMails = function (event, data) {
+ updateCurrentTagAndMail(data);
+ this.refreshMailList(null, data);
+ this.triggerScrollReset();
+ triggerMailOpenForPopState(data);
+ this.openMailFromUrl();
+ };
+
+ this.refreshMailList = function (ev, data) {
+ resetMailList();
+ renderMails(data.mails);
+ };
+
+ this.updateSelected = function (ev, data) {
+ if(data.ident !== this.attr.currentMailIdent){
+ this.uncheckCurrentMail();
+ this.attr.currentMailIdent = data.ident;
+ }
+ this.checkCurrentMail();
+ };
+
+ this.checkCurrentMail = function() {
+ $('#mail-'+this.attr.currentMailIdent+' input:checkbox')
+ .attr('checked', true)
+ .trigger('change');
+ };
+
+ this.uncheckCurrentMail = function() {
+ $('#mail-'+this.attr.currentMailIdent+' input:checkbox')
+ .attr('checked', false)
+ .trigger('change');
+ };
+
+ this.cleanSelected = function () {
+ this.attr.currentMailIdent = '';
+ };
+
+ this.respondWithCheckedMails = function (ev, caller) {
+ this.trigger(caller, events.ui.mail.hereChecked, { checkedMails : this.attr.checkedMails });
+ };
+
+ this.updateCheckAllCheckbox = function () {
+ if (_.keys(this.attr.checkedMails).length > 0) {
+ this.trigger(document, events.ui.mails.hasMailsChecked, true);
+ } else {
+ this.trigger(document, events.ui.mails.hasMailsChecked, false);
+ }
+ };
+
+ this.addToSelectedMails = function (ev, data) {
+ this.attr.checkedMails[data.mail.ident] = data.mail;
+ this.updateCheckAllCheckbox();
+ };
+
+ this.removeFromSelectedMails = function (ev, data) {
+ if (data.mails) {
+ _.each(data.mails, function(mail) {
+ delete this.attr.checkedMails[mail.ident];
+ }, this);
+ } else {
+ delete this.attr.checkedMails[data.mail.ident];
+ }
+ this.updateCheckAllCheckbox();
+ };
+
+ this.refreshWithScroll = function () {
+ this.trigger(document, events.ui.mails.refresh);
+ this.triggerScrollReset();
+ };
+
+ this.refreshAfterSaveDraft = function () {
+ if(this.attr.currentTag === 'drafts') {
+ this.refreshWithScroll();
+ }
+ };
+
+ this.refreshAfterMailSent = function () {
+ if(this.attr.currentTag === 'drafts' || this.attr.currentTag === 'sent') {
+ this.refreshWithScroll();
+ }
+ };
+
+ this.after('initialize', function () {
+ self = this;
+
+ this.on(document, events.ui.mails.cleanSelected, this.cleanSelected);
+
+ this.on(document, events.mails.available, this.showMails);
+ this.on(document, events.mails.availableForRefresh, this.refreshMailList);
+
+ this.on(document, events.mail.draftSaved, this.refreshAfterSaveDraft);
+ this.on(document, events.mail.sent, this.refreshAfterMailSent);
+
+ this.on(document, events.ui.mail.updateSelected, this.updateSelected);
+ this.on(document, events.ui.mail.wantChecked, this.respondWithCheckedMails);
+ this.on(document, events.ui.mail.checked, this.addToSelectedMails);
+ this.on(document, events.ui.mail.unchecked, this.removeFromSelectedMails);
+
+ this.openMailFromUrl = utils.once(function () {
+ if(shouldSelectEmailFromUrlMailIdent()) {
+ selectMailBasedOnUrlMailIdent();
+ }
+ });
+
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list_actions/ui/compose_trigger.js b/web-ui/app/js/mail_list_actions/ui/compose_trigger.js
new file mode 100644
index 00000000..7f100dda
--- /dev/null
+++ b/web-ui/app/js/mail_list_actions/ui/compose_trigger.js
@@ -0,0 +1,31 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'page/events'
+ ],
+
+ function(defineComponent, templates, events) {
+ 'use strict';
+
+ return defineComponent(composeTrigger);
+
+ function composeTrigger() {
+
+ this.defaultAttrs({});
+
+ this.render = function() {
+ this.$node.html(templates.mailActions.composeTrigger);
+ };
+
+ this.enableComposing = function(event, data) {
+ this.trigger(document, events.dispatchers.rightPane.openComposeBox);
+ };
+
+ this.after('initialize', function () {
+ this.render();
+ this.on('click', this.enableComposing);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list_actions/ui/delete_many_trigger.js b/web-ui/app/js/mail_list_actions/ui/delete_many_trigger.js
new file mode 100644
index 00000000..62665db8
--- /dev/null
+++ b/web-ui/app/js/mail_list_actions/ui/delete_many_trigger.js
@@ -0,0 +1,31 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mixins/with_enable_disable_on_event',
+ 'page/events'
+ ],
+
+ function(defineComponent, templates, withEnableDisableOnEvent, events) {
+ 'use strict';
+
+ return defineComponent(deleteManyTrigger, withEnableDisableOnEvent(events.ui.mails.hasMailsChecked));
+
+ function deleteManyTrigger() {
+ this.defaultAttrs({});
+
+ this.getMailsToDelete = function(event) {
+ this.trigger(document, events.ui.mail.wantChecked, this.$node);
+ };
+
+ this.deleteManyEmails = function (event, data) {
+ this.trigger(document, events.ui.mail.deleteMany, data);
+ };
+
+ this.after('initialize', function () {
+ this.on('click', this.getMailsToDelete);
+ this.on(events.ui.mail.hereChecked, this.deleteManyEmails);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list_actions/ui/mail_list_actions.js b/web-ui/app/js/mail_list_actions/ui/mail_list_actions.js
new file mode 100644
index 00000000..146d0780
--- /dev/null
+++ b/web-ui/app/js/mail_list_actions/ui/mail_list_actions.js
@@ -0,0 +1,50 @@
+'use strict';
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mail_list_actions/ui/compose_trigger',
+ 'mail_list_actions/ui/refresh_trigger',
+ 'mail_list/domain/refresher',
+ 'mail_list_actions/ui/toggle_check_all_trigger',
+ 'mail_list_actions/ui/pagination_trigger',
+ 'mail_list_actions/ui/delete_many_trigger',
+ 'mail_list_actions/ui/mark_many_as_read_trigger',
+ 'mail_list_actions/ui/mark_as_unread_trigger'
+ ],
+
+ function (
+ defineComponent,
+ templates,
+ composeTrigger,
+ refreshTrigger,
+ refresher,
+ toggleCheckAllMailTrigger,
+ paginationTrigger,
+ deleteManyTrigger,
+ markManyAsReadTrigger,
+ markAsUnreadTrigger
+ ) {
+
+ return defineComponent(mailsActions);
+
+ function mailsActions() {
+ this.render = function() {
+ this.$node.html(templates.mailActions.actionsBox);
+ refreshTrigger.attachTo('#refresh-trigger');
+ composeTrigger.attachTo('#compose-trigger');
+ toggleCheckAllMailTrigger.attachTo('#toggle-check-all-emails');
+ paginationTrigger.attachTo('#pagination-trigger');
+ deleteManyTrigger.attachTo('#delete-selected');
+ markManyAsReadTrigger.attachTo('#mark-selected-as-read');
+ markAsUnreadTrigger.attachTo('#mark-selected-as-unread');
+ refresher.attachTo(document);
+ };
+
+ this.after('initialize', function () {
+ this.render();
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list_actions/ui/mark_as_unread_trigger.js b/web-ui/app/js/mail_list_actions/ui/mark_as_unread_trigger.js
new file mode 100644
index 00000000..3438a05a
--- /dev/null
+++ b/web-ui/app/js/mail_list_actions/ui/mark_as_unread_trigger.js
@@ -0,0 +1,31 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mixins/with_enable_disable_on_event',
+ 'page/events'
+ ],
+
+ function(defineComponent, templates, withEnableDisableOnEvent, events) {
+ 'use strict';
+
+ return defineComponent(markAsUnreadTrigger, withEnableDisableOnEvent(events.ui.mails.hasMailsChecked));
+
+ function markAsUnreadTrigger() {
+ this.defaultAttrs({});
+
+ this.getMailsToMarkAsUnread = function(event) {
+ this.trigger(document, events.ui.mail.wantChecked, this.$node);
+ };
+
+ this.markManyEmailsAsUnread = function (event, data) {
+ this.trigger(document, events.mail.unread, data);
+ };
+
+ this.after('initialize', function () {
+ this.on('click', this.getMailsToMarkAsUnread);
+ this.on(events.ui.mail.hereChecked, this.markManyEmailsAsUnread);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list_actions/ui/mark_many_as_read_trigger.js b/web-ui/app/js/mail_list_actions/ui/mark_many_as_read_trigger.js
new file mode 100644
index 00000000..ce2f7828
--- /dev/null
+++ b/web-ui/app/js/mail_list_actions/ui/mark_many_as_read_trigger.js
@@ -0,0 +1,31 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mixins/with_enable_disable_on_event',
+ 'page/events'
+ ],
+
+ function(defineComponent, templates, withEnableDisableOnEvent, events) {
+ 'use strict';
+
+ return defineComponent(markManyAsReadTrigger, withEnableDisableOnEvent(events.ui.mails.hasMailsChecked));
+
+ function markManyAsReadTrigger() {
+ this.defaultAttrs({});
+
+ this.getMailsToMarkAsRead = function(event) {
+ this.trigger(document, events.ui.mail.wantChecked, this.$node);
+ };
+
+ this.markManyEmailsAsRead = function (event, data) {
+ this.trigger(document, events.mail.read, data);
+ };
+
+ this.after('initialize', function () {
+ this.on('click', this.getMailsToMarkAsRead);
+ this.on(events.ui.mail.hereChecked, this.markManyEmailsAsRead);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list_actions/ui/pagination_trigger.js b/web-ui/app/js/mail_list_actions/ui/pagination_trigger.js
new file mode 100644
index 00000000..1ada36e7
--- /dev/null
+++ b/web-ui/app/js/mail_list_actions/ui/pagination_trigger.js
@@ -0,0 +1,50 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'page/events'
+ ],
+
+ function(defineComponent, templates, events) {
+ 'use strict';
+
+ return defineComponent(paginationTrigger);
+
+ function paginationTrigger() {
+ this.defaultAttrs({
+ previous: '#left-arrow',
+ next: '#right-arrow',
+ currentPage: "#current-page"
+ });
+
+ this.renderWithPageNumber = function(pageNumber) {
+ this.$node.html(templates.mailActions.paginationTrigger({
+ currentPage: pageNumber
+ }));
+ this.on(this.attr.previous, 'click', this.previousPage);
+ this.on(this.attr.next, 'click', this.nextPage);
+ };
+
+ this.render = function() {
+ this.renderWithPageNumber(1);
+ };
+
+ this.updatePageDisplay = function(event, data) {
+ this.renderWithPageNumber(data.currentPage + 1);
+ };
+
+ this.previousPage = function(event) {
+ this.trigger(document, events.ui.page.previous);
+ };
+
+ this.nextPage = function(event) {
+ this.trigger(document, events.ui.page.next);
+ };
+
+ this.after('initialize', function () {
+ this.render();
+ this.on(document, events.ui.page.changed, this.updatePageDisplay);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list_actions/ui/refresh_trigger.js b/web-ui/app/js/mail_list_actions/ui/refresh_trigger.js
new file mode 100644
index 00000000..aa1ab3a8
--- /dev/null
+++ b/web-ui/app/js/mail_list_actions/ui/refresh_trigger.js
@@ -0,0 +1,28 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'page/events'
+ ],
+
+ function(defineComponent, templates, events) {
+ 'use strict';
+
+ return defineComponent(refreshTrigger);
+
+ function refreshTrigger() {
+ this.render = function() {
+ this.$node.html(templates.mailActions.refreshTrigger);
+ };
+
+ this.refresh = function(event) {
+ this.trigger(document, events.ui.mails.refresh);
+ };
+
+ this.after('initialize', function () {
+ this.render();
+ this.on('click', this.refresh);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_list_actions/ui/toggle_check_all_trigger.js b/web-ui/app/js/mail_list_actions/ui/toggle_check_all_trigger.js
new file mode 100644
index 00000000..aad1f040
--- /dev/null
+++ b/web-ui/app/js/mail_list_actions/ui/toggle_check_all_trigger.js
@@ -0,0 +1,33 @@
+define(
+ [
+ 'flight/lib/component',
+ 'page/events'
+ ],
+
+ function(defineComponent, events) {
+ 'use strict';
+
+ return defineComponent(toggleCheckAllEmailsTrigger);
+
+ function toggleCheckAllEmailsTrigger() {
+ this.defaultAttrs({ });
+
+ this.toggleCheckAll = function(event) {
+ if (this.$node.prop('checked')) {
+ this.trigger(document, events.ui.mails.checkAll);
+ } else {
+ this.trigger(document, events.ui.mails.uncheckAll);
+ }
+ };
+
+ this.setCheckbox = function (event, state) {
+ this.$node.prop('checked', state);
+ };
+
+ this.after('initialize', function () {
+ this.on('click', this.toggleCheckAll);
+ this.on(document, events.ui.mails.hasMailsChecked, this.setCheckbox);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/data/mail_builder.js b/web-ui/app/js/mail_view/data/mail_builder.js
new file mode 100644
index 00000000..27820c1a
--- /dev/null
+++ b/web-ui/app/js/mail_view/data/mail_builder.js
@@ -0,0 +1,79 @@
+/*global _ */
+
+define(['services/model/mail'], function (mailModel) {
+ 'use strict';
+
+ var mail;
+
+ function recipients(mail, place, v) {
+ if (v !== '' && !_.isUndefined(v)) {
+ if(_.isArray(v)) {
+ mail[place] = v;
+ } else {
+ mail[place] = v.split(' ');
+ }
+ } else {
+ mail[place] = [];
+ }
+ }
+
+ return {
+ newMail: function(ident) {
+ ident = _.isUndefined(ident) ? '' : ident;
+
+ mail = {
+ header: {
+ to: [],
+ cc: [],
+ bcc: [],
+ from: undefined,
+ subject: ''
+ },
+ tags: [],
+ body: '',
+ ident: ident
+ };
+ return this;
+ },
+
+ subject: function (subject) {
+ mail.header.subject = subject;
+ return this;
+ },
+
+ body: function(body) {
+ mail.body = body;
+ return this;
+ },
+
+ to: function (to) {
+ recipients(mail.header, 'to', to);
+ return this;
+ },
+
+ cc: function (cc) {
+ recipients(mail.header, 'cc', cc);
+ return this;
+ },
+
+ bcc: function (bcc) {
+ recipients(mail.header, 'bcc', bcc);
+ return this;
+ },
+
+ header: function(name, value) {
+ mail.header[name] = value;
+ return this;
+ },
+
+ tag: function(tag) {
+ if(_.isUndefined(tag)) { tag = 'drafts'; }
+ mail.tags.push(tag);
+ return this;
+ },
+
+ build: function() {
+ return mailModel.create(mail);
+ }
+ };
+});
diff --git a/web-ui/app/js/mail_view/data/mail_sender.js b/web-ui/app/js/mail_view/data/mail_sender.js
new file mode 100644
index 00000000..7440f5a7
--- /dev/null
+++ b/web-ui/app/js/mail_view/data/mail_sender.js
@@ -0,0 +1,74 @@
+define(
+ [
+ 'flight/lib/component',
+ 'mail_view/data/mail_builder',
+ 'page/events'
+ ],
+ function (defineComponent, mailBuilder, events) {
+ 'use strict';
+
+ return defineComponent(mailSender);
+
+ function mailSender() {
+ function successSendMail(on){
+ return function(result) {
+ on.trigger(document, events.mail.sent, result);
+ };
+ }
+
+ function successSaveDraft(on){
+ return function(result){
+ on.trigger(document, events.mail.draftSaved, result);
+ };
+ }
+
+ function failure(on) {
+ return function(xhr, status, error) {
+ on.trigger(events.ui.userAlerts.displayMessage, {message: 'Ops! something went wrong, try again later.'});
+ };
+ }
+
+ this.defaultAttrs({
+ mailsResource: '/mails'
+ });
+
+ this.sendMail = function(event, data) {
+ $.ajax(this.attr.mailsResource, {
+ type: 'POST',
+ dataType: 'json',
+ contentType: 'application/json; charset=utf-8',
+ data: JSON.stringify(data)
+ }).done(successSendMail(this))
+ .fail(failure(this));
+ };
+
+ this.saveMail = function(mail) {
+ var method = (mail.ident === '') ? 'POST' : 'PUT';
+
+ return $.ajax(this.attr.mailsResource, {
+ type: method,
+ dataType: 'json',
+ contentType: 'application/json; charset=utf-8',
+ data: JSON.stringify(mail)
+ });
+ };
+
+ this.saveDraft = function(event, data) {
+ this.saveMail(data)
+ .done(successSaveDraft(this))
+ .fail(failure(this));
+ };
+
+ this.saveMailWithCallback = function(event, data) {
+ this.saveMail(data.mail)
+ .done(function(result) { return data.callback(result); })
+ .fail(function(result) { return data.callback(result); });
+ };
+
+ this.after('initialize', function () {
+ this.on(events.mail.send, this.sendMail);
+ this.on(events.mail.saveDraft, this.saveDraft);
+ this.on(document, events.mail.save, this.saveMailWithCallback);
+ });
+ }
+ });
diff --git a/web-ui/app/js/mail_view/ui/compose_box.js b/web-ui/app/js/mail_view/ui/compose_box.js
new file mode 100644
index 00000000..41954192
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/compose_box.js
@@ -0,0 +1,63 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mixins/with_mail_edit_base',
+ 'page/events',
+ 'mail_view/data/mail_builder'
+ ],
+
+ function (defineComponent, templates, withMailEditBase, events, mailBuilder) {
+ 'use strict';
+
+ return defineComponent(composeBox, withMailEditBase);
+
+ function composeBox() {
+
+ this.defaultAttrs({
+ 'closeButton': '.close-mail-button'
+ });
+
+ this.showNoMessageSelected = function() {
+ this.trigger(events.dispatchers.rightPane.openNoMessageSelected);
+ };
+
+ this.buildMail = function(tag) {
+ return this.builtMail(tag).build();
+ };
+
+ this.builtMail = function(tag) {
+ return mailBuilder.newMail(this.attr.ident)
+ .subject(this.select('subjectBox').val())
+ .to(this.attr.recipientValues.to)
+ .cc(this.attr.recipientValues.cc)
+ .bcc(this.attr.recipientValues.bcc)
+ .body(this.select('bodyBox').val())
+ .tag(tag);
+ };
+
+ this.renderComposeBox = function() {
+ this.render(templates.compose.box, {});
+ this.select('recipientsFields').show();
+ this.on(this.select('closeButton'), 'click', this.showNoMessageSelected);
+ this.enableAutoSave();
+ };
+
+ this.mailDeleted = function(event, data) {
+ if (_.contains(_.pluck(data.mails, 'ident'), this.attr.ident)) {
+ this.trigger(events.dispatchers.rightPane.openNoMessageSelected);
+ }
+ };
+
+ this.after('initialize', function () {
+ this.renderComposeBox();
+
+ this.select('subjectBox').focus();
+ this.on(this.select('cancelButton'), 'click', this.showNoMessageSelected);
+ this.on(document, events.mail.deleted, this.mailDeleted);
+
+ this.on(document, events.mail.sent, this.showNoMessageSelected);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/draft_box.js b/web-ui/app/js/mail_view/ui/draft_box.js
new file mode 100644
index 00000000..95cffd14
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/draft_box.js
@@ -0,0 +1,74 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mixins/with_mail_edit_base',
+ 'page/events',
+ 'mail_view/data/mail_builder'
+ ],
+
+ function (defineComponent, templates, withMailEditBase, events, mailBuilder) {
+ 'use strict';
+
+ return defineComponent(draftBox, withMailEditBase);
+
+ function draftBox() {
+ this.defaultAttrs({
+ closeMailButton: '.close-mail-button'
+ });
+
+ this.showNoMessageSelected = function() {
+ this.trigger(events.dispatchers.rightPane.openNoMessageSelected);
+ };
+
+ this.buildMail = function(tag) {
+ return this.builtMail(tag).build();
+ };
+
+ this.builtMail = function(tag) {
+ return mailBuilder.newMail(this.attr.ident)
+ .subject(this.select('subjectBox').val())
+ .to(this.attr.recipientValues.to)
+ .cc(this.attr.recipientValues.cc)
+ .bcc(this.attr.recipientValues.bcc)
+ .body(this.select('bodyBox').val())
+ .tag(tag);
+ };
+
+ this.renderDraftBox = function(ev, data) {
+ var mail = data.mail;
+ this.attr.ident = mail.ident;
+
+ this.render(templates.compose.box, {
+ recipients: {
+ to: mail.header.to,
+ cc: mail.header.cc,
+ bcc: mail.header.bcc
+ },
+ subject: mail.header.subject,
+ body: mail.body
+ });
+
+ this.select('recipientsFields').show();
+ this.select('bodyBox').focus();
+ this.select('tipMsg').hide();
+ this.enableAutoSave();
+ this.on(this.select('cancelButton'), 'click', this.showNoMessageSelected);
+ this.on(this.select('closeMailButton'), 'click', this.showNoMessageSelected);
+ };
+
+ this.mailDeleted = function(event, data) {
+ if (_.contains(_.pluck(data.mails, 'ident'), this.attr.ident)) {
+ this.trigger(events.dispatchers.rightPane.openNoMessageSelected);
+ }
+ };
+
+ this.after('initialize', function () {
+ this.on(this, events.mail.here, this.renderDraftBox);
+ this.on(document, events.mail.sent, this.showNoMessageSelected);
+ this.on(document, events.mail.deleted, this.mailDeleted);
+ this.trigger(document, events.mail.want, { mail: this.attr.mailIdent, caller: this });
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/draft_save_status.js b/web-ui/app/js/mail_view/ui/draft_save_status.js
new file mode 100644
index 00000000..f56f82f1
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/draft_save_status.js
@@ -0,0 +1,25 @@
+define(
+ [
+ 'flight/lib/component',
+ 'page/events'
+ ],
+
+ function (defineComponent, events) {
+ 'use strict';
+
+ return defineComponent(draftSaveStatus);
+
+ function draftSaveStatus() {
+ this.setMessage = function(msg) {
+ var node = this.$node;
+ return function () { node.text(msg); };
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.mail.saveDraft, this.setMessage('Saving to Drafts...'));
+ this.on(document, events.mail.draftSaved, this.setMessage('Draft Saved.'));
+ this.on(document, events.ui.mail.changedSinceLastSave, this.setMessage(''));
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/forward_box.js b/web-ui/app/js/mail_view/ui/forward_box.js
new file mode 100644
index 00000000..e112b43d
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/forward_box.js
@@ -0,0 +1,66 @@
+/*global Smail */
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'helpers/view_helper',
+ 'mixins/with_hide_and_show',
+ 'mixins/with_compose_inline',
+ 'page/events',
+ 'views/i18n'
+ ],
+
+ function (defineComponent, viewHelper, withHideAndShow, withComposeInline, events, i18n) {
+ 'use strict';
+
+ return defineComponent(forwardBox, withHideAndShow, withComposeInline);
+
+ function forwardBox() {
+ var fwd = function(v) { return i18n('Fwd: ') + v; };
+
+ this.fetchTargetMail = function (ev) {
+ this.trigger(document, events.mail.want, { mail: this.attr.ident, caller: this });
+ };
+
+ this.setupForwardBox = function() {
+ var mail = this.attr.mail;
+ this.attr.subject = fwd(mail.header.subject);
+
+ this.renderInlineCompose('forward-box', {
+ subject: this.attr.subject,
+ recipients: { to: [], cc: []},
+ body: viewHelper.quoteMail(mail)
+ });
+
+ this.on(this.select('subjectDisplay'), 'click', this.showSubjectInput);
+ this.select('recipientsDisplay').hide();
+ this.select('recipientsFields').show();
+ };
+
+ this.showSubjectInput = function() {
+ this.select('subjectDisplay').hide();
+ this.select('subjectInput').show();
+ this.select('subjectInput').focus();
+ };
+
+ this.buildMail = function(tag) {
+ var builder = this.builtMail(tag).subject(this.select('subjectInput').val());
+
+ var headersToFwd = ['bcc', 'cc', 'date', 'from', 'message_id', 'reply_to', 'sender', 'to'];
+ var header = this.attr.mail.header;
+ _.each(headersToFwd, function (h) {
+ if (!_.isUndefined(header[h])) {
+ builder.header('resent_' + h, header[h]);
+ }
+ });
+
+ return builder.build();
+ };
+
+ this.after('initialize', function () {
+ this.setupForwardBox();
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/mail_actions.js b/web-ui/app/js/mail_view/ui/mail_actions.js
new file mode 100644
index 00000000..dc16ea9f
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/mail_actions.js
@@ -0,0 +1,70 @@
+/*global Smail */
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'page/events'
+ ],
+
+ function (defineComponent, templates, events) {
+ 'use strict';
+
+ return defineComponent(mailActions);
+
+ function mailActions() {
+
+ this.defaultAttrs({
+ replyButtonTop: '#reply-button-top',
+ viewMoreActions: '#view-more-actions',
+ replyAllButtonTop: '#reply-all-button-top',
+ deleteButtonTop: '#delete-button-top',
+ moreActions: '#more-actions'
+ });
+
+
+ this.displayMailActions = function () {
+
+ this.$node.html(templates.mails.mailActions());
+
+ this.select('moreActions').hide();
+
+ this.on(this.select('replyButtonTop'), 'click', function () {
+ this.trigger(document, events.ui.replyBox.showReply);
+ }.bind(this));
+
+ this.on(this.select('replyAllButtonTop'), 'click', function () {
+ this.trigger(document, events.ui.replyBox.showReplyAll);
+ this.select('moreActions').hide();
+ }.bind(this));
+
+ this.on(this.select('deleteButtonTop'), 'click', function () {
+ this.trigger(document, events.ui.mail.delete, {mail: this.attr.mail});
+ this.select('moreActions').hide();
+ }.bind(this));
+
+ this.on(this.select('viewMoreActions'), 'click', function () {
+ this.select('moreActions').toggle();
+ }.bind(this));
+
+ this.on(this.select('viewMoreActions'), 'blur', function (event) {
+ var replyButtonTopHover = this.select('replyAllButtonTop').is(':hover');
+ var deleteButtonTopHover = this.select('deleteButtonTop').is(':hover');
+
+ if (replyButtonTopHover || deleteButtonTopHover) {
+ event.preventDefault();
+ } else {
+ this.select('moreActions').hide();
+ }
+ }.bind(this));
+
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.dispatchers.rightPane.clear, this.teardown);
+ this.displayMailActions();
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/mail_view.js b/web-ui/app/js/mail_view/ui/mail_view.js
new file mode 100644
index 00000000..1e27c879
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/mail_view.js
@@ -0,0 +1,242 @@
+/*global Smail */
+/*global _ */
+/*global Bloodhound */
+
+'use strict';
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mail_view/ui/mail_actions',
+ 'helpers/view_helper',
+ 'mixins/with_hide_and_show',
+ 'page/events',
+ 'views/i18n'
+ ],
+
+ function (defineComponent, templates, mailActions, viewHelpers, withHideAndShow, events, i18n) {
+
+ return defineComponent(mailView, mailActions, withHideAndShow);
+
+ function mailView() {
+ this.defaultAttrs({
+ tags: '.tag',
+ newTagInput: '#new-tag-input',
+ newTagButton: '#new-tag-button',
+ addNew: '.add-new',
+ deleteModal: '#delete-modal',
+ trashButton: '#trash-button',
+ archiveButton: '#archive-button',
+ closeModalButton: '.close-reveal-modal',
+ closeMailButton: '.close-mail-button'
+ });
+
+ this.attachTagCompletion = function() {
+ this.attr.tagCompleter = new Bloodhound({
+ datumTokenizer: function(d) { return [d.value]; },
+ queryTokenizer: function(q) { return [q.trim()]; },
+ remote: {
+ url: '/tags?q=%QUERY',
+ filter: function(pr) { return _.map(pr, function(pp) { return {value: pp.name}; }); }
+ }
+ });
+
+ this.attr.tagCompleter.initialize();
+
+ this.select('newTagInput').typeahead({
+ hint: true,
+ highlight: true,
+ minLength: 1
+ }, {
+ source: this.attr.tagCompleter.ttAdapter()
+ });
+ };
+
+ this.displayMail = function (event, data) {
+ this.attr.mail = data.mail;
+
+ var date = new Date(data.mail.header.date);
+ data.mail.header.formattedDate = viewHelpers.getFormattedDate(date);
+
+ data.mail.security_casing = data.mail.security_casing || {};
+ var signed = this.checkSigned(data.mail);
+ var encrypted = this.checkEncrypted(data.mail);
+
+ this.$node.html(templates.mails.fullView({
+ header: data.mail.header,
+ body: [],
+ statuses: viewHelpers.formatStatusClasses(data.mail.status),
+ ident: data.mail.ident,
+ tags: data.mail.tags,
+ encryptionStatus: encrypted,
+ signatureStatus: signed
+ }));
+
+ this.$node.find('.bodyArea').html(viewHelpers.formatMailBody(data.mail));
+ this.trigger(document, events.search.highlightResults, {where: '.bodyArea'});
+ this.trigger(document, events.search.highlightResults, {where: '.subjectArea'});
+ this.trigger(document, events.search.highlightResults, {where: '.msg-header .recipients'});
+
+ this.attachTagCompletion();
+
+ this.select('tags').on('click', function (event) {
+ this.removeTag($(event.target).data('tag'));
+ }.bind(this));
+
+ this.addTagLoseFocus();
+ this.on(this.select('newTagButton'), 'click', this.showNewTagInput);
+ this.on(this.select('newTagInput'), 'keydown', this.handleKeyDown);
+ this.on(this.select('newTagInput'), 'typeahead:selected typeahead:autocompleted', this.createNewTag.bind(this));
+ this.on(this.select('newTagInput'), 'blur', this.addTagLoseFocus);
+ this.on(this.select('trashButton'), 'click', this.moveToTrash);
+ this.on(this.select('archiveButton'), 'click', this.archiveIt);
+ this.on(this.select('closeModalButton'), 'click', this.closeModal);
+ this.on(this.select('closeMailButton'), 'click', this.openNoMessageSelectedPane);
+
+ mailActions.attachTo('#mail-actions', data);
+ this.resetScroll();
+ };
+
+ this.resetScroll = function(){
+ $('#right-pane').scrollTop(0);
+ };
+
+ this.checkEncrypted = function(mail) {
+ if(_.isEmpty(mail.security_casing.locks)) { return 'not-encrypted'; }
+
+ var status = ['encrypted'];
+
+ if(_.any(mail.security_casing.locks, function (lock) { return lock.state === 'valid'; })) { status.push('encryption-valid'); }
+ else { status.push('encryption-failure'); }
+
+ return status.join(' ');
+ };
+
+ this.checkSigned = function(mail) {
+ if(_.isEmpty(mail.security_casing.imprints)) { return 'not-signed'; }
+
+ var status = ['signed'];
+
+ if(_.any(mail.security_casing.imprints, function(imprint) { return imprint.state === 'from_revoked'; })) {
+ status.push('signature-revoked');
+ }
+ if(_.any(mail.security_casing.imprints, function(imprint) { return imprint.state === 'from_expired'; })) {
+ status.push('signature-expired');
+ }
+
+ if(this.isNotTrusted(mail)) {
+ status.push('signature-not-trusted');
+ }
+
+
+ return status.join(' ');
+ };
+
+ this.isNotTrusted = function(mail){
+ return _.any(mail.security_casing.imprints, function(imprint) {
+ if(_.isNull(imprint.seal)){
+ return true;
+ }
+ var currentTrust = _.isUndefined(imprint.seal.trust) ? imprint.seal.validity : imprint.seal.trust;
+ return currentTrust === 'no_trust';
+ });
+ };
+
+ this.openNoMessageSelectedPane = function(ev, data) {
+ this.trigger(document, events.dispatchers.rightPane.openNoMessageSelected);
+ };
+
+ this.createNewTag = function() {
+ var tagsCopy = this.attr.mail.tags.slice();
+ tagsCopy.push(this.select('newTagInput').val());
+ this.attr.tagCompleter.clear();
+ this.attr.tagCompleter.clearPrefetchCache();
+ this.attr.tagCompleter.clearRemoteCache();
+ this.trigger(document, events.mail.tags.update, { ident: this.attr.mail.ident, tags: _.uniq(tagsCopy)});
+ this.trigger(document, events.dispatchers.tags.refreshTagList);
+ };
+
+ this.handleKeyDown = function(event) {
+ var ENTER_KEY = 13;
+ var ESC_KEY = 27;
+
+ if (event.which === ENTER_KEY){
+ event.preventDefault();
+ this.createNewTag();
+ } else if (event.which === ESC_KEY) {
+ event.preventDefault();
+ this.addTagLoseFocus();
+ }
+ };
+
+ this.addTagLoseFocus = function () {
+ this.select('newTagInput').hide();
+ this.select('newTagInput').typeahead('val', '');
+ this.select('addNew').show();
+ };
+
+ this.showNewTagInput = function () {
+ this.select('newTagInput').show();
+ this.select('newTagInput').focus();
+ this.select('addNew').hide();
+ };
+
+ this.removeTag = function (tag) {
+ var filteredTags = _.without(this.attr.mail.tags, tag);
+ if (_.isEmpty(filteredTags)){
+ this.displayMail({}, { mail: this.attr.mail });
+ this.select('deleteModal').foundation('reveal', 'open');
+ } else {
+ this.updateTags(filteredTags);
+ }
+ };
+
+ this.moveToTrash = function(){
+ this.closeModal();
+ this.trigger(document, events.ui.mail.delete, { mail: this.attr.mail });
+ };
+
+ this.archiveIt = function() {
+ this.updateTags([]);
+ this.closeModal();
+ this.trigger(document, events.ui.userAlerts.displayMessage, { message: i18n.get('Your message was archive it!') });
+ this.openNoMessageSelectedPane();
+ };
+
+ this.closeModal = function() {
+ $('#delete-modal').foundation('reveal', 'close');
+ };
+
+ this.updateTags = function(tags) {
+ this.trigger(document, events.mail.tags.update, {ident: this.attr.mail.ident, tags: tags});
+ };
+
+ this.tagsUpdated = function(ev, data) {
+ data = data || {};
+ this.attr.mail.tags = data.tags;
+ this.displayMail({}, { mail: this.attr.mail });
+ this.trigger(document, events.ui.tagList.refresh);
+ };
+
+ this.mailDeleted = function(ev, data) {
+ if (_.contains(_.pluck(data.mails, 'ident'), this.attr.mail.ident)) {
+ this.openNoMessageSelectedPane();
+ }
+ };
+
+ this.fetchMailToShow = function () {
+ this.trigger(events.mail.want, {mail: this.attr.ident, caller: this});
+ };
+
+ this.after('initialize', function () {
+ this.on(this, events.mail.here, this.displayMail);
+ this.on(this, events.mail.notFound, this.openNoMessageSelectedPane);
+ this.on(document, events.dispatchers.rightPane.clear, this.teardown);
+ this.on(document, events.mail.tags.updated, this.tagsUpdated);
+ this.on(document, events.mail.deleted, this.mailDeleted);
+ this.fetchMailToShow();
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/no_message_selected_pane.js b/web-ui/app/js/mail_view/ui/no_message_selected_pane.js
new file mode 100644
index 00000000..64b9a8ff
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/no_message_selected_pane.js
@@ -0,0 +1,25 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mixins/with_hide_and_show',
+ 'page/events'
+ ],
+
+ function(defineComponent, templates, withHideAndShow, events) {
+ 'use strict';
+
+ return defineComponent(noMessageSelectedPane, withHideAndShow);
+
+ function noMessageSelectedPane() {
+ this.render = function() {
+ this.$node.html(templates.noMessageSelected());
+ };
+
+ this.after('initialize', function () {
+ this.render();
+ this.on(document, events.dispatchers.rightPane.clear, this.teardown);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/recipients/recipient.js b/web-ui/app/js/mail_view/ui/recipients/recipient.js
new file mode 100644
index 00000000..967ff61b
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/recipients/recipient.js
@@ -0,0 +1,36 @@
+'use strict';
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates'
+ ],
+
+ function (defineComponent, templates) {
+
+ return defineComponent(recipient);
+
+ function recipient() {
+ this.renderAndPrepend = function (nodeToPrependTo, recipient) {
+ var html = $(templates.compose.fixedRecipient(recipient));
+ html.insertBefore(nodeToPrependTo.children().last());
+ var component = new this.constructor();
+ component.initialize(html, recipient);
+ return component;
+ };
+
+ this.destroy = function () {
+ this.$node.remove();
+ this.teardown();
+ };
+
+ this.select = function () {
+ this.$node.find('.recipient-value').addClass('selected');
+ };
+
+ this.unselect = function () {
+ this.$node.find('.recipient-value').removeClass('selected');
+ };
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/recipients/recipients.js b/web-ui/app/js/mail_view/ui/recipients/recipients.js
new file mode 100644
index 00000000..86f9b9d3
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/recipients/recipients.js
@@ -0,0 +1,127 @@
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'page/events',
+ 'mail_view/ui/recipients/recipients_input',
+ 'mail_view/ui/recipients/recipient',
+ 'mail_view/ui/recipients/recipients_iterator'
+ ],
+ function (defineComponent, templates, events, RecipientsInput, Recipient, RecipientsIterator) {
+ 'use strict';
+
+ return defineComponent(recipients);
+
+ function recipients() {
+ this.defaultAttrs({
+ navigationHandler: '.recipients-navigation-handler'
+ });
+
+ function getAddresses(recipients) {
+ return _.flatten(_.map(recipients, function (e) { return e.attr.address;}));
+ }
+
+ function moveLeft() { this.attr.iterator.moveLeft(); }
+ function moveRight() { this.attr.iterator.moveRight(); }
+ function deleteCurrentRecipient() {
+ this.attr.iterator.deleteCurrent();
+ this.addressesUpdated();
+ }
+
+ var SPECIAL_KEYS_ACTIONS = {
+ 8: deleteCurrentRecipient,
+ 46: deleteCurrentRecipient,
+ 37: moveLeft,
+ 39: moveRight
+ };
+
+ this.addRecipient = function(recipient) {
+ var newRecipient = Recipient.prototype.renderAndPrepend(this.$node, recipient);
+ this.attr.recipients.push(newRecipient);
+ };
+
+ this.recipientEntered = function (event, recipient) {
+ this.addRecipient(recipient);
+ this.addressesUpdated();
+ };
+
+ this.deleteLastRecipient = function () {
+ this.attr.recipients.pop().destroy();
+ this.addressesUpdated();
+ };
+
+ this.enterNavigationMode = function () {
+ this.attr.iterator = new RecipientsIterator({
+ elements: this.attr.recipients,
+ exitInput: this.attr.input.$node
+ });
+
+ this.attr.iterator.current().select();
+ this.attr.input.$node.blur();
+ this.select('navigationHandler').focus();
+ };
+
+ this.leaveNavigationMode = function () {
+ if(this.attr.iterator) { this.attr.iterator.current().unselect(); }
+ this.attr.iterator = null;
+ };
+
+ this.selectLastRecipient = function () {
+ if (this.attr.recipients.length === 0) { return; }
+ this.enterNavigationMode();
+ };
+
+ this.attachInput = function () {
+ this.attr.input = RecipientsInput.prototype.attachAndReturn(this.$node.find('input[type=text]'), this.attr.name);
+ };
+
+ this.processSpecialKey = function (event) {
+ if(SPECIAL_KEYS_ACTIONS.hasOwnProperty(event.which)) { SPECIAL_KEYS_ACTIONS[event.which].apply(this); }
+ };
+
+ this.initializeAddresses = function () {
+ _.each(_.flatten(this.attr.addresses), function (address) {
+ this.addRecipient({ address: address, name: this.attr.name });
+ }.bind(this));
+ };
+
+ this.addressesUpdated = function() {
+ this.trigger(document, events.ui.recipients.updated, {recipientsName: this.attr.name, newRecipients: getAddresses(this.attr.recipients)});
+ };
+
+ this.doCompleteRecipients = function () {
+ var address = this.attr.input.$node.val();
+ if (!_.isEmpty(address)) {
+ var recipient = Recipient.prototype.renderAndPrepend(this.$node, { name: this.attr.name, address: address });
+ this.attr.recipients.push(recipient);
+ this.attr.input.$node.val('');
+ }
+
+ this.trigger(document, events.ui.recipients.updated, {
+ recipientsName: this.attr.name,
+ newRecipients: getAddresses(this.attr.recipients),
+ skipSaveDraft: true
+ });
+
+ };
+
+ this.after('initialize', function () {
+ this.attr.recipients = [];
+ this.attachInput();
+ this.initializeAddresses();
+
+ this.on(events.ui.recipients.deleteLast, this.deleteLastRecipient);
+ this.on(events.ui.recipients.selectLast, this.selectLastRecipient);
+ this.on(events.ui.recipients.entered, this.recipientEntered);
+
+ this.on(document, events.ui.recipients.doCompleteInput, this.doCompleteRecipients);
+
+ this.on(this.attr.input.$node, 'focus', this.leaveNavigationMode);
+ this.on(this.select('navigationHandler'), 'keydown', this.processSpecialKey);
+
+ this.on(document, events.dispatchers.rightPane.clear, this.teardown);
+ });
+ }
+ });
diff --git a/web-ui/app/js/mail_view/ui/recipients/recipients_input.js b/web-ui/app/js/mail_view/ui/recipients/recipients_input.js
new file mode 100644
index 00000000..79780ad2
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/recipients/recipients_input.js
@@ -0,0 +1,140 @@
+/*global _*/
+/*global Bloodhound */
+'use strict';
+
+define([
+ 'flight/lib/component',
+ 'page/events'
+ ],
+ function (defineComponent, events) {
+
+ function recipientsInput() {
+ var EXIT_KEY_CODE_MAP = {
+ 8: 'backspace',
+ 37: 'left'
+ },
+ ENTER_ADDRESS_KEY_CODE_MAP = {
+ 9: 'tab',
+ 186: 'semicolon',
+ 188: 'comma',
+ 32: 'space',
+ 13: 'enter',
+ 27: 'esc'
+ },
+ EVENT_FOR = {
+ 8: events.ui.recipients.deleteLast,
+ 37: events.ui.recipients.selectLast
+ },
+ self;
+
+ var extractContactNames = function (response) {
+ return _.flatten(response.contacts, function (contact) {
+ var filterCriteria = contact.name ?
+ function (e) {
+ return { value: contact.name + ' <' + e + '>' };
+ } :
+ function (e) {
+ return { value: e };
+ };
+
+ return _.map(contact.addresses, filterCriteria);
+ });
+ };
+
+ function createEmailCompleter() {
+ var emailCompleter = new Bloodhound({
+ datumTokenizer: function (d) {
+ return [d.value];
+ },
+ queryTokenizer: function (q) {
+ return [q.trim()];
+ },
+ remote: {
+ url: '/contacts?q=%QUERY',
+ filter: extractContactNames
+ }
+ });
+ emailCompleter.initialize();
+ return emailCompleter;
+ }
+
+ function reset(node) {
+ node.typeahead('val', '');
+ }
+
+ function caretIsInTheBeginningOfInput(input) {
+ return input.selectionStart === 0;
+ }
+
+ function isExitKey(keyPressed) {
+ return EXIT_KEY_CODE_MAP.hasOwnProperty(keyPressed);
+ }
+
+ function isEnterAddressKey(keyPressed) {
+ return ENTER_ADDRESS_KEY_CODE_MAP.hasOwnProperty(keyPressed);
+ }
+
+ this.processSpecialKey = function (event) {
+ var keyPressed = event.which;
+
+ if (isExitKey(keyPressed) && caretIsInTheBeginningOfInput(this.$node[0])) {
+ this.trigger(EVENT_FOR[keyPressed]);
+ return;
+ }
+
+ if (isEnterAddressKey(keyPressed)) {
+ if (!_.isEmpty(this.$node.val())) {
+ this.recipientSelected(null, { value: this.$node.val() });
+ event.preventDefault();
+ }
+ if((keyPressed !== 9 /* tab */)) {
+ event.preventDefault();
+ }
+ }
+
+ };
+
+ this.recipientSelected = function (event, data) {
+ var value = (data && data.value) || this.$node.val();
+
+ this.trigger(this.$node, events.ui.recipients.entered, { name: this.attr.name, address: value });
+ reset(this.$node);
+ };
+
+ this.init = function () {
+ this.$node.typeahead({
+ hint: true,
+ highlight: true,
+ minLength: 1
+ }, {
+ source: createEmailCompleter().ttAdapter()
+ });
+ };
+
+ this.attachAndReturn = function (node, name) {
+ var input = new this.constructor();
+ input.initialize(node, { name: name});
+ return input;
+ };
+
+ this.warnSendButtonOfInputState = function () {
+ var toTrigger = _.isEmpty(this.$node.val()) ? events.ui.recipients.inputHasNoMail : events.ui.recipients.inputHasMail;
+ this.trigger(document, toTrigger, { name: this.attr.name });
+ };
+
+
+ this.after('initialize', function () {
+ self = this;
+ this.init();
+ this.on('typeahead:selected typeahead:autocompleted', this.recipientSelected);
+ this.on(this.$node, 'keydown', this.processSpecialKey);
+ this.on(this.$node, 'keyup', this.warnSendButtonOfInputState);
+
+ this.on(document, events.dispatchers.rightPane.clear, this.teardown);
+ });
+ }
+
+ return defineComponent(recipientsInput);
+
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/recipients/recipients_iterator.js b/web-ui/app/js/mail_view/ui/recipients/recipients_iterator.js
new file mode 100644
index 00000000..73aefc79
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/recipients/recipients_iterator.js
@@ -0,0 +1,41 @@
+define(['helpers/iterator'], function (Iterator) {
+
+ return RecipientsIterator;
+
+ function RecipientsIterator(options) {
+
+ this.iterator = new Iterator(options.elements, options.elements.length - 1);
+ this.input = options.exitInput;
+
+ this.current = function () {
+ return this.iterator.current();
+ };
+
+ this.moveLeft = function () {
+ if (this.iterator.hasPrevious()) {
+ this.iterator.current().unselect();
+ this.iterator.previous().select();
+ }
+ };
+
+ this.moveRight = function () {
+ this.iterator.current().unselect();
+ if (this.iterator.hasNext()) {
+ this.iterator.next().select();
+ } else {
+ this.input.focus();
+ }
+ };
+
+ this.deleteCurrent = function () {
+ this.iterator.removeCurrent().destroy();
+
+ if (this.iterator.hasElements()) {
+ this.iterator.current().select()
+ } else {
+ this.input.focus();
+ }
+ };
+ }
+
+}); \ No newline at end of file
diff --git a/web-ui/app/js/mail_view/ui/reply_box.js b/web-ui/app/js/mail_view/ui/reply_box.js
new file mode 100644
index 00000000..2c39d8d6
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/reply_box.js
@@ -0,0 +1,102 @@
+/*global Smail */
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'helpers/view_helper',
+ 'mixins/with_hide_and_show',
+ 'mixins/with_compose_inline',
+ 'page/events',
+ 'views/i18n'
+ ],
+
+ function (defineComponent, viewHelper, withHideAndShow, withComposeInline, events, i18n) {
+ 'use strict';
+
+ return defineComponent(replyBox, withHideAndShow, withComposeInline);
+
+ function replyBox() {
+ this.defaultAttrs({
+ replyType: 'reply',
+ draftReply: false,
+ mail: null,
+ mailBeingRepliedIdent: undefined
+ });
+
+ this.getRecipients = function() {
+ if (this.attr.replyType === 'replyall') {
+ return this.attr.mail.replyToAllAddress();
+ } else {
+ return this.attr.mail.replyToAddress();
+ }
+ };
+
+ var re = function(v) { return i18n('re') + v; };
+
+ this.setupReplyBox = function() {
+ var recipients, body;
+
+ if (this.attr.draftReply){
+ this.attr.ident = this.attr.mail.ident;
+ this.attr.mailBeingRepliedIdent = this.attr.mail.draft_reply_for;
+
+ recipients = this.attr.mail.recipients();
+ body = this.attr.mail.body;
+ this.attr.subject = this.attr.mail.header.subject;
+ } else {
+ this.attr.mailBeingRepliedIdent = this.attr.mail.ident;
+ recipients = this.getRecipients();
+ body = viewHelper.quoteMail(this.attr.mail);
+ this.attr.subject = re(this.attr.mail.header.subject);
+ }
+
+ this.attr.recipientValues.to = recipients.to;
+ this.attr.recipientValues.cc = recipients.cc;
+
+ this.renderInlineCompose('reply-box', {
+ recipients: recipients,
+ subject: this.attr.subject,
+ body: body
+ });
+
+ this.on(this.select('recipientsDisplay'), 'click keydown', this.showRecipientFields);
+ this.on(this.select('subjectDisplay'), 'click', this.showSubjectInput);
+ };
+
+ this.showRecipientFields = function(ev, data) {
+ if(!ev.keyCode || ev.keyCode === 13){
+ this.select('recipientsDisplay').hide();
+ this.select('recipientsFields').show();
+ $('#recipients-to-area .tt-input').focus();
+ }
+ };
+
+ this.showSubjectInput = function() {
+ this.select('subjectDisplay').hide();
+ this.select('subjectInput').show();
+ this.select('subjectInput').focus();
+ };
+
+ this.buildMail = function(tag) {
+ var builder = this.builtMail(tag).subject(this.select('subjectInput').val());
+ if(!_.isUndefined(this.attr.mail.header.message_id)) {
+ builder.header('in_reply_to', this.attr.mail.header.message_id);
+ }
+
+ if(!_.isUndefined(this.attr.mail.header.list_id)) {
+ builder.header('list_id', this.attr.mail.header.list_id);
+ }
+
+ var mail = builder.build();
+ mail.setDraftReplyFor(this.attr.mailBeingRepliedIdent);
+
+ return mail;
+ };
+
+ this.after('initialize', function () {
+ this.setupReplyBox();
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/reply_section.js b/web-ui/app/js/mail_view/ui/reply_section.js
new file mode 100644
index 00000000..866a255a
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/reply_section.js
@@ -0,0 +1,102 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mail_view/ui/reply_box',
+ 'mail_view/ui/forward_box',
+ 'mixins/with_hide_and_show',
+ 'page/events'
+ ],
+
+ function (defineComponent, templates, ReplyBox, ForwardBox, withHideAndShow, events) {
+ 'use strict';
+
+ return defineComponent(replySection, withHideAndShow);
+
+ function replySection() {
+ this.defaultAttrs({
+ replyButton: '#reply-button',
+ replyAllButton: '#reply-all-button',
+ forwardButton: '#forward-button',
+ replyBox: '#reply-box',
+ replyType: 'reply'
+ });
+
+ this.showReply = function() {
+ this.attr.replyType = 'reply';
+ this.fetchEmailToReplyTo();
+ };
+
+ this.showReplyAll = function() {
+ this.attr.replyType = 'replyall';
+ this.fetchEmailToReplyTo();
+ };
+
+ this.showForward = function() {
+ this.attr.replyType = 'forward';
+ this.fetchEmailToReplyTo();
+ };
+
+ this.render = function () {
+ this.$node.html(templates.compose.replySection);
+
+ this.on(this.select('replyButton'), 'click', this.showReply);
+ this.on(this.select('replyAllButton'), 'click', this.showReplyAll);
+ this.on(this.select('forwardButton'), 'click', this.showForward);
+ };
+
+ this.checkForDraftReply = function() {
+ this.render();
+ this.select('replyButton').hide();
+ this.select('replyAllButton').hide();
+ this.select('forwardButton').hide();
+
+ this.trigger(document, events.mail.draftReply.want, {ident: this.attr.ident});
+ };
+
+ this.fetchEmailToReplyTo = function (ev) {
+ this.trigger(document, events.mail.want, { mail: this.attr.ident, caller: this });
+ };
+
+ this.showDraftReply = function(ev, data) {
+ this.hideButtons();
+ ReplyBox.attachTo(this.select('replyBox'), { mail: data.mail, draftReply: true });
+ };
+
+ this.showReplyComposeBox = function (ev, data) {
+ this.hideButtons();
+ if(this.attr.replyType === 'forward') {
+ ForwardBox.attachTo(this.select('replyBox'), { mail: data.mail });
+ } else {
+ ReplyBox.attachTo(this.select('replyBox'), { mail: data.mail, replyType: this.attr.replyType });
+ }
+ };
+
+ this.hideButtons = function() {
+ this.select('replyButton').hide();
+ this.select('replyAllButton').hide();
+ this.select('forwardButton').hide();
+ };
+
+ this.showButtons = function () {
+ this.select('replyBox').empty();
+ this.select('replyButton').show();
+ this.select('replyAllButton').show();
+ this.select('forwardButton').show();
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.ui.replyBox.showReply, this.showReply);
+ this.on(document, events.ui.replyBox.showReplyAll, this.showReplyAll);
+ this.on(document, events.ui.composeBox.trashReply, this.showButtons);
+ this.on(this, events.mail.here, this.showReplyComposeBox);
+ this.on(document, events.dispatchers.rightPane.clear, this.teardown);
+
+ this.on(document, events.mail.draftReply.notFound, this.showButtons);
+ this.on(document, events.mail.draftReply.here, this.showDraftReply);
+
+ this.checkForDraftReply();
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/mail_view/ui/send_button.js b/web-ui/app/js/mail_view/ui/send_button.js
new file mode 100644
index 00000000..44df3ae6
--- /dev/null
+++ b/web-ui/app/js/mail_view/ui/send_button.js
@@ -0,0 +1,85 @@
+/*global _ */
+'use strict';
+
+define([
+ 'flight/lib/component',
+ 'flight/lib/utils',
+ 'page/events'
+ ],
+ function (defineComponent, utils, events) {
+
+ return defineComponent(sendButton);
+
+ function sendButton() {
+ var RECIPIENTS_BOXES_COUNT = 3;
+
+ this.enableButton = function () {
+ this.$node.prop('disabled', false);
+ };
+
+ this.disableButton = function () {
+ this.$node.prop('disabled', true);
+ };
+
+ this.atLeastOneFieldHasRecipients = function () {
+ return _.any(_.values(this.attr.recipients), function (e) { return !_.isEmpty(e); });
+ };
+
+ this.atLeastOneInputHasMail = function () {
+ return _.any(_.values(this.attr.inputHasMail), function (e) { return e === true; });
+ };
+
+ this.updateButton = function () {
+ if (this.atLeastOneInputHasMail() || this.atLeastOneFieldHasRecipients()) {
+ this.enableButton();
+ } else {
+ this.disableButton();
+ }
+ };
+
+ this.inputHasNoMail = function (ev, data) {
+ this.attr.inputHasMail[data.name] = false;
+ this.updateButton();
+ };
+
+ this.inputHasMail = function (ev, data) {
+ this.attr.inputHasMail[data.name] = true;
+ this.updateButton();
+ };
+
+ this.updateRecipientsForField = function (ev, data) {
+ this.attr.recipients[data.recipientsName] = data.newRecipients;
+ this.attr.inputHasMail[data.recipientsName] = false;
+
+ this.updateButton();
+ };
+
+ this.updateRecipientsAndSendMail = function () {
+
+ this.on(document, events.ui.mail.recipientsUpdated, utils.countThen(RECIPIENTS_BOXES_COUNT, function () {
+ this.trigger(document, events.ui.mail.send);
+ this.off(document, events.ui.mail.recipientsUpdated);
+ }.bind(this)));
+
+ this.trigger(document, events.ui.recipients.doCompleteInput);
+ };
+
+ this.after('initialize', function () {
+ this.attr.recipients = {};
+ this.attr.inputHasMail = {};
+
+ this.on(document, events.ui.recipients.inputHasMail, this.inputHasMail);
+ this.on(document, events.ui.recipients.inputHasNoMail, this.inputHasNoMail);
+ this.on(document, events.ui.recipients.updated, this.updateRecipientsForField);
+
+ this.on(this.$node, 'click', this.updateRecipientsAndSendMail);
+
+ this.on(document, events.dispatchers.rightPane.clear, this.teardown);
+ this.on(document, events.ui.sendbutton.enable, this.enableButton);
+
+ this.disableButton();
+ });
+ }
+
+ }
+);
diff --git a/web-ui/app/js/main.js b/web-ui/app/js/main.js
new file mode 100644
index 00000000..22a11c3a
--- /dev/null
+++ b/web-ui/app/js/main.js
@@ -0,0 +1,58 @@
+'use strict';
+
+requirejs.config({
+ baseUrl: '../',
+ paths: {
+ 'mail_list': 'js/mail_list',
+ 'page': 'js/page',
+ 'flight': 'bower_components/flight',
+ 'hbs': 'js/generated/hbs',
+ 'helpers': 'js/helpers',
+ 'lib': 'js/lib',
+ 'views': 'js/views',
+ 'tags': 'js/tags',
+ 'mail_list_actions': 'js/mail_list_actions',
+ 'user_alerts': 'js/user_alerts',
+ 'mail_view': 'js/mail_view',
+ 'dispatchers': 'js/dispatchers',
+ 'services': 'js/services',
+ 'mixins': 'js/mixins',
+ 'search': 'js/search',
+ 'foundation': 'js/foundation',
+ 'i18next': 'bower_components/i18next/i18next.amd',
+ 'quoted-printable': 'bower_components/quoted-printable'
+ }
+});
+
+require([
+ 'flight/lib/compose',
+ 'flight/lib/debug'
+], function(compose, debug){
+ debug.enable(true);
+ debug.events.logAll();
+});
+
+require(
+ [
+ 'flight/lib/compose',
+ 'flight/lib/registry',
+ 'flight/lib/advice',
+ 'flight/lib/logger',
+ 'flight/lib/debug',
+ 'page/events',
+ 'page/default',
+ 'js/monkey_patching/all'
+ ],
+
+ function(compose, registry, advice, withLogging, debug, events, initializeDefault, _monkeyPatched) {
+ window.Smail = window.Smail || {};
+ window.Smail.events = events;
+
+ compose.mixin(registry, [advice.withAdvice, withLogging]);
+
+ debug.enable(true);
+ debug.events.logAll();
+
+ initializeDefault('');
+ }
+);
diff --git a/web-ui/app/js/mixins/with_compose_inline.js b/web-ui/app/js/mixins/with_compose_inline.js
new file mode 100644
index 00000000..36bc4950
--- /dev/null
+++ b/web-ui/app/js/mixins/with_compose_inline.js
@@ -0,0 +1,63 @@
+/*global _ */
+
+define(
+ [
+ 'page/events',
+ 'views/templates',
+ 'mail_view/data/mail_builder',
+ 'mixins/with_mail_edit_base'
+ ],
+ function(events, templates, mailBuilder, withMailEditBase) {
+ 'use strict';
+
+ function withComposeInline() {
+ this.defaultAttrs({
+ subjectDisplay: '#reply-subject',
+ subjectInput: '#subject-container input',
+ recipientsDisplay: '#all-recipients'
+ });
+
+ this.openMail = function(ev, data) {
+ this.trigger(document, events.ui.mail.open, {ident: this.attr.mail.ident});
+ };
+
+ this.trashReply = function() {
+ this.trigger(document, events.ui.composeBox.trashReply);
+ this.teardown();
+ };
+
+ this.builtMail = function(tag) {
+ return mailBuilder.newMail(this.attr.ident)
+ .subject(this.select('subjectBox').val())
+ .to(this.attr.recipientValues.to)
+ .cc(this.attr.recipientValues.cc)
+ .bcc(this.attr.recipientValues.bcc)
+ .body(this.select('bodyBox').val())
+ .tag(tag);
+ };
+
+ this.renderInlineCompose = function(className, viewData) {
+ this.show();
+ this.render(templates.compose.inlineBox, viewData);
+
+ this.$node.addClass(className);
+ this.select('bodyBox').focus();
+
+ this.enableAutoSave();
+ };
+
+ this.updateIdent = function(ev, data) {
+ this.attr.mail.ident = data.ident;
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.mail.sent, this.openMail);
+ this.on(document, events.mail.deleted, this.trashReply);
+ this.on(document, events.mail.draftSaved, this.updateIdent);
+ });
+
+ withMailEditBase.call(this);
+ }
+
+ return withComposeInline;
+ });
diff --git a/web-ui/app/js/mixins/with_enable_disable_on_event.js b/web-ui/app/js/mixins/with_enable_disable_on_event.js
new file mode 100644
index 00000000..fa574a97
--- /dev/null
+++ b/web-ui/app/js/mixins/with_enable_disable_on_event.js
@@ -0,0 +1,34 @@
+/*global Smail */
+/*global _ */
+
+define([],
+ function () {
+ 'use strict';
+
+ function withEnableDisableOnEvent(ev) {
+ return function () {
+ this.disableElement = function () {
+ this.$node.attr('disabled', 'disabled');
+ };
+
+ this.enableElement = function () {
+ this.$node.removeAttr('disabled');
+ };
+
+ this.toggleEnabled = function (ev, enable) {
+ if (enable) {
+ this.enableElement();
+ } else {
+ this.disableElement();
+ }
+ };
+
+ this.after('initialize', function () {
+ this.on(document, ev, this.toggleEnabled);
+ });
+ };
+ }
+
+ return withEnableDisableOnEvent;
+ }
+);
diff --git a/web-ui/app/js/mixins/with_hide_and_show.js b/web-ui/app/js/mixins/with_hide_and_show.js
new file mode 100644
index 00000000..8cd97ffa
--- /dev/null
+++ b/web-ui/app/js/mixins/with_hide_and_show.js
@@ -0,0 +1,14 @@
+define(function(require) {
+
+ function withHideAndShow() {
+ this.hide = function () {
+ this.$node.hide();
+ };
+ this.show = function () {
+ this.$node.show();
+ };
+ }
+
+ return withHideAndShow;
+
+});
diff --git a/web-ui/app/js/mixins/with_mail_edit_base.js b/web-ui/app/js/mixins/with_mail_edit_base.js
new file mode 100644
index 00000000..fde9823c
--- /dev/null
+++ b/web-ui/app/js/mixins/with_mail_edit_base.js
@@ -0,0 +1,182 @@
+/*global _ */
+
+define(
+ [
+ 'helpers/view_helper',
+ 'mail_view/ui/recipients/recipients',
+ 'mail_view/ui/draft_save_status',
+ 'page/events',
+ 'views/i18n',
+ 'mail_view/ui/send_button',
+ 'flight/lib/utils'
+ ],
+ function(viewHelper, Recipients, DraftSaveStatus, events, i18n, SendButton, utils) {
+ 'use strict';
+
+ function withMailEditBase() {
+
+ this.defaultAttrs({
+ bodyBox: '#text-box',
+ sendButton: '#send-button',
+ draftButton: '#draft-button',
+ cancelButton: '#cancel-button',
+ trashButton: '#trash-button',
+ toArea: '#recipients-to-area',
+ ccArea: '#recipients-cc-area',
+ bccArea: '#recipients-bcc-area',
+ ccsTrigger: '#ccs-trigger',
+ bccsTrigger: '#bccs-trigger',
+ toTrigger: '#to-trigger',
+ subjectBox: '#subject',
+ tipMsg: '.tip-msg',
+ draftSaveStatus: '#draft-save-status',
+ recipientsFields: '#recipients-fields',
+ currentTag: '',
+ recipientValues: {to: [], cc: [], bcc: []},
+ saveDraftInterval: 3000
+ });
+
+ this.attachRecipients = function (context) {
+ Recipients.attachTo(this.select('toArea'), { name: 'to', addresses: context.recipients.to });
+ Recipients.attachTo(this.select('ccArea'), { name: 'cc', addresses: context.recipients.cc || []});
+ Recipients.attachTo(this.select('bccArea'), { name: 'bcc', addresses: context.recipients.bcc || []});
+ };
+
+ this.render = function(template, context) {
+ this.$node.html(template(context));
+
+ if(!context || _.isEmpty(context)){
+ context.recipients = {to: [], cc: [], bcc: []};
+ }
+ this.attr.recipientValues = context.recipients;
+ this.attachRecipients(context);
+
+ this.on(this.select('draftButton'), 'click', this.buildAndSaveDraft);
+ this.on(this.select('trashButton'), 'click', this.trashMail);
+ SendButton.attachTo(this.select('sendButton'));
+
+ if (!_.isEmpty(this.attr.recipientValues.to.concat(this.attr.recipientValues.cc))) {
+ this.trigger(document, events.ui.sendbutton.enable);
+ }
+ };
+
+ this.enableAutoSave = function () {
+ this.select('bodyBox').on('input', this.monitorInput.bind(this));
+ this.select('subjectBox').on('input', this.monitorInput.bind(this));
+ DraftSaveStatus.attachTo(this.select('draftSaveStatus'));
+ };
+
+ this.deleteMail = function(data) {
+ this.attr.ident = data.ident;
+ var mail = this.buildMail();
+ this.trigger(document, events.ui.mail.delete, { mail: mail });
+ };
+
+ this.monitorInput = function() {
+ this.trigger(events.ui.mail.changedSinceLastSave);
+ this.cancelPostponedSaveDraft();
+ var mail = this.buildMail();
+ this.postponeSaveDraft(mail);
+ };
+
+ this.trashMail = function() {
+ this.cancelPostponedSaveDraft();
+ this.trigger(document, events.mail.save, {
+ mail: this.buildMail(),
+ callback: this.deleteMail.bind(this)
+ });
+ };
+
+ this.sendMail = function () {
+ this.cancelPostponedSaveDraft();
+ var mail = this.buildMail('sent');
+
+ if (allRecipientsAreEmails(mail)) {
+ this.trigger(events.mail.send, mail);
+ } else {
+ this.trigger(
+ events.ui.userAlerts.displayMessage,
+ {message: i18n.get('One or more of the recipients are not valid emails')}
+ );
+ }
+ };
+
+ this.buildAndSaveDraft = function () {
+ var mail = this.buildMail();
+ this.saveDraft(mail);
+ };
+
+ this.recipientsUpdated = function (ev, data) {
+ this.attr.recipientValues[data.recipientsName] = data.newRecipients;
+ this.trigger(document, events.ui.mail.recipientsUpdated);
+ if (data.skipSaveDraft) { return; }
+
+ this.attr.silent = true;
+ var mail = this.buildMail();
+ this.postponeSaveDraft(mail);
+ };
+
+ this.saveDraft = function (mail) {
+ this.cancelPostponedSaveDraft();
+ this.trigger(document, events.mail.saveDraft, mail);
+ };
+
+ this.cancelPostponedSaveDraft = function() {
+ clearTimeout(this.attr.timeout);
+ };
+
+ this.postponeSaveDraft = function (mail) {
+ this.cancelPostponedSaveDraft();
+
+ this.attr.timeout = window.setTimeout(_.bind(function() {
+ this.attr.silent = true;
+ this.saveDraft(mail);
+ }, this), this.attr.saveDraftInterval);
+ };
+
+ this.draftSaved = function(event, data) {
+ this.attr.ident = data.ident;
+ if(!this.attr.silent) {
+ this.trigger(document, events.ui.userAlerts.displayMessage, { message: i18n.get('Saved as draft.') });
+ }
+ delete this.attr.silent;
+ };
+
+ this.validateAnyRecipient = function () {
+ return !_.isEmpty(_.flatten(_.values(this.attr.recipientValues)));
+ };
+
+ // Validators and formatters
+ function allRecipientsAreEmails(mail) {
+ var allRecipients = mail.header.to.concat(mail.header.cc).concat(mail.header.bcc);
+ return _.isEmpty(allRecipients) ? false : _.all(allRecipients, emailFormatChecker);
+ }
+
+ function emailFormatChecker(email) {
+ var emailFormat = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailFormat.test(email);
+ }
+
+ this.saveTag = function(ev, data) {
+ this.attr.currentTag = data.tag;
+ };
+
+ this.mailSent = function() {
+ this.trigger(document, events.ui.userAlerts.displayMessage, { message: 'Your message was sent!' });
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.dispatchers.rightPane.clear, this.teardown);
+ this.on(document, events.ui.recipients.updated, this.recipientsUpdated);
+ this.on(document, events.mail.draftSaved, this.draftSaved);
+ this.on(document, events.mail.sent, this.mailSent);
+
+ this.on(document, events.ui.mail.send, this.sendMail);
+
+ this.on(document, events.ui.tag.selected, this.saveTag);
+
+ });
+ }
+
+ return withMailEditBase;
+ });
diff --git a/web-ui/app/js/monkey_patching/all.js b/web-ui/app/js/monkey_patching/all.js
new file mode 100644
index 00000000..e0f98823
--- /dev/null
+++ b/web-ui/app/js/monkey_patching/all.js
@@ -0,0 +1 @@
+require(['js/monkey_patching/array', 'js/monkey_patching/post_message'], function () {}); \ No newline at end of file
diff --git a/web-ui/app/js/monkey_patching/array.js b/web-ui/app/js/monkey_patching/array.js
new file mode 100644
index 00000000..cf0e71ed
--- /dev/null
+++ b/web-ui/app/js/monkey_patching/array.js
@@ -0,0 +1,11 @@
+(function () {
+ 'use strict';
+
+ // Array Remove - By John Resig (MIT Licensed)
+ Array.prototype.remove = function (from, to) {
+ var rest = this.slice((to || from) + 1 || this.length);
+ this.length = from < 0 ? this.length + from : from;
+ return this.push.apply(this, rest);
+ };
+
+}()); \ No newline at end of file
diff --git a/web-ui/app/js/monkey_patching/post_message.js b/web-ui/app/js/monkey_patching/post_message.js
new file mode 100644
index 00000000..87576900
--- /dev/null
+++ b/web-ui/app/js/monkey_patching/post_message.js
@@ -0,0 +1,16 @@
+/*
+ * origin window.postMessage fails with non serializable objects, so we fallback to console.log to do the job
+ */
+(function () {
+ 'use strict';
+
+ var originalPostMessage = window.postMessage;
+ window.postMessage = function(a, b) {
+ try {
+ originalPostMessage(a, b);
+ } catch (e) {
+ console.log(a, b);
+ }
+ };
+
+}());
diff --git a/web-ui/app/js/page/default.js b/web-ui/app/js/page/default.js
new file mode 100644
index 00000000..e47b2f4d
--- /dev/null
+++ b/web-ui/app/js/page/default.js
@@ -0,0 +1,87 @@
+define(
+ [
+ 'mail_view/ui/compose_box',
+ 'mail_list_actions/ui/mail_list_actions',
+ 'user_alerts/ui/user_alerts',
+ 'mail_list/ui/mail_list',
+ 'mail_view/ui/no_message_selected_pane',
+ 'mail_view/ui/mail_view',
+ 'mail_view/ui/mail_actions',
+ 'mail_view/ui/reply_section',
+ 'mail_view/data/mail_sender',
+ 'services/mail_service',
+ 'services/delete_service',
+ 'tags/ui/tag_list',
+ 'tags/data/tags',
+ 'page/router',
+ 'dispatchers/right_pane_dispatcher',
+ 'dispatchers/middle_pane_dispatcher',
+ 'dispatchers/left_pane_dispatcher',
+ 'search/search_trigger',
+ 'search/results_highlighter',
+ 'foundation/off_canvas',
+ 'page/pane_contract_expand',
+ 'views/i18n',
+ 'views/recipientListFormatter',
+ 'flight/lib/logger'
+ ],
+
+ function (
+ composeBox,
+ mailListActions,
+ userAlerts,
+ mailList,
+ noMessageSelectedPane,
+ mailView,
+ mailViewActions,
+ replyButton,
+ mailSender,
+ mailService,
+ deleteService,
+ tagList,
+ tags,
+ router,
+ rightPaneDispatcher,
+ middlePaneDispatcher,
+ leftPaneDispatcher,
+ searchTrigger,
+ resultsHighlighter,
+ offCanvas,
+ paneContractExpand,
+ viewI18n,
+ recipientListFormatter,
+ withLogging) {
+
+ 'use strict';
+ function initialize(path) {
+ viewI18n.init(path);
+ paneContractExpand.attachTo(document);
+
+ userAlerts.attachTo('#user-alerts');
+
+ mailList.attachTo('#mail-list');
+ mailListActions.attachTo('#list-actions');
+
+ searchTrigger.attachTo('#search-trigger');
+ resultsHighlighter.attachTo(document);
+
+ mailSender.attachTo(document);
+
+ mailService.attachTo(document);
+ deleteService.attachTo(document);
+
+ tags.attachTo(document);
+ tagList.attachTo('#tag-list');
+
+ router.attachTo(document);
+
+ rightPaneDispatcher.attachTo(document);
+ middlePaneDispatcher.attachTo(document);
+ leftPaneDispatcher.attachTo(document);
+
+ offCanvas.attachTo(document);
+ }
+
+ return initialize;
+ }
+);
diff --git a/web-ui/app/js/page/events.js b/web-ui/app/js/page/events.js
new file mode 100644
index 00000000..6b39096c
--- /dev/null
+++ b/web-ui/app/js/page/events.js
@@ -0,0 +1,168 @@
+define(function () {
+ 'use strict';
+
+ var events = {
+ router: {
+ pushState: 'router:pushState'
+ },
+ ui: {
+ sendbutton: {
+ enable: 'ui:sendbutton:enable'
+ },
+ middlePane: {
+ expand: 'ui:middlePane:expand',
+ contract: 'ui:middlePane:contract'
+ },
+ userAlerts: {
+ displayMessage: 'ui:userAlerts:displayMessage'
+ },
+ tag: {
+ selected: 'ui:tagSelected',
+ select: 'ui:tagSelect'
+ },
+ tags: {
+ loaded: 'ui:tagsLoaded'
+ },
+ tagList: {
+ refresh: 'ui:tagList:refresh',
+ load: 'ui:tagList:load'
+ },
+ mails: {
+ refresh: 'ui:mails:refresh',
+ fetchByTag: 'ui:mails:fetchByTag',
+ cleanSelected: 'ui:mails:cleanSelected',
+ checkAll: 'ui:mails:checkAll',
+ uncheckAll: 'ui:mails:uncheckAll',
+ hasMailsChecked: 'ui:mails:hasMailsChecked'
+ },
+ mail: {
+ open: 'ui:mail:open',
+ updateSelected: 'ui:mail:updateSelected',
+ delete: 'ui:mail:delete',
+ deleteMany: 'ui:mail:deleteMany',
+ wantChecked: 'ui:mail:wantChecked',
+ hereChecked: 'ui:mail:hereChecked',
+ checked: 'ui:mail:checked',
+ unchecked: 'ui:mail:unchecked',
+ changedSinceLastSave: 'ui:mail:changedSinceLastSave',
+ send: 'ui:mail:send',
+ recipientsUpdated: 'ui:mail:recipientsUpdated'
+ },
+ page: {
+ previous: 'ui:page:previous',
+ next: 'ui:page:next',
+ changed: 'ui:page:changed'
+ },
+ composeBox: {
+ newMessage: 'ui:composeBox:newMessage',
+ newReply: 'ui:composeBox:newReply',
+ trashReply: 'ui:composeBox:trashReply',
+ requestCancelReply: 'ui:composeBox:requestCancelReply'
+ },
+ replyBox: {
+ showReply: 'ui:replyBox:showReply',
+ showReplyAll: 'ui:replyBox:showReplyAll'
+ },
+ recipients: {
+ entered: 'ui:recipients:entered',
+ updated: 'ui:recipients:updated',
+ deleteLast: 'ui:recipients:deleteLast',
+ selectLast: 'ui:recipients:selectLast',
+ unselectAll: 'ui:recipients:unselectAll',
+ addressesExist: 'ui:recipients:addressesExist',
+ inputHasMail: 'ui:recipients:inputHasMail',
+ inputHasNoMail: 'ui:recipients:inputHasNoMail',
+ doCompleteInput: 'ui:recipients:doCompleteInput',
+ doCompleteRecipients: 'ui:recipients:doCompleteRecipients'
+ }
+ },
+ search: {
+ perform: 'search:perform',
+ results: 'search:results',
+ empty: 'search:empty',
+ highlightResults: 'search:highlightResults'
+ },
+ mail: {
+ here: 'mail:here',
+ want: 'mail:want',
+ send: 'mail:send',
+ sent: 'mail:sent',
+ read: 'mail:read',
+ unread: 'mail:unread',
+ delete: 'mail:delete',
+ deleteMany: 'mail:deleteMany',
+ deleted: 'mail:deleted',
+ saveDraft: 'draft:save',
+ draftSaved: 'draft:saved',
+ draftReply: {
+ want: 'mail:draftReply:want',
+ here: 'mail:draftReply:here',
+ notFound: 'mail:draftReply:notFound'
+ },
+ notFound: 'mail:notFound',
+ save: 'mail:saved',
+ tags: {
+ update: 'mail:tags:update',
+ updated: 'mail:tags:updated'
+ }
+ },
+ mails: {
+ available: 'mails:available',
+ availableForRefresh: 'mails:available:refresh',
+ teardown: 'mails:teardown'
+ },
+ tags: {
+ want: 'tags:want',
+ received: 'tags:received',
+ teardown: 'tags:teardown'
+ },
+ route: {
+ toUrl: 'route:toUrl'
+ },
+
+ components: {
+ composeBox: {
+ open: 'components:composeBox:open',
+ close: 'components:composeBox:close'
+ },
+ mailPane: {
+ open: 'components:mailPane:open',
+ close: 'components:mailPane:close'
+ },
+ mailView: {
+ show: 'components:mailView:show',
+ close: 'components:mailView:close'
+ },
+ replySection: {
+ initialize: 'components:replySection:initialize',
+ close: 'components:replySection:close'
+ },
+ noMessageSelectedPane: {
+ open: 'components:noMessageSelectedPane:open',
+ close: 'components:noMessageSelectedPane:close'
+ }
+ },
+
+ dispatchers: {
+ rightPane: {
+ openComposeBox: 'dispatchers:rightPane:openComposeBox',
+ openNoMessageSelected: 'dispatchers:rightPane:openNoMessageSelected',
+ openNoMessageSelectedWithoutPushState: 'dispatchers:rightPane:openNoMessageSelectedWithoutPushState',
+ refreshMailList: 'dispatchers:rightPane:refreshMailList',
+ openDraft: 'dispatchers:rightPane:openDraft',
+ selectTag: 'dispatchers:rightPane:selectTag',
+ clear: 'dispatchers:rightPane:clear'
+ },
+ middlePane: {
+ refreshMailList: 'dispatchers:middlePane:refreshMailList',
+ cleanSelected: 'dispatchers:middlePane:unselect',
+ resetScroll: 'dispatchers:middlePane:resetScroll'
+ },
+ tags: {
+ refreshTagList: 'dispatchers:tag:refresh'
+ }
+ }
+ };
+
+ return events;
+});
diff --git a/web-ui/app/js/page/pane_contract_expand.js b/web-ui/app/js/page/pane_contract_expand.js
new file mode 100644
index 00000000..464c78b0
--- /dev/null
+++ b/web-ui/app/js/page/pane_contract_expand.js
@@ -0,0 +1,34 @@
+'use strict';
+
+define(['flight/lib/component', 'page/events'], function (describeComponent, events) {
+
+ return describeComponent(paneContractExpand);
+
+ function paneContractExpand() {
+ this.defaultAttrs({
+ RIGHT_PANE_EXPAND_CLASSES: 'small-7 medium-7 large-7 columns',
+ RIGHT_PANE_CONTRACT_CLASSES: 'small-7 medium-4 large-4 columns',
+ MIDDLE_PANE_EXPAND_CLASSES: 'small-5 medium-8 large-8 columns no-padding',
+ MIDDLE_PANE_CONTRACT_CLASSES: 'small-5 medium-5 large-5 columns no-padding'
+ });
+
+ this.expandMiddlePaneContractRightPane = function () {
+ $('#middle-pane-container').attr('class', this.attr.MIDDLE_PANE_EXPAND_CLASSES);
+ $('#right-pane').attr('class', this.attr.RIGHT_PANE_CONTRACT_CLASSES);
+ };
+
+ this.contractMiddlePaneExpandRightPane = function () {
+ $('#middle-pane-container').attr('class', this.attr.MIDDLE_PANE_CONTRACT_CLASSES);
+ $('#right-pane').attr('class', this.attr.RIGHT_PANE_EXPAND_CLASSES);
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.ui.mail.open, this.contractMiddlePaneExpandRightPane);
+ this.on(document, events.dispatchers.rightPane.openComposeBox, this.contractMiddlePaneExpandRightPane);
+ this.on(document, events.dispatchers.rightPane.openDraft, this.contractMiddlePaneExpandRightPane);
+ this.on(document, events.dispatchers.rightPane.openNoMessageSelected, this.expandMiddlePaneContractRightPane);
+ this.expandMiddlePaneContractRightPane()
+ });
+
+ }
+});
diff --git a/web-ui/app/js/page/router.js b/web-ui/app/js/page/router.js
new file mode 100644
index 00000000..cc51100b
--- /dev/null
+++ b/web-ui/app/js/page/router.js
@@ -0,0 +1,51 @@
+define(['flight/lib/component', 'page/events', 'page/router/url_params'], function (defineComponent, events, urlParams) {
+ 'use strict';
+
+ return defineComponent(function () {
+ this.defaultAttrs({
+ history: window.history
+ });
+
+ function createHash(data) {
+ var hash = "/#/" + data.tag;
+ if (!_.isUndefined(data.mailIdent)) {
+ hash += '/mail/' + data.mailIdent;
+ }
+ return hash;
+ }
+
+ function createState(data, previousState) {
+ return {
+ tag: data.tag || (previousState && previousState.tag) || urlParams.defaultTag(),
+ mailIdent: data.mailIdent,
+ isDisplayNoMessageSelected: !!data.isDisplayNoMessageSelected
+ };
+ }
+
+ this.smailPushState = function (ev, data) {
+ if (!data.fromPopState) {
+ var nextState = createState(data, this.attr.history.state);
+ this.attr.history.pushState(nextState, '', createHash(nextState));
+ }
+ };
+
+ this.smailPopState = function (ev) {
+ var state = ev.state || {};
+
+ this.trigger(document, events.ui.tag.select, {
+ tag: state.tag || urlParams.getTag(),
+ mailIdent: state.mailIdent,
+ fromPopState: true
+ });
+
+ if (ev.state.isDisplayNoMessageSelected) {
+ this.trigger(document, events.dispatchers.rightPane.openNoMessageSelectedWithoutPushState);
+ }
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.router.pushState, this.smailPushState);
+ window.onpopstate = this.smailPopState.bind(this);
+ });
+ });
+});
diff --git a/web-ui/app/js/page/router/url_params.js b/web-ui/app/js/page/router/url_params.js
new file mode 100644
index 00000000..d4fb28f5
--- /dev/null
+++ b/web-ui/app/js/page/router/url_params.js
@@ -0,0 +1,40 @@
+define([], function () {
+
+ function defaultTag() {
+ return 'inbox';
+ }
+
+ function getDocumentHash() {
+ return document.location.hash.replace(/\/$/, '');
+ }
+
+ function hashTag(hash) {
+ if (hasMailIdent(hash)) {
+ return /\/(.+)\/mail\/\d+$/.exec(getDocumentHash())[1];
+ }
+ return hash.substring(2);
+ }
+
+
+ function getTag() {
+ if (document.location.hash !== '') {
+ return hashTag(getDocumentHash());
+ }
+ return defaultTag();
+ }
+
+ function hasMailIdent() {
+ return getDocumentHash().match(/mail\/\d+$/);
+ }
+
+ function getMailIdent() {
+ return /mail\/(\d+)$/.exec(getDocumentHash())[1];
+ }
+
+ return {
+ getTag: getTag,
+ hasMailIdent: hasMailIdent,
+ getMailIdent: getMailIdent,
+ defaultTag: defaultTag
+ };
+});
diff --git a/web-ui/app/js/search/results_highlighter.js b/web-ui/app/js/search/results_highlighter.js
new file mode 100644
index 00000000..c40f917b
--- /dev/null
+++ b/web-ui/app/js/search/results_highlighter.js
@@ -0,0 +1,53 @@
+/*global Smail */
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'page/events'
+ ], function (defineComponent, events) {
+
+ 'use strict';
+
+ return defineComponent(resultsHighlighter);
+
+ function resultsHighlighter(){
+ this.defaultAttrs({
+ keywords: []
+ });
+
+ this.getKeywordsSearch = function (event, data) {
+ this.attr.keywords = data.query.split(' ').map(function(keyword) {
+ return keyword.toLowerCase();
+ });
+ };
+
+ this.highlightResults = function (event, data) {
+ var domIdent = data.where;
+ if(this.attr.keywords) {
+ _.each(this.attr.keywords, function (keyword) {
+ $(domIdent).highlightRegex(new RegExp(keyword, 'i'), {
+ tagType: 'em',
+ className: 'search-highlight'
+ });
+ });
+ }
+ };
+
+ this.clearHighlights = function (event, data) {
+ this.attr.keywords = [];
+ _.each($('em.search-highlight'), function(highlighted) {
+ var jqueryHighlighted = $(highlighted);
+ var text = jqueryHighlighted.text();
+ jqueryHighlighted.replaceWith(text);
+ });
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.search.perform, this.getKeywordsSearch);
+ this.on(document, events.ui.tag.select, this.clearHighlights);
+
+ this.on(document, events.search.highlightResults, this.highlightResults);
+ });
+ }
+});
diff --git a/web-ui/app/js/search/search_trigger.js b/web-ui/app/js/search/search_trigger.js
new file mode 100644
index 00000000..4f8a7a5e
--- /dev/null
+++ b/web-ui/app/js/search/search_trigger.js
@@ -0,0 +1,68 @@
+/*global _ */
+/*global Smail */
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'page/events'
+ ], function (defineComponent, templates, events) {
+
+ 'use strict';
+
+ return defineComponent(searchTrigger);
+
+ function searchTrigger() {
+ var placeHolder = 'Search results for: ';
+
+ this.defaultAttrs({
+ input: 'input[type=search]',
+ form: 'form'
+ });
+
+ this.render = function() {
+ this.$node.html(templates.search.trigger());
+ };
+
+ this.search = function(ev, data) {
+ ev.preventDefault();
+ var input = this.select('input');
+ var value = input.val();
+ input.blur();
+ if(!_.isEmpty(value)){
+ this.trigger(document, events.ui.tag.select, { tag: 'all', skipMailListRefresh: true });
+ this.trigger(document, events.search.perform, { query: value });
+ } else {
+ this.trigger(document, events.ui.tag.select, { tag: 'all'});
+ this.trigger(document, events.search.empty);
+ }
+ };
+
+ this.clearInput = function(event, data) {
+ if (!data.skipMailListRefresh)
+ this.select('input').val('');
+ };
+
+ this.showOnlySearchTerms = function(event){
+ var value = this.select('input').val();
+ var searchTerms = value.slice(placeHolder.length);
+ this.select('input').val(searchTerms);
+ };
+
+ this.showSearchTermsAndPlaceHolder = function(event){
+ var value = this.select('input').val();
+ if (value.length > 0){
+ this.select('input').val(placeHolder + value);
+ }
+ };
+
+ this.after('initialize', function () {
+ this.render();
+ this.on(this.select('form'), 'submit', this.search);
+ this.on(this.select('input'), 'focus', this.showOnlySearchTerms);
+ this.on(this.select('input'), 'blur', this.showSearchTermsAndPlaceHolder);
+ this.on(document, events.ui.tag.selected, this.clearInput);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/services/delete_service.js b/web-ui/app/js/services/delete_service.js
new file mode 100644
index 00000000..7c6e8cc4
--- /dev/null
+++ b/web-ui/app/js/services/delete_service.js
@@ -0,0 +1,43 @@
+/*global _ */
+
+define(['flight/lib/component', 'page/events', 'views/i18n'], function (defineComponent, events, i18n) {
+ 'use strict';
+
+ return defineComponent(function() {
+
+ this.successDeleteMessageFor = function(mail) {
+ return mail.isInTrash() ?
+ i18n('Your message was permanently deleted!') :
+ i18n('Your message was moved to trash!');
+ };
+
+ this.successDeleteManyMessageFor = function(mail) {
+ return mail.isInTrash() ?
+ i18n('Your messages were permanently deleted!') :
+ i18n('Your messages were moved to trash!');
+ };
+
+ this.deleteEmail = function (event, data) {
+ this.trigger(document, events.mail.delete, {
+ mail: data.mail,
+ successMessage: this.successDeleteMessageFor(data.mail)
+ });
+ };
+
+ this.deleteManyEmails = function (event, data) {
+ var emails = _.values(data.checkedMails),
+ firstEmail = emails[_.first(_.keys(emails))];
+
+ this.trigger(document, events.mail.deleteMany, {
+ mails: emails,
+ successMessage: this.successDeleteManyMessageFor(firstEmail)
+ });
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.ui.mail.delete, this.deleteEmail);
+ this.on(document, events.ui.mail.deleteMany, this.deleteManyEmails);
+ });
+
+ });
+});
diff --git a/web-ui/app/js/services/mail_service.js b/web-ui/app/js/services/mail_service.js
new file mode 100644
index 00000000..86642f37
--- /dev/null
+++ b/web-ui/app/js/services/mail_service.js
@@ -0,0 +1,254 @@
+/*global _ */
+/*global Smail */
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/i18n',
+ 'services/model/mail',
+ 'page/events'
+ ], function (defineComponent, i18n, Mail, events) {
+
+ 'use strict';
+
+ return defineComponent(mailService);
+
+ function mailService() {
+ var that;
+
+ this.defaultAttrs({
+ mailsResource: '/mails',
+ singleMailResource: '/mail',
+ currentTag: '',
+ lastQuery: '',
+ currentPage: 0,
+ numPages: 0,
+ w: 25
+ });
+
+ this.errorMessage = function(msg) {
+ return function() {
+ that.trigger(document, events.ui.userAlerts.displayMessage, { message: msg });
+ };
+ };
+
+ this.updateTags = function(ev, data) {
+ var that = this;
+ var ident = data.ident;
+ $.ajax('/mail/' + ident + '/tags', {
+ type: 'POST',
+ contentType: 'application/json; charset=utf-8',
+ data: JSON.stringify({newtags: data.tags})
+ }).done(function(data) {
+ that.refreshResults();
+ $(document).trigger(events.mail.tags.updated, { ident: ident, tags: data });
+ })
+ .fail(this.errorMessage(i18n('Could not update mail tags')));
+ };
+
+ this.readMail = function(ev, data) {
+ var mailIdents;
+ if (data.checkedMails) {
+ mailIdents = _.map(data.checkedMails, function(mail) {
+ return mail.ident;
+ });
+ $.ajax( '/mails/read', {
+ type: 'POST',
+ data: {idents: JSON.stringify(mailIdents)}
+ }).done(this.triggerMailsRead(data.checkedMails));
+ } else {
+ $.ajax('/mail/' + data.ident + '/read', {type: 'POST'});
+ }
+ };
+
+ this.unreadMail = function(ev, data) {
+ var mailIdents;
+ if (data.checkedMails) {
+ mailIdents = _.map(data.checkedMails, function(mail) {
+ return mail.ident;
+ });
+ $.ajax( '/mails/unread', {
+ type: 'POST',
+ data: {idents: JSON.stringify(mailIdents)}
+ }).done(this.triggerMailsRead(data.checkedMails));
+ } else {
+ $.ajax('/mail/' + data.ident + '/read', {type: 'POST'});
+ }
+ };
+
+ this.triggerMailsRead = function(mails) {
+ return _.bind(function() {
+ this.refreshResults();
+ this.trigger(document, events.ui.mail.unchecked, { mails: mails });
+ this.trigger(document, events.ui.mails.hasMailsChecked, false);
+ }, this);
+ };
+
+ this.triggerDeleted = function(dataToDelete) {
+ return _.bind(function() {
+ var mails = dataToDelete.mails || [dataToDelete.mail];
+
+ this.refreshResults();
+ this.trigger(document, events.ui.userAlerts.displayMessage, { message: dataToDelete.successMessage});
+ this.trigger(document, events.ui.mail.unchecked, { mails: mails });
+ this.trigger(document, events.ui.mails.hasMailsChecked, false);
+ this.trigger(document, events.mail.deleted, { mails: mails });
+ }, this);
+ };
+
+ this.deleteMail = function(ev, data) {
+ $.ajax('/mail/' + data.mail.ident,
+ {type: 'DELETE'})
+ .done(this.triggerDeleted(data))
+ .fail(this.errorMessage(i18n('Could not delete email')));
+ };
+
+ this.deleteManyMails = function(ev, data) {
+ var dataToDelete = data;
+ var mailIdents = _.map(data.mails, function(mail) {
+ return mail.ident;
+ });
+
+ $.ajax('/mails', {
+ type: 'DELETE',
+ data: {idents: JSON.stringify(mailIdents)}
+ }).done(this.triggerDeleted(dataToDelete))
+ .fail(this.errorMessage(i18n('Could not delete emails')));
+ };
+
+ function compileQuery(data) {
+ var query = 'tag:"' + that.attr.currentTag + '"';
+
+ if (data.tag === 'all') {
+ query = 'in:all';
+ }
+ return query;
+ }
+
+ this.fetchByTag = function(ev, data) {
+ this.attr.currentTag = data.tag;
+ this.updateCurrentPageNumber(0);
+
+ this.fetchMail(compileQuery(data), this.attr.currentTag, false, data);
+ };
+
+ this.refreshResults = function(ev, data) {
+ var query = this.attr.lastQuery;
+ this.fetchMail(query, this.attr.currentTag, true);
+ };
+
+ this.newSearch = function(ev, data) {
+ var query = data.query;
+ this.attr.currentTag = 'all';
+ this.fetchMail(query, 'all');
+ };
+
+ this.mailFromJSON = function(mail) {
+ return Mail.create(mail);
+ };
+
+ this.parseMails = function(data) {
+ data.mails = _.map(data.mails, this.mailFromJSON, this);
+
+ return data;
+ };
+
+ function escaped(s) {
+ return encodeURI(s);
+ }
+
+ this.excludeTrashedEmailsForDraftsAndSent = function(query) {
+ if (query === 'tag:"drafts"' || query === 'tag:"sent"') {
+ return query + ' -in:"trash"';
+ } else {
+ return query;
+ }
+ };
+
+ this.fetchMail = function(query, tag, fromRefresh, eventData) {
+ var p = this.attr.currentPage;
+ var w = this.attr.w;
+ var url = this.attr.mailsResource + '?q='+ escaped(this.excludeTrashedEmailsForDraftsAndSent(query)) + '&p=' + p + '&w=' + w;
+ this.attr.lastQuery = this.excludeTrashedEmailsForDraftsAndSent(query);
+ $.ajax(url, { dataType: 'json' })
+ .done(function(data) {
+ this.attr.numPages = Math.ceil(data.stats.total / this.attr.w);
+ var eventToTrigger = fromRefresh ? events.mails.availableForRefresh : events.mails.available;
+ this.trigger(document, eventToTrigger, _.merge(_.merge({tag: tag }, eventData), this.parseMails(data)));
+ }.bind(this))
+ .fail(function() {
+ this.trigger(document, events.ui.userAlerts.displayMessage, { message: i18n('Could not fetch messages') });
+ }.bind(this));
+ };
+
+ function createSingleMailUrl(mailsResource, ident){
+ return mailsResource + '/' + ident;
+ }
+
+ this.fetchSingle = function(event, data) {
+ var fetchUrl = createSingleMailUrl(this.attr.singleMailResource, data.mail);
+
+ $.ajax(fetchUrl, { dataType: 'json' })
+ .done(function(mail) {
+ if (_.isNull(mail)) {
+ this.trigger(data.caller, events.mail.notFound);
+ return;
+ }
+
+ this.trigger(data.caller, events.mail.here, { mail: this.mailFromJSON(mail) });
+ }.bind(this));
+ };
+
+ this.previousPage = function() {
+ if(this.attr.currentPage > 0) {
+ this.updateCurrentPageNumber(this.attr.currentPage - 1);
+ this.refreshResults();
+ }
+ };
+
+ this.nextPage = function() {
+ if(this.attr.currentPage < (this.attr.numPages - 1)) {
+ this.updateCurrentPageNumber(this.attr.currentPage + 1);
+ this.refreshResults();
+ }
+ };
+
+ this.updateCurrentPageNumber = function(newCurrentPage) {
+ this.attr.currentPage = newCurrentPage;
+ this.trigger(document, events.ui.page.changed, {
+ currentPage: this.attr.currentPage,
+ numPages: this.attr.numPages
+ });
+ };
+
+ this.wantDraftReplyForMail = function(ev, data) {
+ $.ajax('/draft_reply_for/' + data.ident, { dataType: 'json' })
+ .done(function(mail) {
+ if (_.isNull(mail)) {
+ this.trigger(document, events.mail.draftReply.notFound);
+ return;
+ }
+ this.trigger(document, events.mail.draftReply.here, { mail: this.mailFromJSON(mail) });
+ }.bind(this));
+ };
+
+ this.after('initialize', function () {
+ that = this;
+
+ this.on(events.mail.want, this.fetchSingle);
+ this.on(document, events.mail.read, this.readMail);
+ this.on(document, events.mail.unread, this.unreadMail);
+ this.on(document, events.mail.tags.update, this.updateTags);
+ this.on(document, events.mail.delete, this.deleteMail);
+ this.on(document, events.mail.deleteMany, this.deleteManyMails);
+ this.on(document, events.search.perform, this.newSearch);
+ this.on(events.mail.draftReply.want, this.wantDraftReplyForMail);
+
+ this.on(events.ui.mails.fetchByTag, this.fetchByTag);
+ this.on(events.ui.mails.refresh, this.refreshResults);
+ this.on(events.ui.page.previous, this.previousPage);
+ this.on(events.ui.page.next, this.nextPage);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/services/model/mail.js b/web-ui/app/js/services/model/mail.js
new file mode 100644
index 00000000..6f99465e
--- /dev/null
+++ b/web-ui/app/js/services/model/mail.js
@@ -0,0 +1,147 @@
+/*global _ */
+'use strict';
+
+define(['helpers/contenttype'],
+ function (contentType) {
+
+ var asMail = (function () {
+
+ function isSentMail() {
+ return _.contains(this.tags, 'sent');
+ }
+
+ function isDraftMail() {
+ return _.contains(this.tags, 'drafts');
+ }
+
+ function normalize(recipients) {
+ return _.chain([recipients])
+ .flatten()
+ .filter(function (r) {
+ return !_.isUndefined(r) && !_.isEmpty(r);
+ })
+ .value();
+ }
+
+ function isInTrash() {
+ return _.contains(this.tags, 'trash');
+ }
+
+ function setDraftReplyFor(ident) {
+ this.draft_reply_for = ident;
+ }
+
+ function recipients(){
+ return {
+ to: normalize(this.header.to),
+ cc: normalize(this.header.cc)
+ };
+ }
+
+ function replyToAddress() {
+ var recipients;
+
+ if (this.isSentMail()) {
+ recipients = this.recipients();
+ } else {
+ recipients = {
+ to: normalize(this.header.reply_to || this.header.from),
+ cc: []
+ };
+ }
+
+ return recipients;
+ }
+
+ function replyToAllAddress() {
+ return {
+ to: normalize([this.header.reply_to, this.header.from, this.header.to]),
+ cc: normalize(this.header.cc)
+ };
+ }
+
+ function getHeadersFromMailPart (rawBody) {
+ var lines, headerLines, endOfHeaders, headers;
+
+ lines = rawBody.split('\n');
+ endOfHeaders = _.indexOf(lines, '');
+ headerLines = lines.slice(0, endOfHeaders);
+
+ headers = _.map(headerLines, function (headerLine) {
+ return headerLine.split(': ');
+ });
+
+ return _.object(headers);
+ }
+
+ function getBodyFromMailPart (rawBody) {
+ var lines, endOfHeaders;
+
+ lines = rawBody.split('\n');
+ endOfHeaders = _.indexOf(lines, '');
+
+ return lines.slice(endOfHeaders + 1).join('\n');
+ }
+
+ function parseWithHeaders(rawBody) {
+ return {headers: getHeadersFromMailPart(rawBody), body: getBodyFromMailPart(rawBody)};
+ }
+
+ function getMailMultiParts () {
+ var mediaType = this.getMailMediaType();
+ var boundary = '--' + mediaType.params.boundary + '\n';
+ var finalBoundary = '--' + mediaType.params.boundary + '--';
+
+ var bodyParts = this.body.split(finalBoundary)[0].split(boundary);
+
+ bodyParts = _.reject(bodyParts, function(bodyPart) { return _.isEmpty(bodyPart.trim()); });
+
+ return _.map(bodyParts, parseWithHeaders);
+ };
+
+ function getMailMediaType () {
+ return new contentType.MediaType(this.header.content_type);
+ }
+
+ function isMailMultipartAlternative () {
+ return this.getMailMediaType().type === 'multipart/alternative';
+ }
+
+ function availableBodyPartsContentType () {
+ var bodyParts = this.getMailMultiParts();
+
+ return _.pluck(_.pluck(bodyParts, 'headers'), 'Content-Type');
+ }
+
+ function getMailPartByContentType (contentType) {
+ var bodyParts = this.getMailMultiParts();
+
+ return _.findWhere(bodyParts, {headers: {'Content-Type': contentType}});
+ }
+
+ return function () {
+ this.isSentMail = isSentMail;
+ this.isDraftMail = isDraftMail;
+ this.isInTrash = isInTrash;
+ this.setDraftReplyFor = setDraftReplyFor;
+ this.replyToAddress = replyToAddress;
+ this.replyToAllAddress = replyToAllAddress;
+ this.recipients = recipients;
+ this.getMailMediaType = getMailMediaType;
+ this.isMailMultipartAlternative = isMailMultipartAlternative;
+ this.getMailMultiParts = getMailMultiParts;
+ this.availableBodyPartsContentType = availableBodyPartsContentType;
+ this.getMailPartByContentType = getMailPartByContentType;
+ return this;
+ };
+ }());
+
+ return {
+ create: function (mail) {
+ if (mail) {
+ asMail.apply(mail);
+ }
+ return mail;
+ }
+ };
+});
diff --git a/web-ui/app/js/tags/data/tags.js b/web-ui/app/js/tags/data/tags.js
new file mode 100644
index 00000000..96f08b99
--- /dev/null
+++ b/web-ui/app/js/tags/data/tags.js
@@ -0,0 +1,42 @@
+define(['flight/lib/component', 'page/events'], function (defineComponent, events) {
+ 'use strict';
+
+ var DataTags = defineComponent(dataTags);
+
+ DataTags.all = {
+ name: 'all',
+ ident: '8752888923742657436',
+ query: 'in:all',
+ default: true,
+ counts:{
+ total:0,
+ read:0,
+ starred:0,
+ replied:0
+ }
+ };
+
+ return DataTags;
+
+ function dataTags() {
+ function sendTagsBackTo(on, params) {
+ return function(data) {
+ data.push(DataTags.all);
+ on.trigger(params.caller, events.tags.received, {tags: data});
+ };
+ }
+
+ this.defaultAttrs({
+ tagsResource: '/tags'
+ });
+
+ this.fetchTags = function(event, params) {
+ $.ajax(this.attr.tagsResource)
+ .done(sendTagsBackTo(this, params));
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.tags.want, this.fetchTags);
+ });
+ }
+});
diff --git a/web-ui/app/js/tags/ui/tag.js b/web-ui/app/js/tags/ui/tag.js
new file mode 100644
index 00000000..311c3c05
--- /dev/null
+++ b/web-ui/app/js/tags/ui/tag.js
@@ -0,0 +1,94 @@
+/*global _ */
+
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'tags/ui/tag_base',
+ 'page/events',
+ 'views/i18n'
+ ],
+
+ function (defineComponent, templates, tagBase, events, i18n) {
+ 'use strict';
+
+ var Tag = defineComponent(tag, tagBase);
+
+ Tag.appendedTo = function (parent, data) {
+ var res = new this();
+ res.renderAndAttach(parent, data);
+ return res;
+ };
+
+ return Tag;
+
+ function tag() {
+
+ this.viewFor = function (tag, template) {
+ return template({
+ tagName: tag.default ? i18n("tags." + tag.name) : tag.name,
+ ident: tag.ident,
+ count: this.badgeType(tag) === 'total' ? tag.counts.total : (tag.counts.total - tag.counts.read),
+ displayBadge: this.displayBadge(tag),
+ badgeType: this.badgeType(tag),
+ icon: tag.icon
+ });
+ };
+
+ this.decreaseReadCountIfMatchingTag = function (ev, data) {
+ if (_.contains(data.tags, this.attr.tag.name)) {
+ this.attr.tag.counts.read++;
+ this.$node.html(this.viewFor(this.attr.tag, templates.tags.tagInner));
+ }
+ };
+
+ this.triggerSelect = function () {
+ this.trigger(document, events.ui.tag.select, { tag: this.attr.tag.name });
+ this.trigger(document, events.search.empty);
+ };
+
+ this.selectTag = function (ev, data) {
+ data.tag === this.attr.tag.name ? this.doSelect(data) : this.doUnselect();
+ };
+
+ this.doUnselect = function () {
+ this.attr.selected = false;
+ this.$node.removeClass('selected');
+ };
+
+ this.doSelect = function (data) {
+ this.attr.selected = true;
+ this.$node.addClass('selected');
+ this.trigger(document, events.ui.mails.cleanSelected);
+ this.trigger(document, events.ui.tag.selected, data);
+ };
+
+ this.addSearchingClass = function() {
+ if (this.attr.tag.name === 'all'){
+ this.$node.addClass('searching');
+ }
+ };
+
+ this.removeSearchingClass = function() {
+ if (this.attr.tag.name === 'all'){
+ this.$node.removeClass('searching');
+ }
+ };
+
+ this.after('initialize', function () {
+ this.on('click', this.triggerSelect);
+ this.on(document, events.ui.tag.select, this.selectTag);
+ this.on(document, events.mail.read, this.decreaseReadCountIfMatchingTag);
+ this.on(document, events.search.perform, this.addSearchingClass);
+ this.on(document, events.search.empty, this.removeSearchingClass);
+ });
+
+ this.renderAndAttach = function (parent, data) {
+ var rendered = this.viewFor(data.tag, templates.tags.tag);
+ parent.append(rendered);
+ this.initialize('#tag-' + data.tag.ident, data);
+ this.on(parent, events.tags.teardown, this.teardown);
+ };
+ }
+ }
+);
diff --git a/web-ui/app/js/tags/ui/tag_base.js b/web-ui/app/js/tags/ui/tag_base.js
new file mode 100644
index 00000000..58f285f7
--- /dev/null
+++ b/web-ui/app/js/tags/ui/tag_base.js
@@ -0,0 +1,24 @@
+define(['views/i18n', 'page/events'], function(i18n, events) {
+
+ function tagBase() {
+ var ALWAYS_HIDE_BADGE_FOR = ['sent', 'trash', 'all'];
+ var TOTAL_BADGE = ['drafts'];
+
+ this.displayBadge = function(tag) {
+ if(_.include(ALWAYS_HIDE_BADGE_FOR, tag.name)) { return false; }
+ if(this.badgeType(tag) === 'total') {
+ return tag.counts.total > 0;
+ } else {
+ return (tag.counts.total - tag.counts.read) > 0;
+ }
+ };
+
+ this.badgeType = function(tag) {
+ return _.include(TOTAL_BADGE, tag.name) ? 'total' : 'unread';
+ };
+
+ }
+
+ return tagBase;
+
+});
diff --git a/web-ui/app/js/tags/ui/tag_list.js b/web-ui/app/js/tags/ui/tag_list.js
new file mode 100644
index 00000000..02eee7f8
--- /dev/null
+++ b/web-ui/app/js/tags/ui/tag_list.js
@@ -0,0 +1,93 @@
+define(
+ [
+ 'flight/lib/component',
+ 'tags/ui/tag',
+ 'views/templates',
+ 'page/events',
+ 'tags/ui/tag_shortcut'
+ ],
+
+ function(defineComponent, Tag, templates, events, TagShortcut) {
+ 'use strict';
+
+ var ICON_FOR = {
+ 'inbox': 'inbox',
+ 'sent': 'send',
+ 'drafts': 'pencil',
+ 'trash': 'trash-o',
+ 'all': 'archive'
+ };
+
+ var ORDER = {
+ 'inbox': '0',
+ 'sent': '1',
+ 'drafts': '2',
+ 'trash': '3',
+ 'all': '4'
+ };
+
+ return defineComponent(tagList);
+
+ function tagOrder(nm) {
+ return ORDER[nm.name] || '999' + nm.name;
+ }
+
+ function tagList() {
+ this.defaultAttrs({
+ defaultTagList: '#default-tag-list',
+ customTagList: '#custom-tag-list'
+ });
+
+ this.renderShortcut = function (tag, tagComponent) {
+ TagShortcut.appendedTo($('#tags-shortcuts'), { linkTo: tag, trigger: tagComponent});
+ };
+
+ function renderTag(tag, defaultList, customList) {
+ var list = tag.default ? defaultList : customList;
+
+ var tagComponent = Tag.appendedTo(list, {tag: tag});
+ if (_.contains(_.keys(ORDER), tag.name)) {
+ this.renderShortcut(tag, tagComponent);
+ }
+ }
+
+ function resetTagList(lists) {
+ _.each(lists, function (list) {
+ this.trigger(list, events.tags.teardown);
+ list.empty();
+ }.bind(this));
+ }
+
+ this.renderTagList = function(tags) {
+ var defaultList = this.select('defaultTagList');
+ var customList = this.select('customTagList');
+
+ resetTagList.bind(this, [defaultList, customList]).call();
+
+ tags.forEach(function (tag) {
+ renderTag.bind(this, tag, defaultList, customList).call();
+ }.bind(this));
+ };
+
+
+ this.loadTagList = function(ev, data) {
+ this.renderTagList(_.sortBy(data.tags, tagOrder));
+ this.trigger(document, events.ui.tags.loaded, { tag: this.attr.currentTag });
+ };
+
+ this.saveTag = function(ev, data) {
+ this.attr.currentTag = data.tag;
+ };
+
+ this.renderTagListTemplate = function () {
+ this.$node.html(templates.tags.tagList());
+ };
+
+ this.after('initialize', function() {
+ this.on(document, events.ui.tagList.load, this.loadTagList);
+ this.on(document, events.ui.tag.selected, this.saveTag);
+ this.renderTagListTemplate();
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/tags/ui/tag_shortcut.js b/web-ui/app/js/tags/ui/tag_shortcut.js
new file mode 100644
index 00000000..6e5c6960
--- /dev/null
+++ b/web-ui/app/js/tags/ui/tag_shortcut.js
@@ -0,0 +1,68 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'page/events',
+ 'tags/ui/tag_base'
+ ],
+
+ function (describeComponent, templates, events, tagBase) {
+
+ var TagShortcut = describeComponent(tagShortcut, tagBase);
+
+ TagShortcut.appendedTo = function (parent, data) {
+ var res = new this();
+ res.renderAndAttach(parent, data);
+ return res;
+ };
+
+ return TagShortcut;
+
+ function tagShortcut() {
+
+
+ this.renderAndAttach = function (parent, options) {
+ var linkTo = options.linkTo;
+
+ var model = {
+ tagName: linkTo.name,
+ displayBadge: this.displayBadge(linkTo),
+ badgeType: this.badgeType(linkTo),
+ count: this.badgeType(linkTo) === 'total' ? linkTo.counts.total : (linkTo.counts.total - linkTo.counts.read),
+ icon: iconFor[linkTo.name]
+ };
+
+ var rendered = templates.tags.shortcut(model);
+ parent.append(rendered);
+
+ this.initialize(parent.children().last(),options);
+ };
+
+ var iconFor = {
+ 'inbox': 'inbox',
+ 'sent': 'send',
+ 'drafts': 'pencil',
+ 'trash': 'trash-o',
+ 'all': 'archive'
+ };
+
+ this.selectTag = function (ev, data) {
+ data.tag === this.attr.linkTo.name ? this.doSelect() : this.doUnselect();
+ };
+
+ this.doUnselect = function () {
+ this.$node.removeClass('selected');
+ };
+
+ this.doSelect = function () {
+ this.$node.addClass('selected');
+ };
+
+ this.after('initialize', function () {
+ this.on('click', function () { this.attr.trigger.triggerSelect(); });
+ this.on(document, events.ui.tag.select, this.selectTag);
+ });
+
+ }
+ }
+);
diff --git a/web-ui/app/js/user_alerts/ui/user_alerts.js b/web-ui/app/js/user_alerts/ui/user_alerts.js
new file mode 100644
index 00000000..308ccfc7
--- /dev/null
+++ b/web-ui/app/js/user_alerts/ui/user_alerts.js
@@ -0,0 +1,35 @@
+define(
+ [
+ 'flight/lib/component',
+ 'views/templates',
+ 'mixins/with_hide_and_show',
+ 'page/events'
+ ],
+
+ function(defineComponent, templates, withHideAndShow, events) {
+ 'use strict';
+
+ return defineComponent(userAlerts, withHideAndShow);
+
+ function userAlerts() {
+ this.defaultAttrs({
+ dismissTimeout: 3000
+ });
+
+ this.render = function (message) {
+ this.$node.html(templates.userAlerts.message(message));
+ this.show();
+ setTimeout(this.hide.bind(this), this.attr.dismissTimeout);
+ };
+
+
+ this.displayMessage = function (ev, data) {
+ this.render({ message: data.message});
+ };
+
+ this.after('initialize', function () {
+ this.on(document, events.ui.userAlerts.displayMessage, this.displayMessage);
+ });
+ }
+ }
+);
diff --git a/web-ui/app/js/views/i18n.js b/web-ui/app/js/views/i18n.js
new file mode 100644
index 00000000..4550e153
--- /dev/null
+++ b/web-ui/app/js/views/i18n.js
@@ -0,0 +1,18 @@
+/*global Handlebars */
+
+define(['i18next'], function(i18n) {
+ 'use strict';
+
+ var self = function(str) {
+ return i18n.t(str);
+ };
+
+ self.get = self;
+
+ self.init = function(path) {
+ i18n.init({detectLngQS: 'lang', fallbackLng: 'en', lowerCaseLng: true, getAsync: false, resGetPath: path + 'locales/__lng__/__ns__.json'});
+ Handlebars.registerHelper('t', self.get.bind(self));
+ };
+
+ return self;
+});
diff --git a/web-ui/app/js/views/recipientListFormatter.js b/web-ui/app/js/views/recipientListFormatter.js
new file mode 100644
index 00000000..c3d05858
--- /dev/null
+++ b/web-ui/app/js/views/recipientListFormatter.js
@@ -0,0 +1,16 @@
+/*global Handlebars */
+
+define(function() {
+ 'use strict';
+ Handlebars.registerHelper('formatRecipients', function (header) {
+ function wrapWith(begin, end) {
+ return function (x) { return begin + x + end; };
+ }
+
+ var to = _.map(header.to, wrapWith('<span class="to">', '</span>'));
+ var cc = _.map(header.cc, wrapWith('<span class="cc">cc: ', '</span>'));
+ var bcc = _.map(header.bcc, wrapWith('<span class="bcc">bcc: ', '</span>'));
+
+ return new Handlebars.SafeString(to.concat(cc, bcc).join(', '));
+ });
+});
diff --git a/web-ui/app/js/views/templates.js b/web-ui/app/js/views/templates.js
new file mode 100644
index 00000000..cc120093
--- /dev/null
+++ b/web-ui/app/js/views/templates.js
@@ -0,0 +1,46 @@
+/*global Handlebars */
+
+define(['hbs/templates'], function (templates) {
+ 'use strict';
+
+ var Templates = {
+ compose: {
+ box: window.Smail['app/templates/compose/compose_box.hbs'],
+ inlineBox: window.Smail['app/templates/compose/inline_box.hbs'],
+ replySection: window.Smail['app/templates/compose/reply_section.hbs'],
+ recipientInput: window.Smail['app/templates/compose/recipient_input.hbs'],
+ fixedRecipient: window.Smail['app/templates/compose/fixed_recipient.hbs'],
+ recipients: window.Smail['app/templates/compose/recipients.hbs']
+ },
+ tags: {
+ tagList: window.Smail['app/templates/tags/tag_list.hbs'],
+ tag: window.Smail['app/templates/tags/tag.hbs'],
+ tagInner: window.Smail['app/templates/tags/tag_inner.hbs'],
+ shortcut: window.Smail['app/templates/tags/shortcut.hbs']
+ },
+ userAlerts: {
+ message: window.Smail['app/templates/user_alerts/message.hbs']
+ },
+ mails: {
+ single: window.Smail['app/templates/mails/single.hbs'],
+ fullView: window.Smail['app/templates/mails/full_view.hbs'],
+ mailActions: window.Smail['app/templates/mails/mail_actions.hbs'],
+ sent: window.Smail['app/templates/mails/sent.hbs']
+ },
+ mailActions: {
+ actionsBox: window.Smail['app/templates/mail_actions/actions_box.hbs'],
+ composeTrigger: window.Smail['app/templates/mail_actions/compose_trigger.hbs'],
+ refreshTrigger: window.Smail['app/templates/mail_actions/refresh_trigger.hbs'],
+ paginationTrigger: window.Smail['app/templates/mail_actions/pagination_trigger.hbs']
+ },
+ noMessageSelected: window.Smail['app/templates/no_message_selected.hbs'],
+ search: {
+ trigger: window.Smail['app/templates/search/search_trigger.hbs']
+ }
+ };
+
+ Handlebars.registerPartial('tag_inner', Templates.tags.tagInner);
+ Handlebars.registerPartial('recipients', Templates.compose.recipients);
+
+ return Templates;
+});
diff --git a/web-ui/app/locales/en/translation.json b/web-ui/app/locales/en/translation.json
new file mode 100644
index 00000000..c953994b
--- /dev/null
+++ b/web-ui/app/locales/en/translation.json
@@ -0,0 +1,69 @@
+{
+ "compose": "Compose",
+ "re": "Re: ",
+ "Fwd: ": "Fwd: ",
+ "Your message was moved to trash!": "Your message was moved to trash!",
+ "Your message was archive it!": "Your message was archived!",
+ "Your message was permanently deleted!": "Your message was permanently deleted!",
+ "Saved as draft.": "Saved as draft.",
+ "One or more of the recipients are not valid emails": "One or more of the recipients are not valid emails",
+ "Could not update mail tags": "Could not update mail tags",
+ "Could not delete email": "Could not delete email",
+ "Could not fetch messages": "Could not fetch messages",
+ "TO": "TO",
+ "To": "To",
+ "CC": "CC",
+ "BCC": "BCC",
+ "Body": "Body",
+ "Subject": "Subject",
+ "Don't worry about recipients right now, you'll be able to add them just before sending.": "Don't worry about recipients right now, you'll be able to add them just before sending.",
+ "Send": "Send",
+ "Cancel": "Cancel",
+ "Save Draft": "Save Draft",
+ "Reply": "Reply",
+ "Reply to All": "Reply to All",
+ "Mark as read": "Mark as read",
+ "Delete": "Delete",
+ "Archive": "Archive",
+ "Close": "Close",
+ "Trash this message": "Trash this message",
+ "NOTHING SELECTED": "NOTHING SELECTED",
+ "Press Enter to create": "Press Enter to create",
+ "You are trying to delete the last tag on this message.": "You are trying to delete the last tag on this message.",
+ "What would you like to do?": "What would you like to do?",
+ "Trash message": "Trash message",
+ "Archive it": "Archive it",
+ "Trash:": "Trash:",
+ "Archive:": "Archive:",
+ "we will keep this message for 30 days, then delete it forever.": "we will keep this message for 30 days, then delete it forever.",
+ "we will remove all the tags, but keep it in your account in case you need it.": "we will remove all the tags, but keep it in your account in case you need it.",
+ "to:": "to:",
+ "no_subject": "<No Subject>",
+ "no_recipient": "<No Recipients>",
+ "you": "you",
+ "encrypted": "Encrypted",
+ "encrypted encryption-failure": "You are not authorized to see this message.",
+ "encrypted encryption-valid": "Message was transmitted securely.",
+ "not-encrypted": "Message was readable during transmission.",
+ "signed": "Certified sender.",
+ "signed signature-revoked": "Sender could not be securely identified.",
+ "signed signature-expired": "Sender could not be securely identified.",
+ "signed signature-not-trusted": "Sender and/or message cannot be trusted.",
+ "signed signature-unknown": "Sender and/or message cannot be trusted.",
+ "not-signed": "Sender could not be securely identified.",
+ "send-button": "Send",
+ "draft-button": "Save Draft",
+ "trash-button": "Trash it",
+ "Search..." : "Search...",
+ "Search results for:": "Search results for:",
+ "Tags": "Tags",
+ "Forward": "Forward",
+
+ "tags": {
+ "inbox": "Inbox",
+ "sent": "Sent",
+ "drafts": "Drafts",
+ "trash": "Trash",
+ "all": "All"
+ }
+}
diff --git a/web-ui/app/locales/pt/translation.json b/web-ui/app/locales/pt/translation.json
new file mode 100644
index 00000000..6623ceaa
--- /dev/null
+++ b/web-ui/app/locales/pt/translation.json
@@ -0,0 +1,20 @@
+{
+ "compose": "Escrever",
+ "re": "Res: ",
+ "Your message was moved to trash!": "Sua mensagem foi movida para a lixeira!",
+ "Your message was archive it!": "Sua mensagem foi arquivada!",
+ "Your message was permanently deleted!": "Sua mensagem foi permanentemente deletada!",
+ "Saved as draft.": "Mensagem salva como rascunho.",
+ "One or more of the recipients are not valid emails": "Email de um ou mais destinatários é inválido",
+ "Could not update mail tags": "Não foi possível atualizar as etiquetas do email",
+ "Could not delete email": "Não foi possível deletar o email",
+ "Could not fetch messages": "Não foi possível buscar as mensagems",
+
+ "tags": {
+ "inbox": "Caixa de Entrada",
+ "sent": "Enviadas",
+ "drafts": "Rascunhos",
+ "trash": "Lixeira",
+ "all": "Todas"
+ }
+}
diff --git a/web-ui/app/locales/sv/translation.json b/web-ui/app/locales/sv/translation.json
new file mode 100644
index 00000000..b76de97f
--- /dev/null
+++ b/web-ui/app/locales/sv/translation.json
@@ -0,0 +1,66 @@
+{
+ "compose": "Skriv nytt",
+ "re": "Sv: ",
+ "Fwd: ": "VB: ",
+ "Your message was moved to trash!": "Ditt meddelande har flyttats till papperskorgen!",
+ "Your message was archive it!": "Ditt meddelande har arkiverats!",
+ "Your message was permanently deleted!": "Ditt meddelande har tagits bort permanent!",
+ "Saved as draft.": "Sparat som utkast.",
+ "One or more of the recipients are not valid emails": "En eller flera mottagare är inte giltiga epost-adresser",
+ "Could not update mail tags": "Kan inte ändra taggar",
+ "Could not delete email": "Kan inte ta bort meddelande",
+ "Could not fetch messages": "Kan inte hämta meddelanden",
+ "TO": "TILL",
+ "To": "Till",
+ "CC": "CC",
+ "BCC": "BCC",
+ "Body": "Innehåll",
+ "Subject": "Titel",
+ "Don't worry about recipients right now, you'll be able to add them just before sending.": "Oroa dig inte över mottagare just nu, du kan lägga till dem senare.",
+ "Send": "Skicka",
+ "Cancel": "Avbryt",
+ "Save Draft": "Spara utkast",
+ "Reply": "Svara",
+ "Reply to All": "Svara Alla",
+ "Mark as read": "Markera som läst",
+ "Delete": "Ta bort",
+ "Archive": "Arkivera",
+ "Close": "Stäng",
+ "Trash this message": "Kasta detta meddelande",
+ "NOTHING SELECTED": "INGET VALT",
+ "Press Enter to create": "Tryck retur för att skapa",
+ "You are trying to delete the last tag on this message.": "Du försöker ta bort den sista taggen på detta meddelande.",
+ "What would you like to do?": "Vad vill du göra?",
+ "Trash message": "Ta bort",
+ "Archive it": "Arkivera",
+ "Trash:": "Ta bort:",
+ "Archive:": "Arkivera:",
+ "we will keep this message for 30 days, then delete it forever.": "vi kommer spara meddelandet i 30 dagar och sedan ta bort det för alltid.",
+ "we will remove all the tags, but keep it in your account in case you need it.": "vi kommer ta bort alla taggar men spara meddelandet i ditt konto ifall du behöver det.",
+ "to:": "till:",
+ "no_subject": "<Ingen titel>",
+ "no_recipient": "<Inga mottagare>",
+ "you": "du",
+ "encrypted": "krypterad",
+ "encrypted encryption-failure": "Du har inte tillstånd att see det här meddelandet.",
+ "encrypted encryption-valid": "Meddelandet skickades säkert.",
+ "not-encrypted": "Meddelandet var läsbart medans det var på väg.",
+ "signed": "Certifierad avsändare.",
+ "signed signature-revoked": "Avsändaren kunde inte säkert identifieras.",
+ "signed signature-expired": "Avsändaren kunde inte säkert identifieras.",
+ "signed signature-not-trusted": "Avsändaren och/eller meddelandet är inte pålitligt.",
+ "signed signature-unknown": "Avsändaren och/eller meddelandet är inte pålitligt.",
+ "not-signed": "Avsändaren kunde inte säkert identifieras.",
+ "Search..." : "Sök...",
+ "Search results for:": "Sökresultat för:",
+ "Tags": "Taggar",
+ "Forward": "Vidarebefodra",
+
+ "tags": {
+ "inbox": "Inlåda",
+ "sent": "Skickat",
+ "drafts": "Utkast",
+ "trash": "Skräp",
+ "all": "Alla"
+ }
+}
diff --git a/web-ui/app/robots.txt b/web-ui/app/robots.txt
new file mode 100644
index 00000000..6b0157e2
--- /dev/null
+++ b/web-ui/app/robots.txt
@@ -0,0 +1,3 @@
+# robotstxt.org
+
+User-agent: * \ No newline at end of file
diff --git a/web-ui/app/scss/_alerts.scss b/web-ui/app/scss/_alerts.scss
new file mode 100644
index 00000000..16171fd7
--- /dev/null
+++ b/web-ui/app/scss/_alerts.scss
@@ -0,0 +1,14 @@
+#user-alerts {
+ width: 100%;
+ margin: 10px auto;
+ position: fixed;
+ z-index: 10000;
+ text-align: center;
+ span {
+ background: $warning;
+ padding: 5px 60px;
+ border: 1px solid darken($warning, 10%);
+ color: darken($warning, 50%);
+ @include box-shadow(1px 1px 3px darken($warning, 60%));
+ }
+}
diff --git a/web-ui/app/scss/_colors.scss b/web-ui/app/scss/_colors.scss
new file mode 100644
index 00000000..97c883a5
--- /dev/null
+++ b/web-ui/app/scss/_colors.scss
@@ -0,0 +1,13 @@
+$warning: #F7E8AF;
+$search-highlight: #FFEF29;
+
+$total_count_bg: #C0B9B9;
+
+$error: #D72A25;
+$attention: #F6A40A;
+$success: #2DAB49;
+
+$contrast: #F2F3ED;
+$top_pane: #EAEAEA;
+$secondary: #3E3A37;
+$primary_color: #EF4E2F;
diff --git a/web-ui/app/scss/_compose.scss b/web-ui/app/scss/_compose.scss
new file mode 100644
index 00000000..3a3dabee
--- /dev/null
+++ b/web-ui/app/scss/_compose.scss
@@ -0,0 +1,92 @@
+// COMPOSE BUTTON
+#compose {
+ margin-bottom: 5px;
+ padding-right: 4px;
+ #compose-trigger {
+ width: 100%;
+ display: inline-block;
+ #compose-mails-trigger {
+ background: $primary_color;
+ color: #FFF;
+ padding: 10px 30px;
+ text-align: center;
+ font-weight: 400;
+ font-size: 1.2em;
+ @include btn-transition;
+ &:hover {
+ background: lighten($primary_color, 10%);
+ cursor: pointer;
+ }
+ }
+ }
+}
+
+// COMPOSE PANE
+#compose-box, #draft-box, #reply-box {
+ margin: 0 0 50px 10px;
+ .input-container {
+ border-bottom: 1px solid #DDD;
+ padding: 1px;
+ }
+ label {
+ color: #AAA;
+ padding: 0.5rem;
+ cursor: text;
+ display: inline-block;
+ padding: 10px;
+ }
+ input, textarea {
+ margin: 0;
+ border: none;
+ }
+ input {
+ &#subject {
+ font-size: 1.6875rem;
+ line-height: 1.4;
+ margin-top: 26px;
+ }
+ }
+ textarea {
+ border-bottom: 2px solid #DDD;
+ min-height: 400px;
+ font-family: inherit;
+ font-weight: normal;
+ font-size: 1rem;
+ line-height: 1.6;
+ text-rendering: optimizeLegibility;
+ }
+
+ &.reply-box, &.forward-box {
+ margin: 0;
+ h4 {
+ font-size: 0.9em;
+ font-style: italic;
+ color: #777;
+ margin: 2px 0;
+ clear: both;
+ cursor: pointer;
+ &:hover {
+ background: $contrast;
+ }
+ }
+ textarea {
+ min-height: 200px;
+ margin: 10px 0;
+ }
+ p {
+ padding: 5px;
+ margin: 10px 0;
+ font-style: italic;
+ cursor: pointer;
+ &:hover {
+ background: $contrast;
+ }
+ }
+ }
+
+ @include recipients;
+}
+
+#reply-box {
+ @include recipients;
+}
diff --git a/web-ui/app/scss/_mascot.scss b/web-ui/app/scss/_mascot.scss
new file mode 100644
index 00000000..98812ce2
--- /dev/null
+++ b/web-ui/app/scss/_mascot.scss
@@ -0,0 +1,32 @@
+/* SHEEP */
+
+#no-message-selected-pane {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100vh;
+
+ z-index: -100;
+ background: #e5e5e3;
+ padding: 30px;
+ vertical-align:middle;
+ text-align:center;
+ -webkit-transform: translate3d(0, 0, 0);
+ &:before{
+ content: '';
+ display: inline-block;
+ height: 100%;
+ vertical-align: middle;
+ margin-right: -0.25em;
+ }
+ .scene{
+ display:inline-block;
+ vertical-align:middle;
+ }
+
+ .text{
+ color:#666;
+ margin-bottom: 40px;
+ }
+}
diff --git a/web-ui/app/scss/_mixins.scss b/web-ui/app/scss/_mixins.scss
new file mode 100644
index 00000000..dfc0f2ec
--- /dev/null
+++ b/web-ui/app/scss/_mixins.scss
@@ -0,0 +1,205 @@
+// SHARED MIXINS
+@mixin btn-transition {
+ @include transition-property(background-color);
+ @include transition-duration(300ms);
+ @include transition-timing-function(ease-out);
+}
+
+@mixin tooltip($top: 8px, $left: 40px) {
+ background: rgba(0, 0, 0, 0.7);
+ color: #FFF;
+ position: absolute;
+ z-index: 2;
+ left: $left;
+ top: $top;
+ font-size: 0.8rem;
+ padding: 2px 10px;
+ white-space: nowrap;
+ @include border-radius(2px);
+}
+
+@mixin tt-hint {
+ .tt-hint {
+ color: #999
+ }
+ .tt-dropdown-menu {
+ width: 400px;
+ margin-top: 6px;
+ padding: 8px 0;
+ background-color: $contrast;
+ border: 1px solid darken($contrast, 5%);
+ }
+ .tt-suggestion {
+ padding: 3px 10px;
+ font-size: 18px;
+ line-height: 24px;
+ &.tt-cursor {
+ background-color: #FFF;
+ }
+ p {
+ margin: 0;
+ }
+ }
+}
+
+// FORM MIXINS
+@mixin check-box {
+ background-color: #FFF;
+ border: 1px solid #CCC;
+ padding: 7px;
+ margin: 3px 0;
+ cursor: pointer;
+ display: inline-block;
+ position: relative;
+ @include border-radius(2px);
+ @include appearance(none);
+
+ &:focus {
+ outline: none;
+ border-color: #666;
+ }
+
+ &:active, &:checked:active {
+ }
+
+ &:checked {
+ background-color: #EEE;
+ border: 1px solid darken(#DDD, 10%);
+ color: #333;
+ }
+
+ &:checked:after {
+ content: '\2714';
+ font-size: 1em;
+ position: absolute;
+ bottom: -2px;
+ left: 1px;
+ color: $secondary;
+ }
+}
+
+@mixin tags {
+ ul.tags {
+ li {
+ background: #DDD;
+ display: inline;
+ font-size: 0.55em;
+ padding: 2px 3px;
+ margin: 0 1px;
+ position: relative;
+ text-transform: uppercase;
+ @include border-radius(2px);
+ &[data-tag="drafts"] {
+ color: $attention;
+ background: #EEE;
+ }
+ &.tag:hover {
+ text-decoration: line-through;
+ cursor: pointer;
+ }
+ &.add-new {
+ opacity: 0.6;
+ transition: background-color 150ms ease-out;
+ background: transparent;
+ border: 1px solid #DDD;
+ line-height: 0;
+ padding: 1px 2px;
+ @include border-radius(2px);
+ &:hover {
+ opacity: 1;
+ background: #DDD;
+ }
+ i {
+ &:before {
+ vertical-align: middle;
+ }
+ }
+ }
+ &.new-tag {
+ font-size: 0.7em;
+ display: inline-block;
+ padding: 0;
+ background: transparent;
+ input {
+ display: inline;
+ font-size: 1em;
+ padding: 2px 5px;
+ width: 120px;
+ margin: 0;
+ }
+ @include tt-hint;
+ .tt-dropdown-menu {
+ width: 250px;
+ }
+ }
+ }
+ }
+}
+
+@mixin searching($top, $left, $color, $size){
+ &.searching {
+ &:after {
+ font-family: FontAwesome;
+ content: "\f002";
+ font-size: $size;
+ top: $top;
+ left: $left;
+ position: absolute;
+ color: $color;
+ text-shadow: -1px 0 $contrast, 0 1px $contrast, 1px 0 $contrast, 0 -1px $contrast;
+ }
+ }
+}
+
+
+@mixin recipients {
+
+ .recipients-area {
+ -webkit-appearance: none;
+ background-color: white;
+ font-family: inherit;
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 0.898em;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ position: relative;
+
+ .recipients-navigation-handler {
+ z-index: -1;
+ position: absolute;
+ }
+
+ .twitter-typeahead {
+ flex: 1 1 50px;
+ }
+
+ input[type=text] {
+ vertical-align: top;
+ height: 35px;
+ margin-left: 1px;
+ font-size: 0.9em;
+ width: 100%;
+ }
+
+ .fixed-recipient {
+ display: inline-block;
+ margin-right: -3px;
+ flex: none;
+
+ .recipient-value {
+ &.selected {
+ border: 1px solid #666666;
+ }
+ background-color: #F5F5F5;
+ border: 1px solid #D9D9D9;
+ border-radius: 2px;
+ margin: 3px;
+ padding: 5px;
+ }
+ }
+ }
+}
+
+@include tt-hint;
diff --git a/web-ui/app/scss/_read.scss b/web-ui/app/scss/_read.scss
new file mode 100644
index 00000000..1d4715e0
--- /dev/null
+++ b/web-ui/app/scss/_read.scss
@@ -0,0 +1,105 @@
+/* MAIL PANE */
+
+@mixin read-msg {
+ #mail-view {
+ .msg-header {
+ display: flex;
+ flex-wrap: nowrap;
+
+ position: fixed;
+ width: 57%;
+ top: 0;
+ z-index: 10;
+ background-color: white;
+ font-size: 0.9em;
+ padding: 0px 0;
+ margin: 1px 0 0 0;
+ .recipients {
+ border-bottom: 1px solid #DDD;
+ padding-bottom: 5px;
+ line-height: 1.5em;
+ i {
+ padding: 0 5px;
+ }
+ .from {
+ font-weight: 700;
+ }
+ }
+ .close-mail-button {
+ position: relative;
+ float: none;
+ flex-shrink: 0;
+ display: inline-block;
+ vertical-align: top;
+ height: 27px;
+ margin-right: 3px;
+ }
+ }
+ h3 {
+ margin-bottom: 0;
+ }
+ .tagsArea {
+ clear: both;
+ margin: 0 0 10px;
+ @include tags;
+ ul li {
+ &.tag:hover {
+ &:before {
+ content: "click to remove";
+ text-transform: lowercase;
+ font-size: 0.5rem;
+ @include tooltip(18px, 8px);
+ }
+ }
+ }
+ }
+ }
+}
+
+#mail-actions {
+ text-align: right;
+ padding: 10px 0;
+ button {
+ display: inline-block;
+ display: inline;
+ line-height: 2em;
+ border: 1px solid #DDD;
+ &#reply-button-top {
+ @include border-right-radius(0);
+ padding: 0 20px;
+ }
+ &#view-more-actions {
+ @include border-left-radius(0);
+ padding: 0 5px;
+ margin-left: -4px;
+ }
+ &:hover {
+ @include btn-transition;
+ background: darken($contrast, 5%)
+ }
+ }
+ ul#more-actions {
+ padding: 5px 0;
+ width: 170px;
+ text-align: left;
+ display: block;
+ position: absolute;
+ background: #FFF;
+ border: 1px solid #DDD;
+ right: 0;
+ top: 40px;
+ z-index: 10;
+ li {
+ span, a {
+ padding: 5px 10px;
+ display: block;
+ &:hover {
+ cursor: pointer;
+ background: $contrast;
+ }
+ }
+ }
+ }
+}
+
+
diff --git a/web-ui/app/scss/_reply.scss b/web-ui/app/scss/_reply.scss
new file mode 100644
index 00000000..5a044b0b
--- /dev/null
+++ b/web-ui/app/scss/_reply.scss
@@ -0,0 +1,36 @@
+
+#reply-section {
+ .reply-container {
+ margin: 10px 0;
+ padding: 10px;
+ border: 1px dashed darken($contrast, 10%);
+ @include btn-transition;
+ }
+
+ button {
+ margin: 0;
+ }
+
+ #all-recipients {
+ color: #000;
+ }
+
+ #all-recipients:focus {
+ background-color: darken($contrast, 10%)
+ }
+
+ #reply-button, #reply-all-button, #forward-button {
+ text-align: center;
+ font-weight: 100;
+ font-size: 1.1em;
+ background: #FFF;
+ color: #999;
+ padding: 25px;
+ margin: 0;
+ @include border-radius(0);
+ &:hover {
+ background: darken($contrast, 5%);
+ cursor: pointer;
+ }
+ }
+}
diff --git a/web-ui/app/scss/_security.scss b/web-ui/app/scss/_security.scss
new file mode 100644
index 00000000..6d68066b
--- /dev/null
+++ b/web-ui/app/scss/_security.scss
@@ -0,0 +1,47 @@
+.security-status {
+ margin: 0 0 5px;
+ clear: both;
+ span {
+ display: inline-block;
+ padding: 2px 5px;
+ white-space: nowrap;
+ background: $success;
+ color: #FFF;
+ &:before {
+ font-family: FontAwesome;
+ }
+ &.encrypted {
+ &:before {
+ content: "\f023 \f00c";
+ }
+ &.encryption-failure {
+ background: $error;
+ &:before {
+ content: "\f023 \f05e";
+ }
+ }
+ }
+ &.signed {
+ &:before {
+ content: "\f007 \f00c";
+ }
+ &.signature-not-trusted {
+ background: $error;
+ &:before {
+ content: "\f007 \f05e";
+ }
+ }
+ }
+ &[class^=not-], &.signature-expired, &.signature-revoked {
+ background: $attention;
+ &:before {
+ content: "\f007 \f12a"
+ }
+ }
+ &.not-encrypted {
+ &:before {
+ content: "\f023 \f12a";
+ }
+ }
+ }
+}
diff --git a/web-ui/app/scss/foundation.scss b/web-ui/app/scss/foundation.scss
new file mode 100644
index 00000000..7918cf26
--- /dev/null
+++ b/web-ui/app/scss/foundation.scss
@@ -0,0 +1,2066 @@
+@import 'compass/css3';
+
+meta {
+ &.foundation-version {
+ font-family: "/5.2.3/";
+ }
+ &.foundation-mq-small {
+ font-family: "/only screen/";
+ width: 0em;
+ }
+ &.foundation-mq-medium {
+ font-family: "/only screen and (min-width:40.063em)/";
+ width: 40.063em;
+ }
+ &.foundation-mq-large {
+ font-family: "/only screen and (min-width:64.063em)/";
+ width: 64.063em;
+ }
+ &.foundation-mq-xlarge {
+ font-family: "/only screen and (min-width:90.063em)/";
+ width: 90.063em;
+ }
+ &.foundation-mq-xxlarge {
+ font-family: "/only screen and (min-width:120.063em)/";
+ width: 120.063em;
+ }
+ &.foundation-data-attribute-namespace {
+ font-family: false;
+ }
+}
+
+html, body {
+ height: 100%;
+}
+
+* {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ &:before, &:after {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ }
+}
+
+html {
+ font-size: 100%;
+}
+
+body {
+ font-family: "Open Sans", "Microsoft YaHei", "Hiragino Sans GB", "Hiragino Sans GB W3", "微软雅黑", "Helvetica Neue", Arial, sans-serif;
+ font-size: 13px;
+ line-height: 1.2em;
+ background: white;
+ color: #333;
+ padding: 0;
+ margin: 0;
+ font-weight: normal;
+ -webkit-font-smoothing: antialiased;
+ font-style: normal;
+ position: relative;
+ cursor: default;
+}
+
+a:hover {
+ cursor: pointer;
+}
+
+img {
+ max-width: 100%;
+ height: auto;
+ -ms-interpolation-mode: bicubic;
+}
+
+#map_canvas {
+ img, embed, object {
+ max-width: none !important;
+ }
+}
+
+.map_canvas {
+ img, embed, object {
+ max-width: none !important;
+ }
+}
+
+.left {
+ float: left !important;
+}
+
+.right {
+ float: right !important;
+}
+
+.clearfix {
+ &:before {
+ content: " ";
+ display: table;
+ }
+ &:after {
+ content: " ";
+ display: table;
+ clear: both;
+ }
+}
+
+.hide {
+ display: none;
+}
+
+.antialiased {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+img {
+ display: inline-block;
+ vertical-align: middle;
+}
+
+textarea {
+ height: auto;
+ min-height: 50px;
+ &:focus {
+ outline: none;
+ }
+}
+
+select {
+ width: 100%;
+}
+
+.row {
+ width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+ margin-top: 0;
+ margin-bottom: 0;
+ &:before {
+ content: " ";
+ display: table;
+ }
+ &:after {
+ content: " ";
+ display: table;
+ clear: both;
+ }
+ &.collapse {
+ > {
+ .column, .columns {
+ padding-left: 0;
+ padding-right: 0;
+ }
+ }
+ .row {
+ margin-left: 0;
+ margin-right: 0;
+ }
+ }
+ .row {
+ width: auto;
+ margin-left: -0.9375em;
+ margin-right: -0.9375em;
+ margin-top: 0;
+ margin-bottom: 0;
+ max-width: none;
+ &:before {
+ content: " ";
+ display: table;
+ }
+ &:after {
+ content: " ";
+ display: table;
+ clear: both;
+ }
+ &.collapse {
+ width: auto;
+ margin: 0;
+ max-width: none;
+ &:before {
+ content: " ";
+ display: table;
+ }
+ &:after {
+ content: " ";
+ display: table;
+ clear: both;
+ }
+ }
+ }
+}
+
+.column, .columns {
+ padding-left: 0.9375em;
+ padding-right: 0.9375em;
+ width: 100%;
+ float: left;
+}
+
+@media only screen {
+ .small-push-0 {
+ position: relative;
+ left: 0%;
+ right: auto;
+ }
+ .small-pull-0 {
+ position: relative;
+ right: 0%;
+ left: auto;
+ }
+ .small-push-1 {
+ position: relative;
+ left: 8.33333%;
+ right: auto;
+ }
+ .small-pull-1 {
+ position: relative;
+ right: 8.33333%;
+ left: auto;
+ }
+ .small-push-2 {
+ position: relative;
+ left: 16.66667%;
+ right: auto;
+ }
+ .small-pull-2 {
+ position: relative;
+ right: 16.66667%;
+ left: auto;
+ }
+ .small-push-3 {
+ position: relative;
+ left: 25%;
+ right: auto;
+ }
+ .small-pull-3 {
+ position: relative;
+ right: 25%;
+ left: auto;
+ }
+ .small-push-4 {
+ position: relative;
+ left: 33.33333%;
+ right: auto;
+ }
+ .small-pull-4 {
+ position: relative;
+ right: 33.33333%;
+ left: auto;
+ }
+ .small-push-5 {
+ position: relative;
+ left: 41.66667%;
+ right: auto;
+ }
+ .small-pull-5 {
+ position: relative;
+ right: 41.66667%;
+ left: auto;
+ }
+ .small-push-6 {
+ position: relative;
+ left: 50%;
+ right: auto;
+ }
+ .small-pull-6 {
+ position: relative;
+ right: 50%;
+ left: auto;
+ }
+ .small-push-7 {
+ position: relative;
+ left: 58.33333%;
+ right: auto;
+ }
+ .small-pull-7 {
+ position: relative;
+ right: 58.33333%;
+ left: auto;
+ }
+ .small-push-8 {
+ position: relative;
+ left: 66.66667%;
+ right: auto;
+ }
+ .small-pull-8 {
+ position: relative;
+ right: 66.66667%;
+ left: auto;
+ }
+ .small-push-9 {
+ position: relative;
+ left: 75%;
+ right: auto;
+ }
+ .small-pull-9 {
+ position: relative;
+ right: 75%;
+ left: auto;
+ }
+ .small-push-10 {
+ position: relative;
+ left: 83.33333%;
+ right: auto;
+ }
+ .small-pull-10 {
+ position: relative;
+ right: 83.33333%;
+ left: auto;
+ }
+ .small-push-11 {
+ position: relative;
+ left: 91.66667%;
+ right: auto;
+ }
+ .small-pull-11 {
+ position: relative;
+ right: 91.66667%;
+ left: auto;
+ }
+ .column, .columns {
+ position: relative;
+ padding-left: 0.9375em;
+ padding-right: 0.9375em;
+ float: left;
+ }
+ .small-1 {
+ width: 8.33333%;
+ }
+ .small-2 {
+ width: 16.66667%;
+ }
+ .small-3 {
+ width: 25%;
+ }
+ .small-4 {
+ width: 33.33333%;
+ }
+ .small-5 {
+ width: 41.66667%;
+ }
+ .small-6 {
+ width: 50%;
+ }
+ .small-7 {
+ width: 58.33333%;
+ }
+ .small-8 {
+ width: 66.66667%;
+ }
+ .small-9 {
+ width: 75%;
+ }
+ .small-10 {
+ width: 83.33333%;
+ }
+ .small-11 {
+ width: 91.66667%;
+ }
+ .small-12 {
+ width: 100%;
+ }
+ [class*="column"] + [class*="column"] {
+ &:last-child {
+ float: right;
+ }
+ &.end {
+ float: left;
+ }
+ }
+ .small-offset-0 {
+ margin-left: 0% !important;
+ }
+ .small-offset-1 {
+ margin-left: 8.33333% !important;
+ }
+ .small-offset-2 {
+ margin-left: 16.66667% !important;
+ }
+ .small-offset-3 {
+ margin-left: 25% !important;
+ }
+ .small-offset-4 {
+ margin-left: 33.33333% !important;
+ }
+ .small-offset-5 {
+ margin-left: 41.66667% !important;
+ }
+ .small-offset-6 {
+ margin-left: 50% !important;
+ }
+ .small-offset-7 {
+ margin-left: 58.33333% !important;
+ }
+ .small-offset-8 {
+ margin-left: 66.66667% !important;
+ }
+ .small-offset-9 {
+ margin-left: 75% !important;
+ }
+ .small-offset-10 {
+ margin-left: 83.33333% !important;
+ }
+ .small-offset-11 {
+ margin-left: 91.66667% !important;
+ }
+ .small-reset-order {
+ margin-left: 0;
+ margin-right: 0;
+ left: auto;
+ right: auto;
+ float: left;
+ }
+ .column.small-centered, .columns.small-centered {
+ margin-left: auto;
+ margin-right: auto;
+ float: none !important;
+ }
+ .column.small-uncentered, .columns.small-uncentered {
+ margin-left: 0;
+ margin-right: 0;
+ float: left !important;
+ }
+ .column.small-uncentered.opposite, .columns.small-uncentered.opposite {
+ float: right;
+ }
+}
+
+@media only screen and (min-width: 40.063em) {
+ .medium-push-0 {
+ position: relative;
+ left: 0%;
+ right: auto;
+ }
+ .medium-pull-0 {
+ position: relative;
+ right: 0%;
+ left: auto;
+ }
+ .medium-push-1 {
+ position: relative;
+ left: 8.33333%;
+ right: auto;
+ }
+ .medium-pull-1 {
+ position: relative;
+ right: 8.33333%;
+ left: auto;
+ }
+ .medium-push-2 {
+ position: relative;
+ left: 16.66667%;
+ right: auto;
+ }
+ .medium-pull-2 {
+ position: relative;
+ right: 16.66667%;
+ left: auto;
+ }
+ .medium-push-3 {
+ position: relative;
+ left: 25%;
+ right: auto;
+ }
+ .medium-pull-3 {
+ position: relative;
+ right: 25%;
+ left: auto;
+ }
+ .medium-push-4 {
+ position: relative;
+ left: 33.33333%;
+ right: auto;
+ }
+ .medium-pull-4 {
+ position: relative;
+ right: 33.33333%;
+ left: auto;
+ }
+ .medium-push-5 {
+ position: relative;
+ left: 41.66667%;
+ right: auto;
+ }
+ .medium-pull-5 {
+ position: relative;
+ right: 41.66667%;
+ left: auto;
+ }
+ .medium-push-6 {
+ position: relative;
+ left: 50%;
+ right: auto;
+ }
+ .medium-pull-6 {
+ position: relative;
+ right: 50%;
+ left: auto;
+ }
+ .medium-push-7 {
+ position: relative;
+ left: 58.33333%;
+ right: auto;
+ }
+ .medium-pull-7 {
+ position: relative;
+ right: 58.33333%;
+ left: auto;
+ }
+ .medium-push-8 {
+ position: relative;
+ left: 66.66667%;
+ right: auto;
+ }
+ .medium-pull-8 {
+ position: relative;
+ right: 66.66667%;
+ left: auto;
+ }
+ .medium-push-9 {
+ position: relative;
+ left: 75%;
+ right: auto;
+ }
+ .medium-pull-9 {
+ position: relative;
+ right: 75%;
+ left: auto;
+ }
+ .medium-push-10 {
+ position: relative;
+ left: 83.33333%;
+ right: auto;
+ }
+ .medium-pull-10 {
+ position: relative;
+ right: 83.33333%;
+ left: auto;
+ }
+ .medium-push-11 {
+ position: relative;
+ left: 91.66667%;
+ right: auto;
+ }
+ .medium-pull-11 {
+ position: relative;
+ right: 91.66667%;
+ left: auto;
+ }
+ .column, .columns {
+ position: relative;
+ padding-left: 0.9375em;
+ padding-right: 0.9375em;
+ float: left;
+ }
+ .medium-1 {
+ width: 8.33333%;
+ }
+ .medium-2 {
+ width: 16.66667%;
+ }
+ .medium-3 {
+ width: 25%;
+ }
+ .medium-4 {
+ width: 33.33333%;
+ }
+ .medium-5 {
+ width: 41.66667%;
+ }
+ .medium-6 {
+ width: 50%;
+ }
+ .medium-7 {
+ width: 58.33333%;
+ }
+ .medium-8 {
+ width: 66.66667%;
+ }
+ .medium-9 {
+ width: 75%;
+ }
+ .medium-10 {
+ width: 83.33333%;
+ }
+ .medium-11 {
+ width: 91.66667%;
+ }
+ .medium-12 {
+ width: 100%;
+ }
+ [class*="column"] + [class*="column"] {
+ &:last-child {
+ float: right;
+ }
+ &.end {
+ float: left;
+ }
+ }
+ .medium-offset-0 {
+ margin-left: 0% !important;
+ }
+ .medium-offset-1 {
+ margin-left: 8.33333% !important;
+ }
+ .medium-offset-2 {
+ margin-left: 16.66667% !important;
+ }
+ .medium-offset-3 {
+ margin-left: 25% !important;
+ }
+ .medium-offset-4 {
+ margin-left: 33.33333% !important;
+ }
+ .medium-offset-5 {
+ margin-left: 41.66667% !important;
+ }
+ .medium-offset-6 {
+ margin-left: 50% !important;
+ }
+ .medium-offset-7 {
+ margin-left: 58.33333% !important;
+ }
+ .medium-offset-8 {
+ margin-left: 66.66667% !important;
+ }
+ .medium-offset-9 {
+ margin-left: 75% !important;
+ }
+ .medium-offset-10 {
+ margin-left: 83.33333% !important;
+ }
+ .medium-offset-11 {
+ margin-left: 91.66667% !important;
+ }
+ .medium-reset-order {
+ margin-left: 0;
+ margin-right: 0;
+ left: auto;
+ right: auto;
+ float: left;
+ }
+ .column.medium-centered, .columns.medium-centered {
+ margin-left: auto;
+ margin-right: auto;
+ float: none !important;
+ }
+ .column.medium-uncentered, .columns.medium-uncentered {
+ margin-left: 0;
+ margin-right: 0;
+ float: left !important;
+ }
+ .column.medium-uncentered.opposite, .columns.medium-uncentered.opposite {
+ float: right;
+ }
+ .push-0 {
+ position: relative;
+ left: 0%;
+ right: auto;
+ }
+ .pull-0 {
+ position: relative;
+ right: 0%;
+ left: auto;
+ }
+ .push-1 {
+ position: relative;
+ left: 8.33333%;
+ right: auto;
+ }
+ .pull-1 {
+ position: relative;
+ right: 8.33333%;
+ left: auto;
+ }
+ .push-2 {
+ position: relative;
+ left: 16.66667%;
+ right: auto;
+ }
+ .pull-2 {
+ position: relative;
+ right: 16.66667%;
+ left: auto;
+ }
+ .push-3 {
+ position: relative;
+ left: 25%;
+ right: auto;
+ }
+ .pull-3 {
+ position: relative;
+ right: 25%;
+ left: auto;
+ }
+ .push-4 {
+ position: relative;
+ left: 33.33333%;
+ right: auto;
+ }
+ .pull-4 {
+ position: relative;
+ right: 33.33333%;
+ left: auto;
+ }
+ .push-5 {
+ position: relative;
+ left: 41.66667%;
+ right: auto;
+ }
+ .pull-5 {
+ position: relative;
+ right: 41.66667%;
+ left: auto;
+ }
+ .push-6 {
+ position: relative;
+ left: 50%;
+ right: auto;
+ }
+ .pull-6 {
+ position: relative;
+ right: 50%;
+ left: auto;
+ }
+ .push-7 {
+ position: relative;
+ left: 58.33333%;
+ right: auto;
+ }
+ .pull-7 {
+ position: relative;
+ right: 58.33333%;
+ left: auto;
+ }
+ .push-8 {
+ position: relative;
+ left: 66.66667%;
+ right: auto;
+ }
+ .pull-8 {
+ position: relative;
+ right: 66.66667%;
+ left: auto;
+ }
+ .push-9 {
+ position: relative;
+ left: 75%;
+ right: auto;
+ }
+ .pull-9 {
+ position: relative;
+ right: 75%;
+ left: auto;
+ }
+ .push-10 {
+ position: relative;
+ left: 83.33333%;
+ right: auto;
+ }
+ .pull-10 {
+ position: relative;
+ right: 83.33333%;
+ left: auto;
+ }
+ .push-11 {
+ position: relative;
+ left: 91.66667%;
+ right: auto;
+ }
+ .pull-11 {
+ position: relative;
+ right: 91.66667%;
+ left: auto;
+ }
+}
+
+@media only screen and (min-width: 64.063em) {
+ .large-push-0 {
+ position: relative;
+ left: 0%;
+ right: auto;
+ }
+ .large-pull-0 {
+ position: relative;
+ right: 0%;
+ left: auto;
+ }
+ .large-push-1 {
+ position: relative;
+ left: 8.33333%;
+ right: auto;
+ }
+ .large-pull-1 {
+ position: relative;
+ right: 8.33333%;
+ left: auto;
+ }
+ .large-push-2 {
+ position: relative;
+ left: 16.66667%;
+ right: auto;
+ }
+ .large-pull-2 {
+ position: relative;
+ right: 16.66667%;
+ left: auto;
+ }
+ .large-push-3 {
+ position: relative;
+ left: 25%;
+ right: auto;
+ }
+ .large-pull-3 {
+ position: relative;
+ right: 25%;
+ left: auto;
+ }
+ .large-push-4 {
+ position: relative;
+ left: 33.33333%;
+ right: auto;
+ }
+ .large-pull-4 {
+ position: relative;
+ right: 33.33333%;
+ left: auto;
+ }
+ .large-push-5 {
+ position: relative;
+ left: 41.66667%;
+ right: auto;
+ }
+ .large-pull-5 {
+ position: relative;
+ right: 41.66667%;
+ left: auto;
+ }
+ .large-push-6 {
+ position: relative;
+ left: 50%;
+ right: auto;
+ }
+ .large-pull-6 {
+ position: relative;
+ right: 50%;
+ left: auto;
+ }
+ .large-push-7 {
+ position: relative;
+ left: 58.33333%;
+ right: auto;
+ }
+ .large-pull-7 {
+ position: relative;
+ right: 58.33333%;
+ left: auto;
+ }
+ .large-push-8 {
+ position: relative;
+ left: 66.66667%;
+ right: auto;
+ }
+ .large-pull-8 {
+ position: relative;
+ right: 66.66667%;
+ left: auto;
+ }
+ .large-push-9 {
+ position: relative;
+ left: 75%;
+ right: auto;
+ }
+ .large-pull-9 {
+ position: relative;
+ right: 75%;
+ left: auto;
+ }
+ .large-push-10 {
+ position: relative;
+ left: 83.33333%;
+ right: auto;
+ }
+ .large-pull-10 {
+ position: relative;
+ right: 83.33333%;
+ left: auto;
+ }
+ .large-push-11 {
+ position: relative;
+ left: 91.66667%;
+ right: auto;
+ }
+ .large-pull-11 {
+ position: relative;
+ right: 91.66667%;
+ left: auto;
+ }
+ .column, .columns {
+ position: relative;
+ padding-left: 0.9375em;
+ padding-right: 0.9375em;
+ float: left;
+ }
+ .large-1 {
+ width: 8.33333%;
+ }
+ .large-2 {
+ width: 16.66667%;
+ }
+ .large-3 {
+ width: 25%;
+ }
+ .large-4 {
+ width: 33.33333%;
+ }
+ .large-5 {
+ width: 41.66667%;
+ }
+ .large-6 {
+ width: 50%;
+ }
+ .large-7 {
+ width: 58.33333%;
+ }
+ .large-8 {
+ width: 66.66667%;
+ }
+ .large-9 {
+ width: 75%;
+ }
+ .large-10 {
+ width: 83.33333%;
+ }
+ .large-11 {
+ width: 91.66667%;
+ }
+ .large-12 {
+ width: 100%;
+ }
+ [class*="column"] + [class*="column"] {
+ &:last-child {
+ float: right;
+ }
+ &.end {
+ float: left;
+ }
+ }
+ .large-offset-0 {
+ margin-left: 0% !important;
+ }
+ .large-offset-1 {
+ margin-left: 8.33333% !important;
+ }
+ .large-offset-2 {
+ margin-left: 16.66667% !important;
+ }
+ .large-offset-3 {
+ margin-left: 25% !important;
+ }
+ .large-offset-4 {
+ margin-left: 33.33333% !important;
+ }
+ .large-offset-5 {
+ margin-left: 41.66667% !important;
+ }
+ .large-offset-6 {
+ margin-left: 50% !important;
+ }
+ .large-offset-7 {
+ margin-left: 58.33333% !important;
+ }
+ .large-offset-8 {
+ margin-left: 66.66667% !important;
+ }
+ .large-offset-9 {
+ margin-left: 75% !important;
+ }
+ .large-offset-10 {
+ margin-left: 83.33333% !important;
+ }
+ .large-offset-11 {
+ margin-left: 91.66667% !important;
+ }
+ .large-reset-order {
+ margin-left: 0;
+ margin-right: 0;
+ left: auto;
+ right: auto;
+ float: left;
+ }
+ .column.large-centered, .columns.large-centered {
+ margin-left: auto;
+ margin-right: auto;
+ float: none !important;
+ }
+ .column.large-uncentered, .columns.large-uncentered {
+ margin-left: 0;
+ margin-right: 0;
+ float: left !important;
+ }
+ .column.large-uncentered.opposite, .columns.large-uncentered.opposite {
+ float: right;
+ }
+ .push-0 {
+ position: relative;
+ left: 0%;
+ right: auto;
+ }
+ .pull-0 {
+ position: relative;
+ right: 0%;
+ left: auto;
+ }
+ .push-1 {
+ position: relative;
+ left: 8.33333%;
+ right: auto;
+ }
+ .pull-1 {
+ position: relative;
+ right: 8.33333%;
+ left: auto;
+ }
+ .push-2 {
+ position: relative;
+ left: 16.66667%;
+ right: auto;
+ }
+ .pull-2 {
+ position: relative;
+ right: 16.66667%;
+ left: auto;
+ }
+ .push-3 {
+ position: relative;
+ left: 25%;
+ right: auto;
+ }
+ .pull-3 {
+ position: relative;
+ right: 25%;
+ left: auto;
+ }
+ .push-4 {
+ position: relative;
+ left: 33.33333%;
+ right: auto;
+ }
+ .pull-4 {
+ position: relative;
+ right: 33.33333%;
+ left: auto;
+ }
+ .push-5 {
+ position: relative;
+ left: 41.66667%;
+ right: auto;
+ }
+ .pull-5 {
+ position: relative;
+ right: 41.66667%;
+ left: auto;
+ }
+ .push-6 {
+ position: relative;
+ left: 50%;
+ right: auto;
+ }
+ .pull-6 {
+ position: relative;
+ right: 50%;
+ left: auto;
+ }
+ .push-7 {
+ position: relative;
+ left: 58.33333%;
+ right: auto;
+ }
+ .pull-7 {
+ position: relative;
+ right: 58.33333%;
+ left: auto;
+ }
+ .push-8 {
+ position: relative;
+ left: 66.66667%;
+ right: auto;
+ }
+ .pull-8 {
+ position: relative;
+ right: 66.66667%;
+ left: auto;
+ }
+ .push-9 {
+ position: relative;
+ left: 75%;
+ right: auto;
+ }
+ .pull-9 {
+ position: relative;
+ right: 75%;
+ left: auto;
+ }
+ .push-10 {
+ position: relative;
+ left: 83.33333%;
+ right: auto;
+ }
+ .pull-10 {
+ position: relative;
+ right: 83.33333%;
+ left: auto;
+ }
+ .push-11 {
+ position: relative;
+ left: 91.66667%;
+ right: auto;
+ }
+ .pull-11 {
+ position: relative;
+ right: 91.66667%;
+ left: auto;
+ }
+}
+
+.inline-list {
+ margin: 0 auto 1.0625rem auto;
+ margin-left: -1.375rem;
+ margin-right: 0;
+ padding: 0;
+ list-style: none;
+ overflow: hidden;
+ > li {
+ list-style: none;
+ float: left;
+ margin-left: 1.375rem;
+ display: block;
+ > * {
+ display: block;
+ }
+ }
+}
+
+.text-left {
+ text-align: left !important;
+}
+
+.text-right {
+ text-align: right !important;
+}
+
+.text-center {
+ text-align: center !important;
+}
+
+.text-justify {
+ text-align: justify !important;
+}
+
+@media only screen and (max-width: 40em) {
+ .small-only-text-left {
+ text-align: left !important;
+ }
+ .small-only-text-right {
+ text-align: right !important;
+ }
+ .small-only-text-center {
+ text-align: center !important;
+ }
+ .small-only-text-justify {
+ text-align: justify !important;
+ }
+}
+
+@media only screen {
+ .small-text-left {
+ text-align: left !important;
+ }
+ .small-text-right {
+ text-align: right !important;
+ }
+ .small-text-center {
+ text-align: center !important;
+ }
+ .small-text-justify {
+ text-align: justify !important;
+ }
+}
+
+@media only screen and (min-width: 40.063em) and (max-width: 64em) {
+ .medium-only-text-left {
+ text-align: left !important;
+ }
+ .medium-only-text-right {
+ text-align: right !important;
+ }
+ .medium-only-text-center {
+ text-align: center !important;
+ }
+ .medium-only-text-justify {
+ text-align: justify !important;
+ }
+}
+
+@media only screen and (min-width: 40.063em) {
+ .medium-text-left {
+ text-align: left !important;
+ }
+ .medium-text-right {
+ text-align: right !important;
+ }
+ .medium-text-center {
+ text-align: center !important;
+ }
+ .medium-text-justify {
+ text-align: justify !important;
+ }
+}
+
+@media only screen and (min-width: 64.063em) and (max-width: 90em) {
+ .large-only-text-left {
+ text-align: left !important;
+ }
+ .large-only-text-right {
+ text-align: right !important;
+ }
+ .large-only-text-center {
+ text-align: center !important;
+ }
+ .large-only-text-justify {
+ text-align: justify !important;
+ }
+}
+
+@media only screen and (min-width: 64.063em) {
+ .large-text-left {
+ text-align: left !important;
+ }
+ .large-text-right {
+ text-align: right !important;
+ }
+ .large-text-center {
+ text-align: center !important;
+ }
+ .large-text-justify {
+ text-align: justify !important;
+ }
+}
+
+@media only screen and (min-width: 90.063em) and (max-width: 120em) {
+ .xlarge-only-text-left {
+ text-align: left !important;
+ }
+ .xlarge-only-text-right {
+ text-align: right !important;
+ }
+ .xlarge-only-text-center {
+ text-align: center !important;
+ }
+ .xlarge-only-text-justify {
+ text-align: justify !important;
+ }
+}
+
+@media only screen and (min-width: 90.063em) {
+ .xlarge-text-left {
+ text-align: left !important;
+ }
+ .xlarge-text-right {
+ text-align: right !important;
+ }
+ .xlarge-text-center {
+ text-align: center !important;
+ }
+ .xlarge-text-justify {
+ text-align: justify !important;
+ }
+}
+
+@media only screen and (min-width: 120.063em) and (max-width: 99999999em) {
+ .xxlarge-only-text-left {
+ text-align: left !important;
+ }
+ .xxlarge-only-text-right {
+ text-align: right !important;
+ }
+ .xxlarge-only-text-center {
+ text-align: center !important;
+ }
+ .xxlarge-only-text-justify {
+ text-align: justify !important;
+ }
+}
+
+@media only screen and (min-width: 120.063em) {
+ .xxlarge-text-left {
+ text-align: left !important;
+ }
+ .xxlarge-text-right {
+ text-align: right !important;
+ }
+ .xxlarge-text-center {
+ text-align: center !important;
+ }
+ .xxlarge-text-justify {
+ text-align: justify !important;
+ }
+}
+
+/* Typography resets */
+
+div, dl, dt, dd, ul, ol, li, h1, h2, h3, h4, h5, h6, pre, form, p, blockquote, th, td {
+ margin: 0;
+ padding: 0;
+}
+
+/* Default Link Styles */
+
+a {
+ color: #2ba6cb;
+ text-decoration: none;
+ line-height: inherit;
+ &:hover, &:focus {
+ color: #258faf;
+ outline: none;
+ }
+ img {
+ border: none;
+ }
+}
+
+/* Default paragraph styles */
+
+p {
+ font-family: inherit;
+ font-weight: normal;
+ font-size: 0.9rem;
+ line-height: 1.4;
+ margin-bottom: 1.25rem;
+ text-rendering: optimizeLegibility;
+ &.lead {
+ font-size: 1.21875rem;
+ line-height: 1.4;
+ }
+ aside {
+ font-size: 0.875rem;
+ line-height: 1.35;
+ font-style: italic;
+ }
+}
+
+/* Default header styles */
+
+h1, h2, h3, h4, h5, h6 {
+ font-weight: normal;
+ font-style: normal;
+ color: #222;
+ text-rendering: optimizeLegibility;
+ margin-top: 0.2rem;
+ margin-bottom: 0.5rem;
+ line-height: 1.2;
+}
+
+h1 small, h2 small, h3 small, h4 small, h5 small, h6 small {
+ font-size: 60%;
+ color: #6f6f6f;
+ line-height: 0;
+}
+
+h1 {
+ font-size: 2.125rem;
+}
+
+h2 {
+ font-size: 1.6875rem;
+}
+
+h3 {
+ font-size: 1.375rem;
+}
+
+h4, h5 {
+ font-size: 1.125rem;
+}
+
+h6 {
+ font-size: 1rem;
+}
+
+.subheader {
+ line-height: 1.4;
+ color: #6f6f6f;
+ font-weight: normal;
+ margin-top: 0.2rem;
+ margin-bottom: 0.5rem;
+}
+
+hr {
+ border: solid #dddddd;
+ border-width: 1px 0 0;
+ clear: both;
+ margin: 1.25rem 0 1.1875rem;
+ height: 0;
+}
+
+/* Helpful Typography Defaults */
+
+em, i {
+ font-style: italic;
+ line-height: inherit;
+}
+
+strong, b {
+ font-weight: bold;
+ line-height: inherit;
+}
+
+small {
+ font-size: 60%;
+ line-height: inherit;
+}
+
+code {
+ font-family: Consolas, "Liberation Mono", Courier, monospace;
+ font-weight: bold;
+ color: #910b0e;
+}
+
+/* Lists */
+
+ul, ol, dl {
+ font-size: 0.9rem;
+ line-height: 1.6;
+ margin-bottom: 1.25rem;
+ list-style-position: outside;
+ font-family: inherit;
+}
+
+ul {
+ margin-left: 0;
+ &.bullets {
+ margin-left: 1.1rem;
+ li {
+ margin-left: 1.25rem;
+ margin-bottom: 0;
+ list-style: circle;
+ }
+ }
+ li {
+ margin-bottom: 0;
+ list-style: none;
+ }
+}
+
+/* Abbreviations */
+
+abbr, acronym {
+ text-transform: uppercase;
+ font-size: 90%;
+ color: #222222;
+ border-bottom: 1px dotted #dddddd;
+ cursor: help;
+}
+
+abbr {
+ text-transform: none;
+}
+
+/* Blockquotes */
+
+blockquote {
+ margin: 0 0 1.25rem;
+ padding: 0.5625rem 1.25rem 0 1.1875rem;
+ border-left: 1px solid #dddddd;
+ cite {
+ display: block;
+ font-size: 0.8125rem;
+ color: #555555;
+ &:before {
+ content: "\2014 \0020";
+ }
+ a {
+ color: #555555;
+ &:visited {
+ color: #555555;
+ }
+ }
+ }
+ line-height: 1.6;
+ color: #6f6f6f;
+ p {
+ line-height: 1.6;
+ color: #6f6f6f;
+ }
+}
+
+/* Microformats */
+
+.vcard {
+ display: inline-block;
+ margin: 0 0 1.25rem 0;
+ border: 1px solid #dddddd;
+ padding: 0.625rem 0.75rem;
+ li {
+ margin: 0;
+ display: block;
+ }
+ .fn {
+ font-weight: bold;
+ font-size: 0.9375rem;
+ }
+}
+
+.vevent {
+ .summary {
+ font-weight: bold;
+ }
+ abbr {
+ cursor: default;
+ text-decoration: none;
+ font-weight: bold;
+ border: none;
+ padding: 0 0.0625rem;
+ }
+}
+
+@media only screen and (min-width: 40.063em) {
+ h1, h2, h3, h4, h5, h6 {
+ line-height: 1.2;
+ }
+ h1 {
+ font-size: 2.55rem;
+ }
+ h2 {
+ font-size: 2.3125rem;
+ }
+ h3 {
+ font-size: 1.4875rem;
+ }
+ h4 {
+ font-size: 1.1375rem;
+ }
+}
+
+/*
+ * Print styles.
+ *
+ * Inlined to avoid required HTTP connection: www.phpied.com/delay-loading-your-print-css/
+ * Credit to Paul Irish and HTML5 Boilerplate (html5boilerplate.com)
+*/
+
+.print-only {
+ display: none !important;
+}
+
+@media print {
+ * {
+ background: transparent !important;
+ color: black !important;
+ /* Black prints faster: h5bp.com/s */
+ box-shadow: none !important;
+ text-shadow: none !important;
+ }
+ a {
+ text-decoration: underline;
+ &:visited {
+ text-decoration: underline;
+ }
+ &[href]:after {
+ content: " (" attr(href) ")";
+ }
+ }
+ abbr[title]:after {
+ content: " (" attr(title) ")";
+ }
+ .ir a:after {
+ content: "";
+ }
+ a {
+ &[href^="javascript:"]:after, &[href^="#"]:after {
+ content: "";
+ }
+ }
+ pre, blockquote {
+ border: 1px solid #999999;
+ page-break-inside: avoid;
+ }
+ thead {
+ display: table-header-group;
+ /* h5bp.com/t */
+ }
+ tr {
+ page-break-inside: avoid;
+ }
+ img {
+ page-break-inside: avoid;
+ max-width: 100% !important;
+ }
+ @page {
+ margin: 0.5cm;
+ }
+
+ p, h2, h3 {
+ orphans: 3;
+ widows: 3;
+ }
+ h2, h3 {
+ page-break-after: avoid;
+ }
+ .hide-on-print {
+ display: none !important;
+ }
+ .print-only {
+ display: block !important;
+ }
+ .hide-for-print {
+ display: none !important;
+ }
+ .show-for-print {
+ display: inherit !important;
+ }
+}
+
+.reveal-modal-bg {
+ position: fixed;
+ height: 100%;
+ width: 100%;
+ background: black;
+ background: rgba(0, 0, 0, 0.45);
+ z-index: 99;
+ display: none;
+ top: 0;
+ left: 0;
+}
+
+dialog, .reveal-modal {
+ visibility: hidden;
+ display: none;
+ position: absolute;
+ z-index: 100;
+ width: 100vw;
+ top: 0;
+ left: 0;
+ background-color: white;
+ padding: 1.25rem;
+ border: solid 1px #666666;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
+}
+
+@media only screen and (max-width: 40em) {
+ dialog, .reveal-modal {
+ min-height: 100vh;
+ }
+}
+
+@media only screen and (min-width: 40.063em) {
+ dialog, .reveal-modal {
+ left: 50%;
+ }
+}
+
+dialog {
+ .column, .columns {
+ min-width: 0;
+ }
+}
+
+.reveal-modal {
+ .column, .columns {
+ min-width: 0;
+ }
+}
+
+dialog > :first-child, .reveal-modal > :first-child {
+ margin-top: 0;
+}
+
+dialog > :last-child, .reveal-modal > :last-child {
+ margin-bottom: 0;
+}
+
+@media only screen and (min-width: 40.063em) {
+ dialog, .reveal-modal {
+ margin-left: -26%;
+ width: 50%;
+ }
+}
+
+@media only screen and (min-width: 40.063em) {
+ dialog, .reveal-modal {
+ top: 6.25rem;
+ }
+}
+
+dialog .close-reveal-modal, .reveal-modal .close-reveal-modal {
+ font-size: 2.5rem;
+ line-height: 1;
+ position: absolute;
+ top: 0.5rem;
+ right: 0.6875rem;
+ color: #aaaaaa;
+ font-weight: bold;
+ cursor: pointer;
+}
+
+dialog[open] {
+ display: block;
+ visibility: visible;
+}
+
+@media only screen and (min-width: 40.063em) {
+ dialog, .reveal-modal {
+ padding: 1.875rem;
+ }
+ dialog.radius, .reveal-modal.radius {
+ border-radius: 3px;
+ }
+ dialog.round, .reveal-modal.round {
+ border-radius: 1000px;
+ }
+ dialog.collapse, .reveal-modal.collapse {
+ padding: 0;
+ }
+ dialog.full, .reveal-modal.full {
+ top: 0;
+ left: 0;
+ height: 100vh;
+ min-height: 100vh;
+ margin-left: 0 !important;
+ }
+}
+
+@media only screen and (min-width: 40.063em) and (min-width: 40.063em) {
+ dialog.tiny, .reveal-modal.tiny {
+ margin-left: -15%;
+ width: 30%;
+ }
+}
+
+@media only screen and (min-width: 40.063em) and (min-width: 40.063em) {
+ dialog.small, .reveal-modal.small {
+ margin-left: -20%;
+ width: 40%;
+ }
+}
+
+@media only screen and (min-width: 40.063em) and (min-width: 40.063em) {
+ dialog.medium, .reveal-modal.medium {
+ margin-left: -30%;
+ width: 60%;
+ }
+}
+
+@media only screen and (min-width: 40.063em) and (min-width: 40.063em) {
+ dialog.large, .reveal-modal.large {
+ margin-left: -35%;
+ width: 70%;
+ }
+}
+
+@media only screen and (min-width: 40.063em) and (min-width: 40.063em) {
+ dialog.xlarge, .reveal-modal.xlarge {
+ margin-left: -47.5%;
+ width: 95%;
+ }
+}
+
+@media only screen and (min-width: 40.063em) and (min-width: 40.063em) {
+ dialog.full, .reveal-modal.full {
+ margin-left: -50vw;
+ width: 100vw;
+ }
+}
+
+@media print {
+ dialog, .reveal-modal {
+ background: white !important;
+ }
+}
+
+.label {
+ font-weight: normal;
+ text-align: center;
+ text-decoration: none;
+ line-height: 1;
+ white-space: nowrap;
+ display: inline-block;
+ position: relative;
+ margin-bottom: inherit;
+ padding: 0.25rem 0.5rem 0.375rem;
+ font-size: 0.6875rem;
+ background-color: #2ba6cb;
+ color: white;
+ &.radius {
+ border-radius: 3px;
+ }
+ &.round {
+ border-radius: 1000px;
+ }
+ &.alert {
+ background-color: #c60f13;
+ color: white;
+ }
+ &.success {
+ background-color: #5da423;
+ color: white;
+ }
+ &.secondary {
+ background-color: #e9e9e9;
+ color: #333333;
+ }
+}
+
+button, .button, input[type=button] {
+ cursor: pointer;
+ margin: 0 0 1.25rem;
+ border: none;
+ position: relative;
+ text-decoration: none;
+ text-align: center;
+ -webkit-appearance: none;
+ display: inline-block;
+ padding: 0.4rem 1.1rem;
+ font-size: 0.9rem;
+ background-color: #2ba6cb;
+ border-color: #2285a2;
+ color: white;
+ transition: background-color 150ms ease-out;
+ @include border-radius(2px);
+ &:hover, &:focus {
+ background-color: #2285a2;
+ outline: none;
+ color: white;
+ }
+ &.large {
+ padding-top: 1.125rem;
+ padding-right: 2.25rem;
+ padding-bottom: 1.1875rem;
+ padding-left: 2.25rem;
+ font-size: 1.25rem;
+ }
+
+ &.small {
+ padding-top: 0.875rem;
+ padding-right: 1.75rem;
+ padding-bottom: 0.9375rem;
+ padding-left: 1.75rem;
+ font-size: 0.8125rem;
+ }
+
+ &.tiny {
+ padding-top: 0.625rem;
+ padding-right: 1.25rem;
+ padding-bottom: 0.6875rem;
+ padding-left: 1.25rem;
+ font-size: 0.6875rem;
+ }
+
+ &.expand {
+ padding-right: 0;
+ padding-left: 0;
+ width: 100%;
+ }
+
+ &.left-align {
+ text-align: left;
+ text-indent: 0.75rem;
+ }
+
+ &.right-align {
+ text-align: right;
+ padding-right: 0.75rem;
+ }
+
+ &.round {
+ border-radius: 1000px;
+ }
+
+ &.disabled, &[disabled] {
+ background-color: #2285a2;
+ border-color: #2285a2;
+ color: white;
+ cursor: default;
+ opacity: 0.5;
+ box-shadow: none;
+ &:hover, &:focus {
+ background-color: #2285a2;
+ opacity: 0.5;
+ }
+ }
+}
+
+
+@media only screen and (min-width: 40.063em) {
+ button, .button {
+ display: inline-block;
+ }
+}
+
+.keystroke, kbd {
+ background-color: #ededed;
+ border-color: #dddddd;
+ color: #222222;
+ border-style: solid;
+ border-width: 1px;
+ margin: 0;
+ font-family: "Consolas", "Menlo", "Courier", monospace;
+ font-size: inherit;
+ padding: 0.125rem 0.25rem 0;
+ border-radius: 3px;
+}
+
+
+
+/* We use this to get basic styling on all basic form elements */
+input[type="text"],
+input[type="password"],
+input[type="date"],
+input[type="datetime"],
+input[type="datetime-local"],
+input[type="month"],
+input[type="week"],
+input[type="email"],
+input[type="number"],
+input[type="search"],
+input[type="tel"],
+input[type="time"],
+input[type="url"],
+textarea {
+ -webkit-appearance: none;
+ background-color: white;
+ font-family: inherit;
+ border: 1px solid #cccccc;
+ color: rgba(0, 0, 0, 0.75);
+ display: block;
+ font-size: 0.875rem;
+ margin: 0 0 1rem 0;
+ padding: 0.4rem;
+ width: 100%;
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+ input[type="text"]:focus,
+ input[type="password"]:focus,
+ input[type="date"]:focus,
+ input[type="datetime"]:focus,
+ input[type="datetime-local"]:focus,
+ input[type="month"]:focus,
+ input[type="week"]:focus,
+ input[type="email"]:focus,
+ input[type="number"]:focus,
+ input[type="search"]:focus,
+ input[type="tel"]:focus,
+ input[type="time"]:focus,
+ input[type="url"]:focus,
+ textarea:focus {}
+ input[type="text"]:focus,
+ input[type="password"]:focus,
+ input[type="date"]:focus,
+ input[type="datetime"]:focus,
+ input[type="datetime-local"]:focus,
+ input[type="month"]:focus,
+ input[type="week"]:focus,
+ input[type="email"]:focus,
+ input[type="number"]:focus,
+ input[type="search"]:focus,
+ input[type="tel"]:focus,
+ input[type="time"]:focus,
+ input[type="url"]:focus,
+ textarea:focus {
+ background: #fafafa;
+ border-color: #999999;
+ outline: none; }
+ input[type="text"][disabled], fieldset[disabled] input[type="text"],
+ input[type="password"][disabled], fieldset[disabled]
+ input[type="password"],
+ input[type="date"][disabled], fieldset[disabled]
+ input[type="date"],
+ input[type="datetime"][disabled], fieldset[disabled]
+ input[type="datetime"],
+ input[type="datetime-local"][disabled], fieldset[disabled]
+ input[type="datetime-local"],
+ input[type="month"][disabled], fieldset[disabled]
+ input[type="month"],
+ input[type="week"][disabled], fieldset[disabled]
+ input[type="week"],
+ input[type="email"][disabled], fieldset[disabled]
+ input[type="email"],
+ input[type="number"][disabled], fieldset[disabled]
+ input[type="number"],
+ input[type="search"][disabled], fieldset[disabled]
+ input[type="search"],
+ input[type="tel"][disabled], fieldset[disabled]
+ input[type="tel"],
+ input[type="time"][disabled], fieldset[disabled]
+ input[type="time"],
+ input[type="url"][disabled], fieldset[disabled]
+ input[type="url"],
+ textarea[disabled], fieldset[disabled]
+ textarea {
+ background-color: #dddddd; }
+ input[type="text"].radius,
+ input[type="password"].radius,
+ input[type="date"].radius,
+ input[type="datetime"].radius,
+ input[type="datetime-local"].radius,
+ input[type="month"].radius,
+ input[type="week"].radius,
+ input[type="email"].radius,
+ input[type="number"].radius,
+ input[type="search"].radius,
+ input[type="tel"].radius,
+ input[type="time"].radius,
+ input[type="url"].radius,
+ textarea.radius {
+ border-radius: 3px; }
+
+input[type="submit"] {
+ -webkit-appearance: none; }
+
+/* Respect enforced amount of rows for textarea */
+textarea[rows] {
+ height: auto; }
+
+/* Add height value for select elements to match text input height */
+select {
+ -webkit-appearance: none !important;
+ background-color: #fafafa;
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSI2cHgiIGhlaWdodD0iM3B4IiB2aWV3Qm94PSIwIDAgNiAzIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA2IDMiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxwb2x5Z29uIHBvaW50cz0iNS45OTIsMCAyLjk5MiwzIC0wLjAwOCwwICIvPjwvc3ZnPg==");
+ background-repeat: no-repeat;
+ background-position: 97% center;
+ border: 1px solid #cccccc;
+ padding: 0.5rem;
+ font-size: 0.875rem;
+ color: rgba(0, 0, 0, 0.75);
+ line-height: normal;
+ border-radius: 0;
+}
+ select.radius {
+ border-radius: 3px; }
+ select:hover {
+ background-color: #f3f3f3;
+ border-color: #999999; }
+
+/* Adjust margin for form elements below */
+input[type="file"],
+input[type="checkbox"],
+input[type="radio"],
+select {
+ margin: 0 0 1rem 0; }
+
+input[type="checkbox"] + label,
+input[type="radio"] + label {
+ display: inline-block;
+
+ margin-left: 0.5rem;
+ margin-right: 1rem;
+ margin-bottom: 0;
+ vertical-align: baseline; }
+
+/* Normalize file input width */
+input[type="file"] {
+ width: 100%; }
diff --git a/web-ui/app/scss/main.scss b/web-ui/app/scss/main.scss
new file mode 100644
index 00000000..23de32be
--- /dev/null
+++ b/web-ui/app/scss/main.scss
@@ -0,0 +1,46 @@
+@import "reset.scss";
+@import "foundation.scss";
+@import "compass/css3";
+@import "colors.scss";
+@import "styles.scss";
+
+
+html {
+ height:100%;
+}
+body {
+ min-height:100%;
+ overflow: hidden;
+ background: #FFF;
+}
+header#main {
+ overflow: hidden;
+ position: fixed;
+ top: 0;
+ padding: 5px 0;
+ width: 100%;
+ position: relative;
+ background: $secondary;
+ border-bottom: 1px solid lighten($secondary, 5%);
+ margin-bottom: 0;
+}
+
+
+.no-padding {
+ padding: 0;
+}
+
+.tip-msg {
+ padding: 10px;
+ margin: 8px 20px -25px 20px;
+ background: $warning;
+ color: darken($warning, 50%);
+ font-size: 0.9em;
+ i {
+ margin-right: 5px;
+ }
+}
+
+.text-right {
+ text-align: right;
+}
diff --git a/web-ui/app/scss/news-cycle.scss b/web-ui/app/scss/news-cycle.scss
new file mode 100644
index 00000000..8f813996
--- /dev/null
+++ b/web-ui/app/scss/news-cycle.scss
@@ -0,0 +1,13 @@
+@font-face {
+ font-family: 'News Cycle';
+ src: url('/fonts/NewsCycleRegular.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+}
+
+@font-face {
+ font-family: 'News Cycle';
+ src: url('/fonts/NewsCycleBold.ttf') format('truetype');
+ font-weight: bold;
+ font-style: normal;
+}
diff --git a/web-ui/app/scss/opensans.scss b/web-ui/app/scss/opensans.scss
new file mode 100644
index 00000000..5d5c7ff5
--- /dev/null
+++ b/web-ui/app/scss/opensans.scss
@@ -0,0 +1,61 @@
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 300;
+ src: local('Open Sans Light'), local('OpenSans-Light'), url('/fonts/OpenSans-Light.woff') format('woff');
+}
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Open Sans'), local('OpenSans'), url('/fonts/OpenSans.woff') format('woff');
+}
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 600;
+ src: local('Open Sans Semibold'), local('OpenSans-Semibold'), url('/fonts/OpenSans-Semibold.woff') format('woff');
+}
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 700;
+ src: local('Open Sans Bold'), local('OpenSans-Bold'), url('/fonts/OpenSans-Bold.woff') format('woff');
+}
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 800;
+ src: local('Open Sans Extrabold'), local('OpenSans-Extrabold'), url('/fonts/OpenSans-Extrabold.woff') format('woff');
+}
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 300;
+ src: local('Open Sans Light Italic'), local('OpenSansLight-Italic'), url('/fonts/OpenSansLight-Italic.woff') format('woff');
+}
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 400;
+ src: local('Open Sans Italic'), local('OpenSans-Italic'), url('/fonts/OpenSans-Italic.woff') format('woff');
+}
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 600;
+ src: local('Open Sans Semibold Italic'), local('OpenSans-SemiboldItalic'), url('/fonts/OpenSans-SemiboldItalic.woff
+') format('woff');
+}
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 700;
+ src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'), url('/fonts/OpenSans-BoldItalic.woff') format('woff');
+}
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 800;
+ src: local('Open Sans Extrabold Italic'), local('OpenSans-ExtraboldItalic'), url('/fonts/OpenSans-ExtraboldItalic.woff') format('woff');
+}
diff --git a/web-ui/app/scss/reset.scss b/web-ui/app/scss/reset.scss
new file mode 100644
index 00000000..55f8d054
--- /dev/null
+++ b/web-ui/app/scss/reset.scss
@@ -0,0 +1,421 @@
+/*! normalize.css v3.0.1 | MIT License | git.io/normalize */
+
+/**
+ * 1. Set default font family to sans-serif.
+ * 2. Prevent iOS text size adjust after orientation change, without disabling
+ * user zoom.
+ */
+
+html {
+ font-family: sans-serif;
+ /* 1 */
+ -ms-text-size-adjust: 100%;
+ /* 2 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+}
+
+/**
+ * Remove default margin.
+ */
+
+body {
+ margin: 0;
+}
+
+/* HTML5 display definitions
+ ========================================================================== */
+
+/**
+ * Correct `block` display not defined for any HTML5 element in IE 8/9.
+ * Correct `block` display not defined for `details` or `summary` in IE 10/11 and Firefox.
+ * Correct `block` display not defined for `main` in IE 11.
+ */
+
+article, aside, details, figcaption, figure, footer, header, hgroup, main, nav, section, summary {
+ display: block;
+}
+
+/**
+ * 1. Correct `inline-block` display not defined in IE 8/9.
+ * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
+ */
+
+audio, canvas, progress, video {
+ display: inline-block;
+ /* 1 */
+ vertical-align: baseline;
+ /* 2 */
+}
+
+/**
+ * Prevent modern browsers from displaying `audio` without controls.
+ * Remove excess height in iOS 5 devices.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/**
+ * Address `[hidden]` styling not present in IE 8/9/10.
+ * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.
+ */
+
+[hidden], template {
+ display: none;
+}
+
+/* Links
+ ========================================================================== */
+
+/**
+ * Remove the gray background color from active links in IE 10.
+ */
+
+a {
+ background: transparent;
+ &:active, &:hover {
+ outline: 0;
+ }
+}
+
+/**
+ * Improve readability when focused and also mouse hovered in all browsers.
+ */
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * Address styling not present in IE 8/9/10/11, Safari, and Chrome.
+ */
+
+abbr[title] {
+ border-bottom: 1px dotted;
+}
+
+/**
+ * Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
+ */
+
+b, strong {
+ font-weight: bold;
+}
+
+/**
+ * Address styling not present in Safari and Chrome.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/**
+ * Address variable `h1` font-size and margin within `section` and `article`
+ * contexts in Firefox 4+, Safari, and Chrome.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/**
+ * Address styling not present in IE 8/9.
+ */
+
+mark {
+ background: #ff0;
+ color: #000;
+}
+
+/**
+ * Address inconsistent and variable font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` affecting `line-height` in all browsers.
+ */
+
+sub {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+ top: -0.5em;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Remove border when inside `a` element in IE 8/9/10.
+ */
+
+img {
+ border: 0;
+}
+
+/**
+ * Correct overflow not hidden in IE 9/10/11.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * Address margin not present in IE 8/9 and Safari.
+ */
+
+figure {
+ margin: 1em 40px;
+}
+
+/**
+ * Address differences between Firefox and other browsers.
+ */
+
+hr {
+ -moz-box-sizing: content-box;
+ box-sizing: content-box;
+ height: 0;
+}
+
+/**
+ * Contain overflow in all browsers.
+ */
+
+pre {
+ overflow: auto;
+}
+
+/**
+ * Address odd `em`-unit font size rendering in all browsers.
+ */
+
+code, kbd, pre, samp {
+ font-family: monospace, monospace;
+ font-size: 1em;
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * Known limitation: by default, Chrome and Safari on OS X allow very limited
+ * styling of `select`, unless a `border` property is set.
+ */
+
+/**
+ * 1. Correct color not being inherited.
+ * Known issue: affects color of disabled elements.
+ * 2. Correct font properties not being inherited.
+ * 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
+ */
+
+button, input, optgroup, select, textarea {
+ color: inherit;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+ margin: 0;
+ /* 3 */
+}
+
+/**
+ * Address `overflow` set to `hidden` in IE 8/9/10/11.
+ */
+
+button {
+ overflow: visible;
+ text-transform: none;
+}
+
+/**
+ * Address inconsistent `text-transform` inheritance for `button` and `select`.
+ * All other form control elements do not inherit `text-transform` values.
+ * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
+ * Correct `select` style inheritance in Firefox.
+ */
+
+select {
+ text-transform: none;
+}
+
+/**
+ * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
+ * and `video` controls.
+ * 2. Correct inability to style clickable `input` types in iOS.
+ * 3. Improve usability and consistency of cursor style between image-type
+ * `input` and others.
+ */
+
+button, html input[type="button"] {
+ -webkit-appearance: button;
+ /* 2 */
+ cursor: pointer;
+ /* 3 */
+}
+
+input {
+ &[type="reset"], &[type="submit"] {
+ -webkit-appearance: button;
+ /* 2 */
+ cursor: pointer;
+ /* 3 */
+ }
+}
+
+/**
+ * Re-set default cursor for disabled elements.
+ */
+
+button[disabled], html input[disabled] {
+ cursor: default;
+}
+
+/**
+ * Remove inner padding and border in Firefox 4+.
+ */
+
+button::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+
+input {
+ &::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+ }
+ line-height: normal;
+ &[type="checkbox"], &[type="radio"] {
+ box-sizing: border-box;
+ /* 1 */
+ padding: 0;
+ /* 2 */
+ }
+ &[type="number"] {
+ &::-webkit-inner-spin-button, &::-webkit-outer-spin-button {
+ height: auto;
+ }
+ }
+ &[type="search"] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ -moz-box-sizing: content-box;
+ -webkit-box-sizing: content-box;
+ /* 2 */
+ box-sizing: content-box;
+ &::-webkit-search-cancel-button, &::-webkit-search-decoration {
+ -webkit-appearance: none;
+ }
+ }
+}
+
+/**
+ * Address Firefox 4+ setting `line-height` on `input` using `!important` in
+ * the UA stylesheet.
+ */
+
+/**
+ * It's recommended that you don't attempt to style these elements.
+ * Firefox's implementation doesn't respect box-sizing, padding, or width.
+ *
+ * 1. Address box sizing set to `content-box` in IE 8/9/10.
+ * 2. Remove excess padding in IE 8/9/10.
+ */
+
+/**
+ * Fix the cursor style for Chrome's increment/decrement buttons. For certain
+ * `font-size` values of the `input`, it causes the cursor style of the
+ * decrement button to change from `default` to `text`.
+ */
+
+/**
+ * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
+ * 2. Address `box-sizing` set to `border-box` in Safari and Chrome
+ * (include `-moz` to future-proof).
+ */
+
+/**
+ * Remove inner padding and search cancel button in Safari and Chrome on OS X.
+ * Safari (but not Chrome) clips the cancel button when the search input has
+ * padding (and `textfield` appearance).
+ */
+
+/**
+ * Define consistent border, margin, and padding.
+ */
+
+fieldset {
+ border: 1px solid #c0c0c0;
+ margin: 0 2px;
+ padding: 0.35em 0.625em 0.75em;
+}
+
+/**
+ * 1. Correct `color` not being inherited in IE 8/9/10/11.
+ * 2. Remove padding so people aren't caught out if they zero out fieldsets.
+ */
+
+legend {
+ border: 0;
+ /* 1 */
+ padding: 0;
+ /* 2 */
+}
+
+/**
+ * Remove default vertical scrollbar in IE 8/9/10/11.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * Don't inherit the `font-weight` (applied by a rule above).
+ * NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
+ */
+
+optgroup {
+ font-weight: bold;
+}
+
+/* Tables
+ ========================================================================== */
+
+/**
+ * Remove most spacing between table cells.
+ */
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+td, th {
+ padding: 0;
+}
diff --git a/web-ui/app/scss/styles.scss b/web-ui/app/scss/styles.scss
new file mode 100644
index 00000000..8f643b34
--- /dev/null
+++ b/web-ui/app/scss/styles.scss
@@ -0,0 +1,610 @@
+@import "compass/css3";
+@import "colors.scss";
+@import "mixins.scss";
+@import "alerts.scss";
+@import "read.scss";
+@import "reply.scss";
+@import "compose.scss";
+@import "security.scss";
+
+
+#logo {
+ color: #FFF;
+}
+
+.search-highlight {
+ background-color: $search-highlight;
+}
+
+
+@mixin list-actions {
+ #list-actions {
+ width: 100%;
+ height: 34px;
+ margin: 0;
+ border-top: 1px solid #FFF;
+ border-bottom: 2px solid lighten($top_pane, 30%);
+ background: $top_pane;
+ clear: both;
+ overflow: hidden;
+ z-index: 1;
+ li {
+ display: inline-block;
+ margin: 0 -3px;
+ vertical-align: top;
+ input[type=checkbox] {
+ @include check-box;
+ margin: 7px 8px;
+ }
+ select {
+ padding: 1px 3px;
+ margin: 0;
+ }
+ input[type=button] {
+ margin: 2px;
+ padding: 4px 10px;
+ background: lighten($contrast, 5%);
+ color: #333;
+ text-transform: uppercase;
+ font-weight: 400;
+ font-size: 0.8em;
+ opacity: 0.7;
+ border: 1px solid darken($contrast, 10%);
+ @include border-radius(1px);
+ @include btn-transition;
+ &:hover {
+ opacity: 1;
+ }
+ &[disabled=disabled] {
+ opacity: 0.4;
+ cursor: default;
+ }
+ }
+ }
+
+ #pagination-trigger {
+ cursor: pointer;
+ margin: 0 5px;
+ }
+ }
+}
+
+@mixin email-list {
+ ul#mail-list {
+ clear: both;
+ li {
+ height: 50px;
+ position: relative;
+ padding: 2px 5px;
+ background: lighten($contrast, 2%);
+ border-bottom: 1px solid white;
+ cursor: pointer;
+ font-weight: bold;
+ transition: background-color 150ms ease-out;
+ span {
+ display: inline-block;
+ vertical-align: top;
+ &:last-child {
+ width: 92%;
+ }
+ input[type=checkbox] {
+ @include check-box;
+ margin-right: 2px;
+ }
+ a {
+ color: #333;
+ }
+ }
+ .subject-and-tags {
+ display: inline-block;
+ width: 90%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ @include tags;
+ ul.tags {
+ display: inline-block;
+ li {
+ display: inline-block;
+ height: auto;
+ font-weight: 400;
+ border: none;
+ &.tag:hover {
+ text-decoration: none;
+ }
+ }
+ }
+ }
+
+ .received-date, .sent-date {
+ position: absolute;
+ right: 10px;
+ font-size: 0.7em;
+ }
+ .from {
+ white-space: nowrap;
+ font-size: 0.8em;
+ width: 80%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &.status-read {
+ background: $contrast;
+ a {
+ font-weight: normal;
+ color: #555;
+ }
+ }
+ &:hover {
+ background: darken($contrast, 5%);
+ }
+ &.selected {
+ background: #FFF;
+ a {
+ color: #333;
+ }
+ }
+ }
+ }
+}
+
+@mixin mail-count($bg_color) {
+ background: $bg_color;
+ color: #FFF;
+ padding: 2px 5px;
+ font-size: 0.7em;
+ margin-left: 5px;
+ font-weight: 700;
+ @include border-radius(100px);
+}
+
+article {
+ padding-left: 50px !important;
+}
+
+section {
+ display: inline-block;
+ vertical-align: top;
+ height: 100vh;
+ overflow-y: scroll;
+ &#top-pane {
+ height: auto;
+ overflow: hidden;
+ background: darken($top_pane, 10%);
+ border-top: 1px solid $top_pane;
+ @include list-actions;
+ #compose-search-trigger {
+ padding: 4px;
+ }
+ #actions {
+ ul {
+ margin: 0;
+ li {
+ display: inline-block;
+ margin-right: -5px;
+ a {
+ transition: background-color 150ms ease-out;
+ background: darken($top_pane, 10%);
+ color: #FFF;
+ font-size: 1.5em;
+ display: block;
+ padding: 14px 20px;
+ margin: 0 1px 0px;
+ opacity: 0.35;
+ &.selected {
+ background: $top_pane;
+ opacity: 1;
+ cursor: default;
+ }
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+ }
+ }
+ #search-trigger {
+ input {
+ margin: 0;
+ padding: 8px 30px;
+ color: #EEE;
+ background: lighten(#333, 10%);
+ border: none;
+ transition: background-color 150ms ease-out;
+ &:hover {
+ background: lighten(#333, 12%);
+ }
+ &:focus {
+ background: lighten(#333, 20%);
+ }
+ }
+ &:before {
+ font-family: "FontAwesome";
+ content: "\f002";
+ position: absolute;
+ padding: 0 10px;
+ top: 9px;
+ color: #999;
+ }
+ }
+ }
+
+ &#left-pane {
+ nav {
+ padding-bottom: 25px;
+ border-right: 1px solid $contrast;
+ ul#default-tag-list, #custom-tag-list {
+ li {
+ transition: background-color 150ms ease-out;
+ padding: 2px 10px;
+ cursor: pointer;
+ &:hover {
+ background: #CCC;
+ }
+ &.selected {
+ font-weight: bold;
+ background: $contrast;
+ }
+ }
+ }
+
+ ul#default-tag-list {
+ li {
+ padding: 5px 10px;
+ position: relative;
+ @include searching(4px, 19px, #333, 0.7em);
+
+ &:before {
+ font-family: "FontAwesome";
+ margin-right: 10px;
+ font-weight: normal;
+ }
+ &:nth-child(1) {
+ &:before {
+ content: "\f01c";
+ }
+ }
+ &:nth-child(2) {
+ &:before {
+ content: "\f1d8";
+ }
+ }
+ &:nth-child(3) {
+ &:before {
+ content: "\f040";
+ }
+ }
+ &:nth-child(4) {
+ &:before {
+ content: "\f014";
+ }
+ }
+ &:nth-child(5) {
+ &:before {
+ content: "\f187";
+ }
+ }
+ }
+ }
+
+ ul#custom-tag-list {
+ padding-bottom: 30px;
+ li {
+ font-size: 0.8em;
+ padding: 5px 20px;
+ }
+ }
+
+ h3 {
+ text-transform: uppercase;
+ font-size: 0.6em;
+ padding: 5px;
+ font-weight: 600;
+ margin: 0 10px;
+ border-bottom: 1px dotted #DDD;
+ }
+ }
+ }
+
+ &#middle-pane {
+ background: lighten($contrast, 2%);
+ @include email-list;
+ }
+
+ &#right-pane {
+ padding: 0 10px 60px 0px;
+ background: #FFF;
+ top: -25px;
+ box-shadow: -2px -2px 5px rgba(0, 0, 0, 0.12);
+ z-index: 2;
+ @include read-msg;
+ [id^=fullView-] {
+ position: relative;
+ }
+ }
+}
+
+.unread-count {
+ @include mail-count($error);
+}
+.total-count {
+ @include mail-count($total_count_bg);
+}
+
+
+/* ACTIONS */
+#refresh-mails-trigger {
+ i {
+ cursor: pointer;
+ opacity: 0.9;
+ padding: 4px;
+ &:hover {
+ opacity: 1;
+ &:after {
+ content: "\f021";
+ }
+ &:before {
+ content: "refresh";
+ font-size: 0.8em;
+ padding-right: 5px;
+ }
+ }
+ }
+}
+
+
+
+.buttons-group {
+ clear: both;
+ margin: 20px 0 0;
+ padding: 0;
+}
+
+#draft-save-status {
+ float: right;
+ padding: 0.4rem 1.1rem;
+ color: #91C2D1;
+}
+
+button {
+ border: 1px solid transparent;
+ i {
+ margin-left: 5px;
+ }
+ &#trash-button, &#draft-button {
+ background: #FFF;
+ border: 1px solid #999;
+ color: #999;
+ float: right;
+ margin-left: 5px;
+ &:hover, &:focus {
+ background: #EEE;
+ }
+ }
+ &.close-mail-button {
+ background: transparent;
+ color: #999;
+ float: right;
+ &:hover {
+ color: darken(#999, 10%);
+ }
+ }
+ &.close-mail-button {
+ position: absolute;
+ left: 0;
+ top: 0;
+ margin: 0;
+ padding: 3px 6px 5px;
+ background: #DDD;
+ opacity: 0.7;
+ @include border-radius(0);
+ &:hover {
+ opacity: 1;
+ }
+ i {
+ margin: 0;
+ }
+ }
+ &.no-style {
+ background: transparent;
+ color: #999;
+ padding: 0;
+ margin: 0;
+ i {
+ margin: 0;
+ padding: 0;
+ vertical-align: middle;
+ }
+ &:hover {
+ }
+ }
+}
+
+.collapsed-nav {
+ width: 50px;
+ position: absolute;
+ z-index: 2;
+ height: 100vh;
+ background: #FFF;
+ border-right: 1px solid darken($contrast, 20%);
+ .left-off-canvas-toggle {
+ text-align: center;
+ display: block;
+ left: 0;
+ padding: 10px;
+ background: #FFF;
+ top: 0;
+ z-index: 10000;
+ position: relative;
+ }
+ ul.shortcuts {
+ margin-top: 10px;
+ li {
+ position: relative;
+ margin-bottom: 5px;
+ opacity: 0.8;
+ &.selected {
+ background: $contrast;
+ color: #212121;
+ opacity: 1;
+ cursor: default;
+ }
+ @include searching(6px, 26px, #666, 0.9em);
+ a {
+ display: block;
+ position: relative;
+ font-size: 1.4em;
+ padding: 5px;
+ color: #555;
+ text-align: center;
+ &:hover {
+ background: darken($contrast, 10%);
+ color: #333;
+ @include btn-transition;
+ }
+ &[title]:hover:after {
+ content: attr(title);
+ @include tooltip;
+ }
+ }
+ .unread-count, .total-count {
+ font-size: 0.5em;
+ padding: 1px 5px 0;
+ top: 1px;
+ left: 0;
+ border: 1px solid #FFF;
+ position: absolute;
+ opacity: 0.88;
+ }
+ .total-count {
+ background: #999;
+ }
+ }
+ }
+ #custom-tags-shortcuts {
+ li {
+ border-top: 1px solid #DDD;
+ }
+ }
+ div.shortcut-label {
+ font-size: xx-small;
+ text-transform: uppercase;
+ text-align: center;
+ }
+}
+.move-right {
+ ul.shortcuts {
+ li {
+ display: none;
+ }
+ }
+}
+
+.left-off-canvas-menu {
+ width: 222px;
+ -webkit-backface-visibility: hidden;
+ box-sizing: content-box;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ position: absolute;
+ overflow-y: auto;
+ z-index: 1001;
+ transition: transform 500ms ease 0s;
+ -webkit-overflow-scrolling: touch;
+ -ms-transform: translate(-100%, 0);
+ -webkit-transform: translate3d(-100%, 0, 0);
+ -moz-transform: translate3d(-100%, 0, 0);
+ -ms-transform: translate3d(-100%, 0, 0);
+ -o-transform: translate3d(-100%, 0, 0);
+ transform: translate3d(-100%, 0, 0);
+}
+.left-off-canvas-menu * {
+-webkit-backface-visibility: hidden; }
+
+
+.off-canvas-wrap {
+ -webkit-backface-visibility: hidden;
+ position: relative;
+ width: 100%;
+overflow: hidden; }
+.off-canvas-wrap.move-right, .off-canvas-wrap.move-left {
+ min-height: 100%;
+-webkit-overflow-scrolling: touch; }
+
+.inner-wrap {
+ -webkit-backface-visibility: hidden;
+ position: relative;
+ width: 100%;
+ -webkit-transition: -webkit-transform 500ms ease;
+ -moz-transition: -moz-transform 500ms ease;
+ -ms-transition: -ms-transform 500ms ease;
+ -o-transition: -o-transform 500ms ease;
+transition: transform 500ms ease; }
+.inner-wrap:before, .inner-wrap:after {
+ content: " ";
+display: table; }
+.inner-wrap:after {
+clear: both; }
+
+.move-right > .inner-wrap {
+ -ms-transform: translate(13.88889rem, 0);
+ -webkit-transform: translate3d(13.88889rem, 0, 0);
+ -moz-transform: translate3d(13.88889rem, 0, 0);
+ -ms-transform: translate3d(13.88889rem, 0, 0);
+ -o-transform: translate3d(13.88889rem, 0, 0);
+transform: translate3d(13.88889rem, 0, 0); }
+.move-right .exit-off-canvas {
+ -webkit-backface-visibility: hidden;
+ transition: background 300ms ease;
+ cursor: pointer;
+ display: block;
+ position: absolute;
+ background: rgba(255, 255, 255, 0.2);
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 1002;
+-webkit-tap-highlight-color: rgba(0, 0, 0, 0); }
+@media only screen and (min-width:40.063em) {
+ .move-right .exit-off-canvas:hover {
+background: rgba(255, 255, 255, 0.05); } }
+
+.offcanvas-overlap .left-off-canvas-menu, .offcanvas-overlap .right-off-canvas-menu {
+ -ms-transform: none;
+ -webkit-transform: none;
+ -moz-transform: none;
+ -o-transform: none;
+ transform: none;
+z-index: 1003; }
+.offcanvas-overlap .exit-offcanvas-menu {
+ -webkit-backface-visibility: hidden;
+ transition: background 300ms ease;
+ cursor: pointer;
+ display: block;
+ position: absolute;
+ background: rgba(255, 255, 255, 0.2);
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 1002;
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+z-index: 1002; }
+
+
+#delete-modal {
+ button#trash-button, button#archive-button {
+ width: 40%;
+ margin: 0 22px 30px;
+ height: 80px;
+ }
+ small {
+ font-size: 80%;
+ display: block;
+ }
+}
+
+@import "mascot.scss";
diff --git a/web-ui/app/templates/compose/compose_box.hbs b/web-ui/app/templates/compose/compose_box.hbs
new file mode 100644
index 00000000..42efb30b
--- /dev/null
+++ b/web-ui/app/templates/compose/compose_box.hbs
@@ -0,0 +1,23 @@
+<button class="close-mail-button">
+ <i class="fa fa-times"></i>
+</button>
+<div class="tip-msg">
+ <i class="fa fa-lightbulb-o"></i>{{t "Don't worry about recipients right now, you'll be able to add them just before sending." }}
+</div>
+<input type="text" id="subject" value="{{subject}}" placeholder="{{t 'Subject'}}" tabindex="1"/>
+<textarea id="text-box" placeholder="{{t 'Body'}}" tabindex="2">{{body}}</textarea>
+
+{{> recipients }}
+
+<div class="clearfix">
+ <a id="to-trigger" class="hide">{{t 'To'}}</a>
+ <a id="ccs-trigger" class="hide">{{t 'CC'}}</a>
+ <a id="bccs-trigger" class="hide">{{t 'BCC'}}</a>
+</div>
+
+<div class="buttons-group columns">
+ <button id="send-button" tabindex="6">{{t 'send-button'}}<i class="fa fa-send"></i></button>
+ <button id="trash-button" tabindex="7">{{t 'trash-button'}}<i class="fa fa-trash-o"></i></button>
+ <button id="draft-button">{{t 'draft-button'}}<i class="fa fa-pencil"></i></button>
+ <div id="draft-save-status"></div>
+</div>
diff --git a/web-ui/app/templates/compose/fixed_recipient.hbs b/web-ui/app/templates/compose/fixed_recipient.hbs
new file mode 100644
index 00000000..2f773c76
--- /dev/null
+++ b/web-ui/app/templates/compose/fixed_recipient.hbs
@@ -0,0 +1,6 @@
+<div class="fixed-recipient">
+ <span class="recipient-area">
+ <div class="recipient-value">{{ address }}</div>
+ </span>
+ <input type="hidden" value="{{ address }}" name="{{ name }}" />
+</div>
diff --git a/web-ui/app/templates/compose/inline_box.hbs b/web-ui/app/templates/compose/inline_box.hbs
new file mode 100644
index 00000000..eb339c21
--- /dev/null
+++ b/web-ui/app/templates/compose/inline_box.hbs
@@ -0,0 +1,18 @@
+<div id="subject-container">
+ <h4 id="reply-subject">{{subject}}</h4>
+ <input type="text" value="{{subject}}" style="display: none"/>
+</div>
+<textarea id="text-box" placeholder="{{t 'Body'}}" tabindex=1>{{body}}</textarea>
+
+<a id="all-recipients" tabindex=2>
+ <strong>{{t 'To'}}:</strong> {{formatRecipients recipients}}
+</a>
+
+{{> recipients }}
+
+<div class="buttons-group columns">
+ <button id="send-button" tabindex=6>{{t 'send-button'}}<i class="fa fa-send"></i></button>
+ <button id="trash-button" tabindex=7>{{t 'trash-button'}}<i class="fa fa-trash-o"></i></button>
+ <button id="draft-button">{{t 'draft-button'}}<i class="fa fa-pencil"></i></button>
+ <div id="draft-save-status"></div>
+</div>
diff --git a/web-ui/app/templates/compose/recipient_input.hbs b/web-ui/app/templates/compose/recipient_input.hbs
new file mode 100644
index 00000000..9416f11f
--- /dev/null
+++ b/web-ui/app/templates/compose/recipient_input.hbs
@@ -0,0 +1 @@
+<input type="text" />
diff --git a/web-ui/app/templates/compose/recipients.hbs b/web-ui/app/templates/compose/recipients.hbs
new file mode 100644
index 00000000..6ec29ae5
--- /dev/null
+++ b/web-ui/app/templates/compose/recipients.hbs
@@ -0,0 +1,19 @@
+<div id="recipients-fields" style="display:none">
+ <div id='recipients-to-area' class="recipients-area input-container columns large-12 no-padding">
+ <input class="recipients-navigation-handler"/>
+ <label class="column large-1">{{t 'TO'}}: </label>
+ <input type="text" tabindex="3"/>
+ </div>
+
+ <div id="recipients-cc-area" class="recipients-area input-container columns large-12 no-padding">
+ <input class="recipients-navigation-handler"/>
+ <label class="column large-1">{{t 'CC'}}: </label>
+ <input type="text" tabindex="4"/>
+ </div>
+
+ <div id="recipients-bcc-area" class="recipients-area input-container columns large-12 no-padding">
+ <input class="recipients-navigation-handler"/>
+ <label class="column large-1">{{t 'BCC'}}: </label>
+ <input type="text" tabindex="5"/>
+ </div>
+</div> \ No newline at end of file
diff --git a/web-ui/app/templates/compose/reply_section.hbs b/web-ui/app/templates/compose/reply_section.hbs
new file mode 100644
index 00000000..9e833ffe
--- /dev/null
+++ b/web-ui/app/templates/compose/reply_section.hbs
@@ -0,0 +1,6 @@
+<div class="reply-container columns small-12 large-12">
+ <button id="reply-button" class="column small-12 large-4">{{t 'Reply'}} <i class="fa fa-reply"></i></button>
+ <button id="reply-all-button" class="column small-12 large-4">{{t 'Reply to All'}} <i class="fa fa-reply-all"></i></button>
+ <button id="forward-button" class="column small-12 large-4">{{t 'Forward'}} <i class="fa fa-mail-forward"></i></button>
+ <div id="reply-box" style="display:none"></div>
+</div>
diff --git a/web-ui/app/templates/mail_actions/actions_box.hbs b/web-ui/app/templates/mail_actions/actions_box.hbs
new file mode 100644
index 00000000..b6dc2f53
--- /dev/null
+++ b/web-ui/app/templates/mail_actions/actions_box.hbs
@@ -0,0 +1,6 @@
+<li><input type="checkbox" id="toggle-check-all-emails"/></li>
+<li><input type="button" id="mark-selected-as-read" value="{{t 'Mark as read'}}" disabled="disabled"/></li>
+<li><input type="button" id="mark-selected-as-unread" value="{{t 'Mark as unread'}}" disabled="disabled"/></li>
+<li><input type="button" id="delete-selected" value="{{t 'Delete'}}" disabled="disabled"/></li>
+<li id="pagination-trigger" class="right"></li>
+<li id="refresh-trigger" class="right"></li>
diff --git a/web-ui/app/templates/mail_actions/compose_trigger.hbs b/web-ui/app/templates/mail_actions/compose_trigger.hbs
new file mode 100644
index 00000000..ccdb4df0
--- /dev/null
+++ b/web-ui/app/templates/mail_actions/compose_trigger.hbs
@@ -0,0 +1,3 @@
+<div id="compose-mails-trigger">
+ {{t 'compose' }}
+</div>
diff --git a/web-ui/app/templates/mail_actions/pagination_trigger.hbs b/web-ui/app/templates/mail_actions/pagination_trigger.hbs
new file mode 100644
index 00000000..cbd8a089
--- /dev/null
+++ b/web-ui/app/templates/mail_actions/pagination_trigger.hbs
@@ -0,0 +1,3 @@
+<span id="left-arrow"><i class="fa fa-angle-left"></i></span>
+<span id="current-page">{{ currentPage }}</span>
+<span id="right-arrow"><i class="fa fa-angle-right"></i></span>
diff --git a/web-ui/app/templates/mail_actions/refresh_trigger.hbs b/web-ui/app/templates/mail_actions/refresh_trigger.hbs
new file mode 100644
index 00000000..68685442
--- /dev/null
+++ b/web-ui/app/templates/mail_actions/refresh_trigger.hbs
@@ -0,0 +1,3 @@
+<div id="refresh-mails-trigger">
+ <i class="fa fa-refresh"></i>
+</div>
diff --git a/web-ui/app/templates/mails/full_view.hbs b/web-ui/app/templates/mails/full_view.hbs
new file mode 100644
index 00000000..a466308d
--- /dev/null
+++ b/web-ui/app/templates/mails/full_view.hbs
@@ -0,0 +1,87 @@
+
+<div id="fullView-{{ ident }}" class="{{statuses}}">
+
+ <header class="msg-header row">
+
+ <button class="close-mail-button">
+ <i class="fa fa-times"></i>
+ </button>
+
+
+ <div style="display:inline-block;padding-top: 5px;width:95%;flex-shrink:1" >
+
+ <div class="column large-10 no-padding security-status">
+ <span class="{{signatureStatus}}">
+ {{t signatureStatus }}
+ </span>
+ <span class="{{encryptionStatus}}">
+ {{t encryptionStatus }}
+ </span>
+ </div>
+ <div class="column large-2 no-padding text-right">
+ <span class="received-date">{{ header.formattedDate }}</span>
+ </div>
+ <div class="recipients column large-12 no-padding">
+ <span class="from">
+ {{#if header.from }}
+ {{ header.from }}
+ {{else}}
+ {{t 'you'}}
+ {{/if}}
+ </span>
+ <i class="fa fa-long-arrow-right"></i>
+ {{{formatRecipients header}}}
+ </div>
+
+ <div>
+ <h3 class="subjectArea column large-10 no-padding">
+ <span class="subject">{{ header.subject }}</span>
+
+ <div class="tagsArea">
+ <ul class="tags">
+ {{#each tags }}
+ <li class="tag" data-tag="{{this}}">{{ this }}</li>
+ {{/each }}
+
+ <li class="new-tag">
+ <input type="text" id="new-tag-input" placeholder="{{t 'Press Enter to create'}}"/>
+ </li>
+ <li class="add-new">
+ <button id="new-tag-button" class="no-style"><i class="fa fa-plus"></i></button>
+ </li>
+ </ul>
+ </div>
+ </h3>
+ <nav id="mail-actions" class="column large-2 no-padding">
+ </nav>
+ </div>
+
+ </div>
+ </header>
+
+ <div id="delete-modal" class="reveal-modal" data-reveal>
+ <p class="lead">{{t 'You are trying to delete the last tag on this message.'}}</p>
+
+ <p>{{t 'What would you like to do?'}}</p>
+ <button id="trash-button">{{t 'Trash message'}}</button>
+ <button id="archive-button">{{t 'Archive it'}}</button>
+ <span class="close-reveal-modal">&#215;</span>
+ <small><strong>{{t 'Trash:'}}</strong> {{t 'we will keep this message for 30 days, then delete it forever.'}}
+ </small>
+ <small>
+ <strong>{{t 'Archive:'}}</strong> {{t 'we will remove all the tags, but keep it in your account in case you need it.'}}
+ </small>
+ </div>
+
+ <div class="bodyArea column large-12">
+ {{#each body }}
+ <p>{{ this }}</p>
+ {{/each }}
+ </div>
+</div>
+<script>
+ (function () {
+ var height = $(".msg-header")[0].offsetHeight;
+ $(".bodyArea")[0].style.marginTop = height + 'px';
+ }());
+</script>
diff --git a/web-ui/app/templates/mails/mail_actions.hbs b/web-ui/app/templates/mails/mail_actions.hbs
new file mode 100644
index 00000000..8933db79
--- /dev/null
+++ b/web-ui/app/templates/mails/mail_actions.hbs
@@ -0,0 +1,6 @@
+<button id="reply-button-top" class="no-style"><i class="fa fa-reply"></i></button>
+<button id="view-more-actions" class="no-style"><i class="fa fa-caret-down"></i></button>
+<ul id="more-actions">
+ <li><span id="reply-all-button-top">{{t 'Reply to All'}}</span></li>
+ <li><span id="delete-button-top">{{t 'Trash this message'}}</span></li>
+</ul>
diff --git a/web-ui/app/templates/mails/sent.hbs b/web-ui/app/templates/mails/sent.hbs
new file mode 100644
index 00000000..826a66d5
--- /dev/null
+++ b/web-ui/app/templates/mails/sent.hbs
@@ -0,0 +1,23 @@
+<span>
+ <input type="checkbox"/>
+</span>
+<span>
+ <a href="/#/{{ tag }}/mail/{{ ident }}">
+ <span class="sent-date">{{ header.formattedDate }}</span>
+
+ <div class="from">{{t 'to:'}} {{#if header.to }}{{
+ header.to }}{{else}}{{t 'no_recipient'}}{{/if}}</div>
+ <div class="subject-and-tags">
+ <ul class="tags">
+ {{#each tagsForListView }}
+ <li class="tag" data-tag="{{this}}">{{ this }}</li>
+ {{/each }}
+ </ul>
+ {{#if header.subject }}
+ {{header.subject}}
+ {{else}}
+ {{t 'no_subject'}}
+ {{/if}}
+ </div>
+ </a>
+</span>
diff --git a/web-ui/app/templates/mails/single.hbs b/web-ui/app/templates/mails/single.hbs
new file mode 100644
index 00000000..9a054c79
--- /dev/null
+++ b/web-ui/app/templates/mails/single.hbs
@@ -0,0 +1,19 @@
+<span>
+ <input type="checkbox" {{#if isChecked }}checked="true"{{/if}}/>
+</span>
+<span>
+<a href="/#/{{ tag }}/mail/{{ ident }}">
+ <span class="received-date">{{ header.formattedDate }}</span>
+
+ <div class="from">{{#if header.from }}{{ header.from }}{{else}}{{t "you"}}{{/if}}</div>
+ <div class="subject-and-tags">
+ <ul class="tags">
+ {{#each tagsForListView }}
+ <li class="tag" data-tag="{{this}}">{{ this }}</li>
+ {{/each }}
+ </ul>
+
+ {{ header.subject }}
+ </div>
+</a>
+</span>
diff --git a/web-ui/app/templates/no_message_selected.hbs b/web-ui/app/templates/no_message_selected.hbs
new file mode 100644
index 00000000..0442192d
--- /dev/null
+++ b/web-ui/app/templates/no_message_selected.hbs
@@ -0,0 +1,3 @@
+<div class="scene">
+ <div class="text">{{t 'NOTHING SELECTED'}}.</div>
+</div>
diff --git a/web-ui/app/templates/search/search_trigger.hbs b/web-ui/app/templates/search/search_trigger.hbs
new file mode 100644
index 00000000..fbf24170
--- /dev/null
+++ b/web-ui/app/templates/search/search_trigger.hbs
@@ -0,0 +1,3 @@
+<form>
+ <input type="search" placeholder="{{t 'Search...'}}"></input>
+</form>
diff --git a/web-ui/app/templates/tags/shortcut.hbs b/web-ui/app/templates/tags/shortcut.hbs
new file mode 100644
index 00000000..49ddfdb2
--- /dev/null
+++ b/web-ui/app/templates/tags/shortcut.hbs
@@ -0,0 +1,9 @@
+<li>
+ <a title="{{ tagName }}">
+ {{#if displayBadge }}
+ <span class="{{ badgeType }}-count">{{ count }}</span>
+ {{/if}}
+ <i class="fa fa-{{ icon }}"></i>
+ <div class="shortcut-label">{{ tagName }}</div>
+ </a>
+</li> \ No newline at end of file
diff --git a/web-ui/app/templates/tags/tag.hbs b/web-ui/app/templates/tags/tag.hbs
new file mode 100644
index 00000000..c645f782
--- /dev/null
+++ b/web-ui/app/templates/tags/tag.hbs
@@ -0,0 +1,3 @@
+<li id="tag-{{ ident }}">
+ {{> tag_inner }}
+</li>
diff --git a/web-ui/app/templates/tags/tag_inner.hbs b/web-ui/app/templates/tags/tag_inner.hbs
new file mode 100644
index 00000000..2e0958cb
--- /dev/null
+++ b/web-ui/app/templates/tags/tag_inner.hbs
@@ -0,0 +1,4 @@
+{{ tagName }}
+{{#if displayBadge }}
+<span class="{{ badgeType }}-count">{{ count }}</span>
+{{/if}}
diff --git a/web-ui/app/templates/tags/tag_list.hbs b/web-ui/app/templates/tags/tag_list.hbs
new file mode 100644
index 00000000..e2e97833
--- /dev/null
+++ b/web-ui/app/templates/tags/tag_list.hbs
@@ -0,0 +1,3 @@
+<ul id="default-tag-list"></ul>
+<h3>{{t 'Tags'}}</h3>
+<ul id="custom-tag-list"></ul>
diff --git a/web-ui/app/templates/user_alerts/message.hbs b/web-ui/app/templates/user_alerts/message.hbs
new file mode 100644
index 00000000..d2fff04a
--- /dev/null
+++ b/web-ui/app/templates/user_alerts/message.hbs
@@ -0,0 +1 @@
+<span>{{ message }}</span>
diff --git a/web-ui/bower.json b/web-ui/bower.json
new file mode 100644
index 00000000..f8eebac7
--- /dev/null
+++ b/web-ui/bower.json
@@ -0,0 +1,21 @@
+{
+ "name": "flightjs",
+ "version": "0.0.0",
+ "dependencies": {
+ "jquery": "2.x.x",
+ "flight": "~1.1.0",
+ "requirejs": "~2.1.5",
+ "lodash": "~2.4.1",
+ "foundation": "5.3.0",
+ "i18next": "~1.7.3",
+ "font-awesome": "~4.1.0",
+ "quoted-printable": "0.2.1"
+ },
+ "devDependencies": {
+ "jasmine-flight": "~2.2.0",
+ "jasmine-jquery": "~1.1.3",
+ "handlebars": "~1.3.0",
+ "typeahead.js": "~0.10.2",
+ "almond": "~0.2.9"
+ }
+}
diff --git a/web-ui/control-tower.yml b/web-ui/control-tower.yml
new file mode 100644
index 00000000..874e1b64
--- /dev/null
+++ b/web-ui/control-tower.yml
@@ -0,0 +1,3 @@
+---
+include_pattern: 'app/js/**/*.js'
+exclude_pattern: ''
diff --git a/web-ui/go b/web-ui/go
new file mode 100755
index 00000000..ad575723
--- /dev/null
+++ b/web-ui/go
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+node_modules/grunt-cli/bin/grunt $GRUNT_OPTS $*
diff --git a/web-ui/karma.conf.js b/web-ui/karma.conf.js
new file mode 100644
index 00000000..6a4463fe
--- /dev/null
+++ b/web-ui/karma.conf.js
@@ -0,0 +1,85 @@
+// Karma configuration
+//
+// For all available config options and default values, see:
+// http://karma-runner.github.io/0.10/config/configuration-file.html
+
+module.exports = function (config) {
+ 'use strict';
+
+ config.set({
+
+ // base path, that will be used to resolve files and exclude
+ basePath: '',
+
+ // frameworks to use
+ frameworks: ['jasmine'],
+
+ // list of files / patterns to load in the browser
+ files: [
+ // loaded without require
+ 'app/bower_components/es5-shim/es5-shim.js',
+ 'app/bower_components/es5-shim/es5-sham.js',
+ 'app/bower_components/lodash/dist/lodash.js',
+ 'app/bower_components/jquery/dist/jquery.js',
+ 'app/bower_components/jasmine-jquery/lib/jasmine-jquery.js',
+ 'app/bower_components/jasmine-flight/lib/jasmine-flight.js',
+ 'app/bower_components/jasmine-jquery/lib/jasmine-jquery.js',
+ 'app/bower_components/handlebars/handlebars.min.js',
+ 'app//bower_components/modernizr/modernizr.js',
+ 'app/bower_components/foundation/js/foundation.js',
+ 'app/bower_components/foundation/js/foundation/foundation.reveal.js',
+ 'app/bower_components/foundation/js/foundation/foundation.offcanvas.js',
+
+ // hack to load RequireJS after the shim libs
+ 'node_modules/requirejs/require.js',
+ 'node_modules/karma-requirejs/lib/adapter.js',
+
+ // loaded with require
+ {pattern: 'app/bower_components/flight/**/*.js', included: false},
+ {pattern: 'app/bower_components/i18next/**/*.js', included: false},
+ {pattern: 'app/bower_components/quoted-printable/*.js', included: false},
+ {pattern: 'app/locales/**/*.json', included: false},
+ {pattern: 'app/js/**/*.js', included: false},
+ {pattern: 'test/test_data.js', included: false},
+ {pattern: 'test/spec/**/*.spec.js', included: false},
+
+ 'test/test-main.js'
+ ],
+
+ // list of files to exclude
+ exclude: [
+ 'app/js/main.js'
+ ],
+
+ // test results reporter to use
+ // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage'
+ reporters: ['progress'],
+
+ // enable / disable watching file and executing tests whenever any file changes
+ autoWatch: true,
+
+ // Start these browsers, currently available:
+ // - Chrome
+ // - ChromeCanary
+ // - Firefox
+ // - Opera
+ // - Safari (only Mac)
+ // - PhantomJS
+ // - IE (only Windows)
+ browsers: [
+ 'PhantomJS'
+ ],
+
+ // If browser does not capture in given timeout [ms], kill it
+ captureTimeout: 5000,
+
+ // Continuous Integration mode
+ // if true, it capture browsers, run tests and exit
+ singleRun: false,
+
+ // Karma will report all the tests that are slower than given time limit (in
+ // ms).
+ reportSlowerThan: 500,
+
+ });
+};
diff --git a/web-ui/package.json b/web-ui/package.json
new file mode 100644
index 00000000..a57ef1ca
--- /dev/null
+++ b/web-ui/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "smail-front",
+ "version": "0.0.0",
+ "devDependencies": {
+ "bower": "^1.3.5",
+ "connect-livereload": "^0.4.0",
+ "express": "^4.3.2",
+ "generator-flight": "~0.7.3",
+ "grunt": "^0.4.5",
+ "grunt-autoprefixer": "~0.4.0",
+ "grunt-build-control": "^0.1.3",
+ "grunt-cli": "^0.1.13",
+ "grunt-concurrent": "~0.4.1",
+ "grunt-contrib-clean": "~0.5.0",
+ "grunt-contrib-coffee": "~0.7.0",
+ "grunt-contrib-compass": "~0.6.0",
+ "grunt-contrib-compress": "^0.9.1",
+ "grunt-contrib-concat": "~0.3.0",
+ "grunt-contrib-connect": "~0.5.0",
+ "grunt-contrib-copy": "~0.4.1",
+ "grunt-contrib-cssmin": "~0.7.0",
+ "grunt-contrib-handlebars": "^0.8.0",
+ "grunt-contrib-htmlmin": "~0.1.3",
+ "grunt-contrib-imagemin": "~0.3.0",
+ "grunt-contrib-jshint": "~0.7.1",
+ "grunt-contrib-requirejs": "~0.4.4",
+ "grunt-contrib-uglify": "~0.2.0",
+ "grunt-contrib-watch": "~0.5.2",
+ "grunt-google-cdn": "~0.2.0",
+ "grunt-karma": "^0.8.3",
+ "grunt-newer": "~0.5.4",
+ "grunt-ngmin": "~0.0.2",
+ "grunt-regex-replace": "^0.2.6",
+ "grunt-rev": "~0.1.0",
+ "grunt-svgmin": "~0.2.0",
+ "grunt-usemin": "^2.3.0",
+ "handlebars": "^2.0.0-alpha.4",
+ "jshint-stylish": "^0.2.0",
+ "karma": "~0.12.0",
+ "karma-chrome-launcher": "~0.1.0",
+ "karma-firefox-launcher": "~0.1.0",
+ "karma-ie-launcher": "~0.1.1",
+ "karma-jasmine": "~0.1.0",
+ "karma-junit-reporter": "^0.2.2",
+ "karma-phantomjs-launcher": "~0.1.0",
+ "karma-requirejs": "~0.2.1",
+ "karma-safari-launcher": "~0.1.1",
+ "load-grunt-tasks": "~0.2.0",
+ "node-static": "~0.7.3",
+ "requirejs": "~2.1.11",
+ "time-grunt": "~0.2.1",
+ "tiny-lr": "0.0.5"
+ },
+ "scripts": {
+ "test": "karma start --single-run --browsers PhantomJS",
+ "watch-test": "karma start"
+ }
+}
diff --git a/web-ui/test/spec/dispatchers/left_pane_dispatcher.spec.js b/web-ui/test/spec/dispatchers/left_pane_dispatcher.spec.js
new file mode 100644
index 00000000..fb5b169a
--- /dev/null
+++ b/web-ui/test/spec/dispatchers/left_pane_dispatcher.spec.js
@@ -0,0 +1,79 @@
+/*global Smail */
+
+describeComponent('dispatchers/left_pane_dispatcher', function () {
+ 'use strict';
+
+ describe('initialize', function () {
+ it('asks for tags', function () {
+ var tagWantEvent = spyOnEvent(document, Smail.events.tags.want);
+
+ setupComponent();
+
+ expect(tagWantEvent).toHaveBeenTriggeredOn(document);
+ expect(tagWantEvent.mostRecentCall.data.caller[0]).toEqual(this.$node[0]);
+ });
+ });
+
+ describe('after initialization', function () {
+ beforeEach(function () {
+ setupComponent();
+ });
+
+ it('pushes the url state when a tag is selected but not for the first tag', function () {
+ var pushStateEvent = spyOnEvent(document, Smail.events.router.pushState);
+
+ $(document).trigger(Smail.events.ui.tag.selected, { tag: 'Drafts'});
+ $(document).trigger(Smail.events.ui.tag.selected, { tag: 'inbox'});
+
+ expect(pushStateEvent).toHaveBeenTriggeredOn(document, { tag: 'inbox'});
+ });
+
+ it('fetches mails by tag when a tag is selected', function () {
+ var fetchByTagEvent = spyOnEvent(document, Smail.events.ui.mails.fetchByTag);
+
+ $(document).trigger(Smail.events.ui.tag.selected, { tag: 'Drafts'});
+
+ expect(fetchByTagEvent).toHaveBeenTriggeredOn(document, { tag: 'Drafts'});
+ });
+
+ it('doesnt fetch mails by tag when skipMailListRefresh is sent on tag.selected', function () {
+ var fetchByTagEvent = spyOnEvent(document, Smail.events.ui.mails.fetchByTag);
+
+ $(document).trigger(Smail.events.ui.tag.selected, { tag: 'Drafts', skipMailListRefresh: true});
+
+ expect(fetchByTagEvent).not.toHaveBeenTriggeredOn(document, { tag: 'Drafts'});
+ });
+
+ it('asks for more tags when refreshTagList is fired', function () {
+ var tagWantEvent = spyOnEvent(document, Smail.events.tags.want);
+
+ $(document).trigger(Smail.events.dispatchers.tags.refreshTagList);
+
+ expect(tagWantEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('fires tagLoad when the tags are received', function () {
+ var tagListLoadEvent = spyOnEvent(document, Smail.events.ui.tagList.load);
+
+ this.$node.trigger(Smail.events.tags.received, { tags: ['tags']});
+
+ expect(tagListLoadEvent).toHaveBeenTriggeredOn(document, { tags: ['tags']});
+ });
+
+ it('on tags loaded selects the inbox tag if no data is provided', function () {
+ var selectTagEvent = spyOnEvent(document, Smail.events.ui.tag.select);
+
+ $(document).trigger(Smail.events.ui.tags.loaded);
+
+ expect(selectTagEvent).toHaveBeenTriggeredOnAndWith(document, { tag: 'inbox' });
+ });
+
+ it('on tags loaded selects the a different tag if tag is provided', function () {
+ var selectTagEvent = spyOnEvent(document, Smail.events.ui.tag.select);
+
+ $(document).trigger(Smail.events.ui.tags.loaded, { tag: 'Drafts' });
+
+ expect(selectTagEvent).toHaveBeenTriggeredOnAndWith(document, { tag: 'Drafts' });
+ });
+ });
+});
diff --git a/web-ui/test/spec/dispatchers/middle_pane_dispatchers.spec.js b/web-ui/test/spec/dispatchers/middle_pane_dispatchers.spec.js
new file mode 100644
index 00000000..2dd0de2e
--- /dev/null
+++ b/web-ui/test/spec/dispatchers/middle_pane_dispatchers.spec.js
@@ -0,0 +1,27 @@
+/*global Smail */
+
+describeComponent('dispatchers/middle_pane_dispatcher', function () {
+ 'use strict';
+
+ beforeEach(function() {
+ setupComponent('<div><div id="middle-pane" style="height: 200px; overflow-y: scroll;"><div style="height: 400px"></div></div></div>');
+ });
+
+ it ('listens to refresh mail list event', function() {
+ var mailsListRefreshEventSpy = spyOnEvent(document, Smail.events.ui.mails.fetchByTag);
+ this.component.trigger(document, Smail.events.dispatchers.middlePane.refreshMailList);
+ expect(mailsListRefreshEventSpy).toHaveBeenTriggeredOn(document);
+ });
+
+ it ('listens to unselect event', function() {
+ var mailListUnselectEvent = spyOnEvent(document, Smail.events.ui.mails.cleanSelected);
+ this.component.trigger(document, Smail.events.dispatchers.middlePane.cleanSelected);
+ expect(mailListUnselectEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('resets the scrollTop value when asked to', function() {
+ this.component.select('middlePane').scrollTop(200);
+ this.component.trigger(document, Smail.events.dispatchers.middlePane.resetScroll);
+ expect(this.component.select('middlePane').scrollTop()).toEqual(0);
+ });
+});
diff --git a/web-ui/test/spec/dispatchers/right_pane_dispatcher.spec.js b/web-ui/test/spec/dispatchers/right_pane_dispatcher.spec.js
new file mode 100644
index 00000000..5fc7ecd7
--- /dev/null
+++ b/web-ui/test/spec/dispatchers/right_pane_dispatcher.spec.js
@@ -0,0 +1,90 @@
+/*global Smail */
+
+describeComponent('dispatchers/right_pane_dispatcher', function () {
+ 'use strict';
+
+ describe('after initialization', function () {
+ beforeEach(function () {
+ setupComponent();
+ });
+
+ it('listens to open compose box event and creates a compose box', function () {
+ var composeBox = require('mail_view/ui/compose_box');
+ spyOn(composeBox, 'attachTo');
+
+ this.component.trigger(document, Smail.events.dispatchers.rightPane.openComposeBox);
+
+ expect(composeBox.attachTo).toHaveBeenCalled();
+ });
+
+ describe('no message selected', function () {
+ var noMessageSelectedPane;
+ beforeEach(function () {
+ noMessageSelectedPane = require('mail_view/ui/no_message_selected_pane');
+ spyOn(noMessageSelectedPane, 'attachTo');
+ });
+
+ it('listen to open no message selected event and creates a no-message-selected-pane', function () {
+ this.component.trigger(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ expect(noMessageSelectedPane.attachTo).toHaveBeenCalled();
+ });
+
+ it('sends an dispatchers.middlePane.unselect event', function () {
+ var unselectEvent = spyOnEvent(document, Smail.events.dispatchers.middlePane.cleanSelected);
+ this.component.trigger(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ expect(unselectEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('pushes the current state with the current tag', function () {
+ var pushStateEvent = spyOnEvent(document, Smail.events.router.pushState);
+
+ this.component.attr.currentTag = 'sometag';
+ this.component.trigger(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ expect(pushStateEvent).toHaveBeenTriggeredOnAndWith(document, jasmine.objectContaining({tag: this.component.attr.currentTag }));
+ });
+
+ it('pushes the current state stating that it meant to close the right pane', function () {
+ var pushStateEvent = spyOnEvent(document, Smail.events.router.pushState);
+
+ this.component.attr.currentTag = 'sometag';
+ this.component.trigger(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ expect(pushStateEvent).toHaveBeenTriggeredOnAndWith(document, jasmine.objectContaining({ isDisplayNoMessageSelected: true }));
+ });
+
+
+ });
+
+ it('listens to open a draft and creates it', function () {
+ var draftBox = require('mail_view/ui/draft_box');
+ spyOn(draftBox, 'attachTo');
+
+ this.component.trigger(document, Smail.events.dispatchers.rightPane.openDraft, { ident: '1' });
+
+ expect(draftBox.attachTo).toHaveBeenCalled();
+ });
+ });
+
+ describe('on initialization', function () {
+ var noMessageSelectedPane;
+
+ beforeEach(function () {
+ noMessageSelectedPane = require('mail_view/ui/no_message_selected_pane');
+ spyOn(noMessageSelectedPane, 'attachTo');
+ });
+
+ it('opens the no message selected pane but doesnt push the state', function () {
+ var pushStateEvent = spyOnEvent(document, Smail.events.router.pushState);
+
+ setupComponent();
+
+ expect(noMessageSelectedPane.attachTo).toHaveBeenCalled();
+ expect(pushStateEvent).not.toHaveBeenTriggeredOn(document);
+
+ });
+ });
+
+});
diff --git a/web-ui/test/spec/helpers/view_helper.spec.js b/web-ui/test/spec/helpers/view_helper.spec.js
new file mode 100644
index 00000000..7fa10e56
--- /dev/null
+++ b/web-ui/test/spec/helpers/view_helper.spec.js
@@ -0,0 +1,76 @@
+/*global Smail */
+
+define(['helpers/view_helper'], function (viewHelper) {
+ 'use strict';
+
+ var testData;
+ describe('view helper', function() {
+ beforeEach(function () {
+ testData = Smail.testData();
+ });
+
+ describe('quote email', function() {
+ it('should add > to body text', function() {
+ testData.rawMail.mail.body = 'First Line\nSecond Line';
+
+ var quotedMail = viewHelper.quoteMail(testData.rawMail.mail);
+
+ expect(quotedMail).toContain('> First Line\n> Second Line');
+ });
+ });
+
+ describe('getFormmattedDate', function() {
+ it('formats correctly a Date for today', function() {
+ var d = new Date();
+ var dtest = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 14, 2, 36);
+
+ var res = viewHelper.getFormattedDate(dtest);
+
+ expect(res).toEqual('14:02');
+
+ });
+
+ it('formats correctly a Date for a specific day', function() {
+ var dtest = new Date(2013, 2, 13, 7, 56, 1);
+
+ var res = viewHelper.getFormattedDate(dtest);
+
+ // This expectation is weird for the month - JS Dates have date numbers be zero-indexed, thus the discrepency
+ // Specifically, the 2 in the constructor DOES match the 3 in the expectation below.
+ expect(res).toEqual('2013-03-13');
+ });
+ });
+
+ describe('format status classes', function () {
+ it('formats all the status of the email to css classes', function () {
+ var statuses = ['read', 'banana'];
+
+ expect(viewHelper.formatStatusClasses(statuses)).toEqual('status-read status-banana');
+ });
+
+ it('formats a single status of the email to a css class', function () {
+ var statuses = ['read'];
+
+ expect(viewHelper.formatStatusClasses(statuses)).toEqual('status-read');
+ });
+ });
+
+ it('formats the body of a multipart email', function () {
+ expect(viewHelper.formatMailBody(testData.parsedMail.html)).toContainHtml('<p>Hello everyone!</p>');
+ });
+
+ it('decodes a quoted-printable email body', function () {
+ var result = viewHelper.formatMailBody(testData.parsedMail.htmlQuotedPrintable);
+
+ expect(result).toContainHtml('<p style="border: 5px;">Hello everyone!</p>');
+ });
+
+ it('move caret to the end of text after 1ms', function () {
+ spyOn(window, 'setTimeout');
+
+ viewHelper.moveCaretToEndOfText();
+
+ expect(window.setTimeout.calls[0].args[1]).toEqual(1);
+ });
+ });
+});
diff --git a/web-ui/test/spec/mail_list/ui/mail_items/generic_mail_item.spec.js b/web-ui/test/spec/mail_list/ui/mail_items/generic_mail_item.spec.js
new file mode 100644
index 00000000..b49bc7f0
--- /dev/null
+++ b/web-ui/test/spec/mail_list/ui/mail_items/generic_mail_item.spec.js
@@ -0,0 +1,136 @@
+describeComponent('mail_list/ui/mail_items/generic_mail_item', function () {
+ 'use strict';
+
+ var mail;
+
+ beforeEach(function () {
+ mail = Smail.testData().parsedMail.simpleTextPlain;
+ mail.tags = ['inbox'];
+
+ setupComponent('<li></li>', {
+ mail: mail,
+ selected: false,
+ tag: 'inbox'
+ });
+ });
+
+ it('should trigger ui:openMail on click', function () {
+ var spyEvent = spyOnEvent(document, Smail.events.ui.mail.open);
+
+ this.component.$node.find('a').click();
+
+ expect(spyEvent).toHaveBeenTriggeredOn(document);
+ expect(spyEvent.mostRecentCall.data).toEqual({ ident: mail.ident });
+ });
+
+ it('should add selected class when selecting', function () {
+ this.$node.find('a').click();
+
+ expect(this.$node).toHaveClass('selected');
+ });
+
+ it('should remove selected class when selecting a different mail', function () {
+ $(document).trigger(Smail.events.ui.mail.updateSelected, { ident: 2 });
+
+ expect(this.$node).not.toHaveClass('selected');
+ });
+
+ it('should remove selected class when enabling compose box', function () {
+ this.$node.find('a').click();
+
+ $(document).trigger(Smail.events.ui.composeBox.newMessage);
+
+ expect(this.$node).not.toHaveClass('selected');
+ });
+
+ it('should have the href link with mail ident and tag name', function () {
+ expect(this.$node.find('a')[0].href).toMatch('inbox/mail/' + mail.ident);
+ });
+
+ describe('clicking on a mail', function () {
+
+ function createClickEvent(options) {
+ var clickEvent = $.Event('click');
+ _.merge(clickEvent, options);
+ spyOn(clickEvent, 'preventDefault');
+ return clickEvent;
+ }
+
+ it('triggers mail open and pushes the state', function () {
+ var clickEvent = createClickEvent();
+ var mailOpenEvent = spyOnEvent(document, Smail.events.ui.mail.open);
+ var pushStateEvent = spyOnEvent(document, Smail.events.router.pushState);
+
+ $(this.$node.find('a')).trigger(clickEvent);
+
+ expect(mailOpenEvent).toHaveBeenTriggeredOnAndWith(document, { ident: mail.ident });
+ expect(pushStateEvent).toHaveBeenTriggeredOnAndWith(document, { mailIdent: mail.ident });
+ expect(clickEvent.preventDefault).toHaveBeenCalled();
+ });
+
+ describe('when opening on a new tab', function () {
+
+ _.each([
+ {metaKey: true},
+ {which: 2},
+ {ctrlKey: true}
+ ], function (specialKey) {
+ it('doesnt trigger mail open and nor pushes the state', function () {
+ var clickEvent = createClickEvent(specialKey);
+ var mailOpenEvent = spyOnEvent(document, Smail.events.ui.mail.open);
+ var pushStateEvent = spyOnEvent(document, Smail.events.router.pushState);
+
+ $(this.$node.find('a')).trigger(clickEvent);
+
+ expect(mailOpenEvent).not.toHaveBeenTriggeredOnAndWith(document, { ident: mail.ident });
+ expect(pushStateEvent).not.toHaveBeenTriggeredOnAndWith(document, { mailIdent: mail.ident });
+ expect(clickEvent.preventDefault).not.toHaveBeenCalled();
+ });
+
+ it('marks the email as read', function () {
+ debugger;
+ var mailReadEvent = spyOnEvent(document, Smail.events.mail.read);
+ var clickEvent = createClickEvent(specialKey);
+
+ $(this.$node.find('a')).trigger(clickEvent);
+
+ expect(this.component.attr.mail.status).toContain(this.component.status.READ);
+ expect(this.$node.attr('class')).toMatch('status-read');
+ expect(mailReadEvent).toHaveBeenTriggeredOnAndWith(document, { ident: mail.ident, tags: ['inbox'] });
+ });
+
+ });
+
+ });
+
+ });
+
+ describe('marking emails as read', function () {
+ it('should trigger mail:read event when unread is clicked', function () {
+ var mailReadEvent = spyOnEvent(document, Smail.events.mail.read);
+
+ this.$node.find('a').click();
+
+ expect(mailReadEvent).toHaveBeenTriggeredOnAndWith(document, jasmine.objectContaining({ident: mail.ident}));
+ });
+
+ it('should not trigger mail:read event when clicking mail that is already read', function () {
+ var mailReadEvent = spyOnEvent(document, Smail.events.mail.read);
+ this.component.attr.mail.status.push(this.component.status.READ);
+
+ this.$node.find('a').click();
+
+ expect(mailReadEvent).not.toHaveBeenTriggeredOnAndWith(document, {ident: mail.ident});
+ });
+
+ it('should add status-read class to email when clicking an unread email', function () {
+ this.$node.find('a').click();
+
+ expect(this.$node).toHaveClass('status-read');
+ });
+
+ it('should not have status-read class when initializing email without read status', function () {
+ expect(this.$node).not.toHaveClass('status-read');
+ });
+ });
+});
diff --git a/web-ui/test/spec/mail_list/ui/mail_items/mail_item.spec.js b/web-ui/test/spec/mail_list/ui/mail_items/mail_item.spec.js
new file mode 100644
index 00000000..1b5899a1
--- /dev/null
+++ b/web-ui/test/spec/mail_list/ui/mail_items/mail_item.spec.js
@@ -0,0 +1,40 @@
+/*global Smail */
+
+describeMixin('mail_list/ui/mail_items/mail_item', function () {
+ 'use strict';
+
+ beforeEach(function () {
+ var mail = Smail.testData().parsedMail.simpleTextPlain;
+ mail.tags = ['inbox'];
+
+ setupComponent('<li><input type="checkbox"></input></li>', {
+ mail: mail,
+ selected: false,
+ tag: 'inbox'
+ });
+ });
+
+ describe('mail checkbox', function () {
+ var mailCheckedEvent, mailUncheckedEvent, checkbox;
+ beforeEach(function () {
+ mailCheckedEvent = spyOnEvent(document, Smail.events.ui.mail.checked);
+ mailUncheckedEvent = spyOnEvent(document, Smail.events.ui.mail.unchecked);
+ checkbox = this.component.$node.find('input[type=checkbox]');
+ });
+
+ it('checkCheckbox checks it and triggers events.ui.mail.checked', function () {
+ this.component.checkCheckbox();
+
+ expect(checkbox.prop('checked')).toBe(true);
+ expect(mailCheckedEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('uncheckCheckbox checks it and triggers events.ui.mail.checked', function () {
+ checkbox.prop('checked', true);
+ this.component.uncheckCheckbox();
+
+ expect(checkbox.prop('checked')).toBe(false);
+ expect(mailUncheckedEvent).toHaveBeenTriggeredOn(document);
+ });
+ });
+});
diff --git a/web-ui/test/spec/mail_list/ui/mail_list.spec.js b/web-ui/test/spec/mail_list/ui/mail_list.spec.js
new file mode 100644
index 00000000..f383d540
--- /dev/null
+++ b/web-ui/test/spec/mail_list/ui/mail_list.spec.js
@@ -0,0 +1,275 @@
+/*global Smail */
+
+describeComponent('mail_list/ui/mail_list', function () {
+ 'use strict';
+
+ var mailList;
+
+ beforeEach(function () {
+ setupComponent('<div id="mails"></div>', {
+ urlParams: {
+ hasMailIdent: function () {
+ return false;
+ }
+ }
+ });
+ mailList =
+ [
+ createMail('the mail subject', 'from@mail.com', '1', '2012-12-26T01:38:46-08:00'),
+ createMail('another mail subject', 'from_another@mail.com', '2', '2012-12-28T01:38:46-08:00')
+ ];
+ });
+
+
+ it('should open mail at first mail:available if there is a mailIdent in the url hash', function () {
+ this.component.attr.urlParams = {
+ hasMailIdent: function () {
+ return true;
+ },
+ getMailIdent: function () {
+ return '10';
+ }
+ };
+ var openMailEvent = spyOnEvent(document, Smail.events.ui.mail.open);
+
+ this.$node.trigger(Smail.events.mails.available, { mails: mailList });
+ expect(openMailEvent).toHaveBeenTriggeredOnAndWith(document, { ident: '10' });
+
+ this.$node.trigger(Smail.events.mails.available, { mails: mailList });
+ expect(openMailEvent.calls.length).toEqual(1);
+ });
+
+ it('should push the state if there is a mail ident in the hash url', function () {
+ this.component.attr.urlParams = {
+ hasMailIdent: function () {
+ return true;
+ },
+ getMailIdent: function () {
+ return '10';
+ }
+ };
+ var pushState = spyOnEvent(document, Smail.events.router.pushState);
+ this.component.attr.currentTag = 'inbox';
+
+ this.$node.trigger(Smail.events.mails.available, { mails: mailList });
+
+ expect(pushState).toHaveBeenTriggeredOnAndWith(document, { tag: 'inbox', mailIdent: '10' });
+ });
+
+ describe('checking/unchecking mails in the list', function () {
+
+ it('keeps a list with the currently checked mails', function () {
+ var checkedMails = {};
+
+ this.component.attr.checkedMails = {};
+
+ $(document).trigger(Smail.events.ui.mail.checked, {mail: mailList[0]});
+
+ checkedMails[mailList[0].ident] = mailList[0];
+
+ expect(this.component.attr.checkedMails).toEqual(checkedMails);
+ });
+
+ it('returns the list of checked mails to whomever requests them', function () {
+ var caller = {};
+ this.component.attr.checkedMails = {'1': {}};
+ var mailHereCheckedEvent = spyOnEvent(caller, Smail.events.ui.mail.hereChecked);
+
+ $(document).trigger(Smail.events.ui.mail.wantChecked, caller);
+
+ expect(mailHereCheckedEvent).toHaveBeenTriggeredOnAndWith(caller, { checkedMails: {'1': {} }});
+ });
+
+ it('returns an empty list to whomever requests the checked mails if there are no checked mails', function () {
+ var caller = {};
+ var mailHereCheckedEvent = spyOnEvent(caller, Smail.events.ui.mail.hereChecked);
+
+ $(document).trigger(Smail.events.ui.mail.wantChecked, caller);
+
+ expect(mailHereCheckedEvent).toHaveBeenTriggeredOnAndWith(caller, { checkedMails: {} });
+ });
+
+ it('removes for the checked mails when mail is unchecked', function () {
+ this.component.attr.checkedMails = {
+ '1': {},
+ '2': {},
+ '3': {}
+ };
+
+ $(document).trigger(Smail.events.ui.mail.unchecked, {mail: {ident: '1'}});
+
+ expect(this.component.attr.checkedMails).toEqual({'2': {}, '3': {} });
+ });
+
+ it('checks the check all checkbox if at least one mail is checked', function () {
+ var setCheckAllCheckboxEvent = spyOnEvent(document, Smail.events.ui.mails.hasMailsChecked);
+
+ $(document).trigger(Smail.events.ui.mail.checked, {mail: mailList[0]});
+
+ expect(setCheckAllCheckboxEvent).toHaveBeenTriggeredOnAndWith(document, true);
+ });
+
+ it('unchecks the check all checkbox if no mail is left checked', function () {
+ this.component.attr.checkedMails = {1: {}};
+
+ var setCheckAllCheckboxEvent = spyOnEvent(document, Smail.events.ui.mails.hasMailsChecked);
+
+ $(document).trigger(Smail.events.ui.mail.unchecked, {mail: {ident: '1'}});
+
+ expect(setCheckAllCheckboxEvent).toHaveBeenTriggeredOnAndWith(document, false);
+ });
+ });
+
+ describe('when mails are available', function () {
+ it('should open email if popstate event happened (when mailIdent isnt undefined)', function () {
+ var openMailEvent = spyOnEvent(document, Smail.events.ui.mail.open);
+
+ this.component.$node.trigger(Smail.events.mails.available, { mails: mailList, mailIdent: '30' });
+
+ expect(openMailEvent).toHaveBeenTriggeredOnAndWith(document, { ident: '30'});
+ });
+
+ it('should open draft in popstate event if tag is Drafts', function () {
+ var openDraftEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openDraft);
+
+ this.component.$node.trigger(Smail.events.mails.available, { mails: mailList, mailIdent: '30', tag: 'drafts' });
+
+ expect(openDraftEvent).toHaveBeenTriggeredOnAndWith(document, { ident: '30'});
+ });
+ });
+
+ it('should not append emails when another mails:available event is triggered', function () {
+ this.component.$node.trigger(Smail.events.mails.available, { mails: mailList });
+
+ expect(this.component.$node.find('a').length).toEqual(2);
+
+ this.component.$node.trigger(Smail.events.mails.available, { mails: mailList });
+
+ expect(this.component.$node.find('a').length).toEqual(2);
+ });
+
+ it('resets scroll when opening a new tag or choosing a new tag', function () {
+ var eventSpy = spyOnEvent(document, Smail.events.dispatchers.middlePane.resetScroll);
+ this.component.$node.trigger(Smail.events.mails.available, { mails: mailList });
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ });
+
+ describe('rendering the mails', function () {
+
+ describe('when mails are available for refreshing', function () {
+ it('renders the new mails', function () {
+ this.component.$node.trigger(Smail.events.mails.availableForRefresh, { mails: mailList });
+
+ matchMail(mailList[0], this.component.$node);
+ matchMail(mailList[1], this.component.$node);
+ });
+
+ });
+
+ it('should render all mails sent in ui:mails:show event', function () {
+ this.component.$node.trigger(Smail.events.mails.available, { mails: mailList });
+
+ matchMail(mailList[0], this.component.$node);
+ matchMail(mailList[1], this.component.$node);
+ });
+
+ it('should select the current email when mails are available', function () {
+ this.component.attr.currentMailIdent = '1';
+
+ this.component.trigger(Smail.events.mails.available, { mails: mailList });
+
+ matchSelectedMail(mailList[0], this.component.$node);
+ matchMail(mailList[1], this.component.$node);
+ });
+
+ it('should keep the mail checked when it was previously checked (so refresh works)', function () {
+ var checkbox, mailIdent;
+
+ mailIdent = mailList[0].ident;
+ this.component.attr.checkedMails[mailIdent] = mailList[0];
+ this.component.$node.trigger(Smail.events.mails.available, { mails: [mailList[0]] });
+ checkbox = this.$node.find('input[type=checkbox]');
+
+ expect(checkbox.prop('checked')).toBe(true);
+ });
+
+ it('should render links for the emails', function () {
+ this.component.$node.trigger(Smail.events.mails.available, { mails: mailList, tag: 'inbox' });
+
+ expect(this.$node.html()).toMatch('href="/#/inbox/mail/1');
+ expect(this.$node.html()).toMatch('href="/#/inbox/mail/2');
+ });
+
+ it('should clean the selected email', function () {
+ this.component.attr.currentMailIdent = '1';
+ this.component.trigger(Smail.events.ui.mails.cleanSelected);
+
+ expect(this.component.attr.currentMailIdent).toEqual('');
+ });
+
+ function matchMail(mail, node) {
+ expect(node.html()).toMatch('id="mail-' + mail.ident + '"');
+ expect(node.html()).toMatch('<div class="subject-and-tags">');
+ expect(node.html()).toMatch('<div class="from">' + mail.header.from + '</div>');
+ expect(node.html()).toMatch('<span class="received-date">' + mail.header.formattedDate + '</span>');
+ }
+
+ function matchSelectedMail(mail, node) {
+ expect(node.html()).toMatch(['id="mail-', mail.ident, '" class="selected"'].join(''));
+ }
+ });
+
+ describe('when saving a draft', function () {
+ it('refreshes the list if the current tag is drafts', function () {
+ this.component.attr.currentTag = 'drafts';
+ var spyRefresh = spyOnEvent(document, Smail.events.ui.mails.refresh);
+ var spyScroll = spyOnEvent(document, Smail.events.dispatchers.middlePane.resetScroll);
+ this.component.trigger(Smail.events.mail.draftSaved, {ident: 1});
+ expect(spyRefresh).toHaveBeenTriggeredOn(document);
+ expect(spyScroll).toHaveBeenTriggeredOn(document);
+ });
+
+ it('does not refresh the list if the current tag is not drafts', function() {
+ this.component.attr.currentTag = 'sent';
+ var spyRefresh = spyOnEvent(document, Smail.events.ui.mails.refresh);
+ var spyScroll = spyOnEvent(document, Smail.events.dispatchers.middlePane.resetScroll);
+ this.component.trigger(Smail.events.mail.draftSaved, {ident: 1});
+ expect(spyRefresh).not.toHaveBeenTriggeredOn(document);
+ expect(spyScroll).not.toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ describe('when sending a mail', function () {
+ it('refreshes the list if the current tag is drafts', function () {
+ this.component.attr.currentTag = 'drafts';
+ var spyRefresh = spyOnEvent(document, Smail.events.ui.mails.refresh);
+ var spyScroll = spyOnEvent(document, Smail.events.dispatchers.middlePane.resetScroll);
+ this.component.trigger(Smail.events.mail.sent);
+ expect(spyRefresh).toHaveBeenTriggeredOn(document);
+ expect(spyScroll).toHaveBeenTriggeredOn(document);
+ });
+
+ it('refreshes the list if the current tag is sent', function() {
+ this.component.attr.currentTag = 'sent';
+ var spyRefresh = spyOnEvent(document, Smail.events.ui.mails.refresh);
+ var spyScroll = spyOnEvent(document, Smail.events.dispatchers.middlePane.resetScroll);
+ this.component.trigger(Smail.events.mail.sent);
+ expect(spyRefresh).toHaveBeenTriggeredOn(document);
+ expect(spyScroll).toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ function createMail(subject, from, ident, date) {
+ var mail = Smail.testData().parsedMail.simpleTextPlain;
+
+ return _.merge(mail, {
+ header: {
+ subject: subject,
+ from: from,
+ date: date
+ },
+ ident: ident,
+ tags: ['inbox']
+ });
+ }
+});
diff --git a/web-ui/test/spec/mail_list_actions/ui/compose_trigger.spec.js b/web-ui/test/spec/mail_list_actions/ui/compose_trigger.spec.js
new file mode 100644
index 00000000..4942b5b6
--- /dev/null
+++ b/web-ui/test/spec/mail_list_actions/ui/compose_trigger.spec.js
@@ -0,0 +1,16 @@
+describeComponent('mail_list_actions/ui/compose_trigger', function () {
+ 'use strict';
+
+ beforeEach(function () {
+ setupComponent('<div></div>');
+ });
+
+ it('triggers the enableComposebox event when clicked', function () {
+ var spyEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openComposeBox);
+
+ this.component.trigger('click');
+
+ expect(spyEvent).toHaveBeenTriggeredOn(document);
+ });
+
+});
diff --git a/web-ui/test/spec/mail_list_actions/ui/mail_actions.spec.js b/web-ui/test/spec/mail_list_actions/ui/mail_actions.spec.js
new file mode 100644
index 00000000..f7a0ed72
--- /dev/null
+++ b/web-ui/test/spec/mail_list_actions/ui/mail_actions.spec.js
@@ -0,0 +1,9 @@
+describeComponent('mail_list_actions/ui/mail_actions', function () {
+ 'use strict';
+
+ beforeEach(function () {
+ setupComponent();
+ });
+
+
+});
diff --git a/web-ui/test/spec/mail_list_actions/ui/pagination_trigger.spec.js b/web-ui/test/spec/mail_list_actions/ui/pagination_trigger.spec.js
new file mode 100644
index 00000000..67c2f3ef
--- /dev/null
+++ b/web-ui/test/spec/mail_list_actions/ui/pagination_trigger.spec.js
@@ -0,0 +1,26 @@
+describeComponent('mail_list_actions/ui/pagination_trigger', function () {
+ 'use strict';
+
+ beforeEach(function () {
+ setupComponent();
+ });
+
+ it('triggers the ui:page:previous event when the left arrow is clicked', function () {
+ var eventSpy = spyOnEvent(document, Smail.events.ui.page.previous);
+ this.component.select('previous').click();
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ });
+
+
+ it('triggers the ui:page:next event when the right arrow is clicked', function () {
+ var eventSpy = spyOnEvent(document, Smail.events.ui.page.next);
+ this.component.select('next').click();
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ });
+
+ it('re-renders with current page number when page changes', function () {
+ this.component.trigger(document, Smail.events.ui.page.changed, {currentPage: 0});
+
+ expect(this.component.select('currentPage').text()).toBe('1');
+ });
+});
diff --git a/web-ui/test/spec/mail_view/data/mail_builder.spec.js b/web-ui/test/spec/mail_view/data/mail_builder.spec.js
new file mode 100644
index 00000000..bf17d598
--- /dev/null
+++ b/web-ui/test/spec/mail_view/data/mail_builder.spec.js
@@ -0,0 +1,110 @@
+define(['mail_view/data/mail_builder'], function (mailBuilder) {
+ describe('mail builder', function () {
+ 'use strict';
+
+ it('sets ident if passed to constructor', function() {
+ var mail = mailBuilder.newMail('12345').build();
+
+ expect(mail.ident).toBe('12345');
+ });
+
+ it('sets ident to empty if not passed to constructor', function() {
+ var mail = mailBuilder.newMail().build();
+
+ expect(mail.ident).toBe('');
+ });
+
+ it('sets the subject', function() {
+ var mail = mailBuilder.newMail().subject("subject").build();
+
+ expect(mail.header.subject).toBe("subject");
+ });
+
+ it('sets the body', function() {
+ var mail = mailBuilder.newMail().body("some body text").build();
+
+ expect(mail.body).toBe("some body text");
+ });
+
+ describe('to field', function() {
+ it('adds a single address', function() {
+ var mail = mailBuilder.newMail().to('foo@bar.com').build();
+
+ expect(mail.header.to).toContain('foo@bar.com');
+ });
+
+ it('adds multiple addresses', function() {
+ var mail = mailBuilder.newMail().to('foo@bar.com bar@foo.com').build();
+
+ expect(mail.header.to).toContain('foo@bar.com');
+ expect(mail.header.to).toContain('bar@foo.com');
+ });
+
+ it('accepts undefined without breaking', function() {
+ var mail = mailBuilder.newMail().to(undefined).build();
+
+ expect(mail.header.to).toEqual([]);
+ });
+ });
+
+ describe('cc field', function() {
+ it('adds a single address', function() {
+ var mail = mailBuilder.newMail().cc('foo@bar.com').build();
+
+ expect(mail.header.cc).toContain('foo@bar.com');
+ });
+
+ it('adds multiple addresses', function() {
+ var mail = mailBuilder.newMail().cc('foo@bar.com bar@foo.com').build();
+
+ expect(mail.header.cc).toContain('foo@bar.com');
+ expect(mail.header.cc).toContain('bar@foo.com');
+ });
+
+ it('accepts undefined without breaking', function() {
+ var mail = mailBuilder.newMail().cc(undefined).build();
+
+ expect(mail.header.cc).toEqual([]);
+ });
+ });
+
+ describe('bcc field', function() {
+ it('adds a single address', function() {
+ var mail = mailBuilder.newMail().bcc('foo@bar.com').build();
+
+ expect(mail.header.bcc).toContain('foo@bar.com');
+ });
+
+ it('adds multiple addresses', function() {
+ var mail = mailBuilder.newMail().bcc('foo@bar.com bar@foo.com').build();
+
+ expect(mail.header.bcc).toContain('foo@bar.com');
+ expect(mail.header.bcc).toContain('bar@foo.com');
+ });
+
+ it('accepts undefined without breaking', function() {
+ var mail = mailBuilder.newMail().bcc(undefined).build();
+
+ expect(mail.header.bcc).toEqual([]);
+ });
+ });
+
+ it('adds arbitrary headers', function() {
+ var mail = mailBuilder.newMail()
+ .header('Reply-To', 'something')
+ .header('In-Reply-To', '12345')
+ .build();
+
+ expect(mail.header['Reply-To']).toBe('something');
+ expect(mail.header['In-Reply-To']).toBe('12345');
+ });
+
+ it('adds tag', function() {
+ var mail = mailBuilder.newMail()
+ .tag('tag1')
+ .build();
+
+ expect(mail.tags).toContain('tag1');
+ });
+ });
+});
diff --git a/web-ui/test/spec/mail_view/data/mail_sender.spec.js b/web-ui/test/spec/mail_view/data/mail_sender.spec.js
new file mode 100644
index 00000000..3bdc1934
--- /dev/null
+++ b/web-ui/test/spec/mail_view/data/mail_sender.spec.js
@@ -0,0 +1,70 @@
+/*global Smail */
+
+describeComponent('mail_view/data/mail_sender', function () {
+ 'use strict';
+
+ var mailBuilder;
+ var mail;
+
+ beforeEach(function () {
+ mailBuilder = require('mail_view/data/mail_builder');
+ mail = Smail.testData().parsedMail.simpleTextPlain;
+ setupComponent();
+ });
+
+ it('sends mail data with a POST to the server when asked to send email', function() {
+ var mailSentEventSpy = spyOnEvent(document, Smail.events.mail.sent);
+ var g;
+
+ spyOn($, 'ajax').andReturn({done: function(f) { g = f; return {fail: function(){}};}});
+
+ this.component.trigger(Smail.events.mail.send, mail);
+
+ g();
+
+ expect(mailSentEventSpy).toHaveBeenTriggeredOn(document);
+
+ expect($.ajax.mostRecentCall.args[0]).toEqual('/mails');
+ expect($.ajax.mostRecentCall.args[1].type).toEqual('POST');
+ expect(JSON.parse($.ajax.mostRecentCall.args[1].data).header).toEqual(mail.header);
+ expect(JSON.parse($.ajax.mostRecentCall.args[1].data).body).toEqual(mail.body);
+ });
+
+ it('save draft data with a POST to the server when asked to save draft for the first time', function() {
+ var draftSavedEventSpy = spyOnEvent(document, Smail.events.mail.draftSaved);
+ var g;
+
+ spyOn($, 'ajax').andReturn({done: function(f) { g = f; return {fail: function(){}};}});
+
+ mail.ident = '';
+ this.component.trigger(Smail.events.mail.saveDraft, mail);
+
+ g();
+
+ expect(draftSavedEventSpy).toHaveBeenTriggeredOn(document);
+
+ expect($.ajax.mostRecentCall.args[0]).toEqual('/mails');
+ expect($.ajax.mostRecentCall.args[1].type).toEqual('POST');
+ expect(JSON.parse($.ajax.mostRecentCall.args[1].data).header).toEqual(mail.header);
+ expect(JSON.parse($.ajax.mostRecentCall.args[1].data).body).toEqual(mail.body);
+ });
+
+ it('save draft data with a PUT to the server when asked to save draft for the second time', function() {
+ var draftSavedEventSpy = spyOnEvent(document, Smail.events.mail.draftSaved);
+ var g;
+
+ spyOn($, 'ajax').andReturn({done: function(f) { g = f; return {fail: function(){}};}});
+
+ mail.ident = 0;
+ this.component.trigger(Smail.events.mail.saveDraft, mail);
+
+ g();
+
+ expect(draftSavedEventSpy).toHaveBeenTriggeredOn(document);
+
+ expect($.ajax.mostRecentCall.args[0]).toEqual('/mails');
+ expect($.ajax.mostRecentCall.args[1].type).toEqual('PUT');
+ expect(JSON.parse($.ajax.mostRecentCall.args[1].data).header).toEqual(mail.header);
+ expect(JSON.parse($.ajax.mostRecentCall.args[1].data).body).toEqual(mail.body);
+ });
+});
diff --git a/web-ui/test/spec/mail_view/ui/compose_box.spec.js b/web-ui/test/spec/mail_view/ui/compose_box.spec.js
new file mode 100644
index 00000000..a131d2cf
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/compose_box.spec.js
@@ -0,0 +1,132 @@
+/*global jasmine */
+/*global Smail */
+
+describeComponent('mail_view/ui/compose_box', function () {
+ 'use strict';
+ beforeEach(function () {
+ Smail.mockBloodhound();
+ setupComponent('<div style="display:none"></div>');
+ });
+
+
+ describe('compose new mail', function() {
+
+ it('only sends if all the recipients are valid emails', function() {
+ $(document).trigger(Smail.events.ui.recipients.updated, {recipientsName: 'to', newRecipients: ['valid@email.example']});
+
+ var eventSpy = spyOnEvent(document, Smail.events.mail.send);
+
+ $(document).trigger(Smail.events.ui.mail.send);
+
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ });
+
+ it('sends the recipient entered', function () {
+ $(document).trigger(Smail.events.ui.recipients.updated, {recipientsName: 'to', newRecipients: ['fox@somewhere.com']});
+
+ var eventSpy = spyOnEvent(document, Smail.events.mail.send);
+
+ $(document).trigger(Smail.events.ui.mail.send);
+
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ expect(eventSpy.mostRecentCall.data.header).toEqual(jasmine.objectContaining({
+ to: ['fox@somewhere.com']
+ }));
+ });
+
+ it('sends the multiple recipients entered', function () {
+ $(document).trigger(Smail.events.ui.recipients.updated, {recipientsName: 'to', newRecipients: ['fox@somewhere.com', 'blarg@someone.com', 'fox2@google.se']});
+ var eventSpy = spyOnEvent(document, Smail.events.mail.send);
+
+ $(document).trigger(Smail.events.ui.mail.send);
+
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ expect(eventSpy.mostRecentCall.data.header).toEqual(jasmine.objectContaining({
+ to: ['fox@somewhere.com', 'blarg@someone.com', 'fox2@google.se']
+ }));
+ });
+
+ it('sends the subject line entered', function () {
+ $(document).trigger(Smail.events.ui.recipients.updated, {recipientsName: 'to', newRecipients: ['aa@aa.com']});
+ this.component.select('subjectBox').val('A new fancy subject!');
+ var eventSpy = spyOnEvent(document, Smail.events.mail.send);
+
+ $(document).trigger(Smail.events.ui.mail.send);
+
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ expect(eventSpy.mostRecentCall.data.header).toEqual(jasmine.objectContaining({
+ subject: 'A new fancy subject!'
+ }));
+ });
+
+ it('sends the multiple CCs entered', function () {
+ $(document).trigger(Smail.events.ui.recipients.updated, {recipientsName: 'cc', newRecipients: ['cc1@foo.bar', 'cc2@bar.foo', 'cc3@zz.top']});
+ var eventSpy = spyOnEvent(document, Smail.events.mail.send);
+
+ $(document).trigger(Smail.events.ui.mail.send);
+
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ expect(eventSpy.mostRecentCall.data.header).toEqual(jasmine.objectContaining({
+ cc: ['cc1@foo.bar', 'cc2@bar.foo', 'cc3@zz.top']
+ }));
+ });
+
+ it('sends the multiple BCCs entered', function () {
+ $(document).trigger(Smail.events.ui.recipients.updated, {recipientsName: 'bcc', newRecipients: ['bcc1@foo.bar', 'bcc2@bar.foo', 'bcc3@zz.top']});
+ var eventSpy = spyOnEvent(document, Smail.events.mail.send);
+
+ $(document).trigger(Smail.events.ui.mail.send);
+
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ expect(eventSpy.mostRecentCall.data.header).toEqual(jasmine.objectContaining({
+ bcc: ['bcc1@foo.bar', 'bcc2@bar.foo', 'bcc3@zz.top']
+ }));
+ });
+
+ it('shows no message selected pane when deleting the email being composed', function() {
+ var openNoMessageSelectedPaneEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+ var mails = [{ident: 123}];
+ this.component.attr.ident = 123;
+
+ this.component.trigger(document, Smail.events.mail.deleted, {mails: mails});
+
+ expect(openNoMessageSelectedPaneEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('does not show no message selected pane when deleting a different set of emails', function() {
+ var openNoMessageSelectedPaneEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+ var mails = [{ident: 321}];
+ this.component.attr.ident = 123;
+
+ this.component.trigger(document, Smail.events.mail.deleted, {mails: mails});
+
+ expect(openNoMessageSelectedPaneEvent).not.toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ describe('close button behavior', function() {
+
+ it('should fire Show no message selected if the close button is clicked', function() {
+ var spy = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+ this.component.select('closeButton').click();
+ expect(spy).toHaveBeenTriggeredOn(document);
+ });
+
+ });
+
+ describe('draft compose box', function() {
+ it('should save a draft when click on draft button', function () {
+ $(document).trigger(Smail.events.ui.recipients.updated, {recipientsName: 'to', newRecipients: ['fox@somewhere.com']});
+
+ this.component.select('subjectBox').val('A new fancy subject!');
+ var eventSpy = spyOnEvent(document, Smail.events.mail.saveDraft);
+
+ this.component.select('draftButton').click();
+
+ expect(eventSpy).toHaveBeenTriggeredOn(document);
+ expect(eventSpy.mostRecentCall.data.header).toEqual(jasmine.objectContaining({
+ to: ['fox@somewhere.com']
+ }));
+ });
+ });
+});
diff --git a/web-ui/test/spec/mail_view/ui/draft_box.spec.js b/web-ui/test/spec/mail_view/ui/draft_box.spec.js
new file mode 100644
index 00000000..3e02f752
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/draft_box.spec.js
@@ -0,0 +1,67 @@
+/* global Smail */
+
+describeComponent('mail_view/ui/draft_box', function () {
+ 'use strict';
+
+ var mail;
+
+ beforeEach(function () {
+ Smail.mockBloodhound();
+ mail = Smail.testData().parsedMail.simpleTextPlain;
+ });
+
+ describe('when initializing', function () {
+ it('fetches the email to draft', function () {
+ var mailWantEvent = spyOnEvent(document, Smail.events.mail.want);
+
+ setupComponent({mailIdent: '1'});
+
+ expect(mailWantEvent).toHaveBeenTriggeredOnAndWith(document, {
+ mail: '1', caller: this.component
+ });
+ });
+ });
+
+ describe('after initialize', function () {
+ beforeEach(function () {
+ setupComponent({mailIdent: '1'});
+ });
+
+ it('renders the compose box when mail is received', function () {
+ var templates = require('views/templates');
+
+ spyOn(this.component, 'render');
+
+ this.component.trigger(this.component, Smail.events.mail.here, { mail: mail});
+
+ expect(this.component.render).toHaveBeenCalledWith(templates.compose.box, {
+ recipients: { to: mail.header.to, cc: mail.header.cc, bcc: mail.header.bcc },
+ subject: mail.header.subject,
+ body: mail.body
+ });
+ });
+
+ });
+
+ it('sending a draft sends the correct mailIdent', function () {
+ setupComponent({mailIdent: mail.ident});
+ this.component.trigger(this.component, Smail.events.mail.here, { mail: mail});
+
+ var sendDraftEvent = spyOnEvent(document, Smail.events.mail.saveDraft);
+ this.component.select('draftButton').click();
+
+ expect(sendDraftEvent).toHaveBeenTriggeredOnAndWith(document, jasmine.objectContaining({ident: mail.ident}));
+ });
+
+ it('shows no message selected pane when draft is sent', function() {
+ var openNoMessageSelectedEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ setupComponent({mailIdent: mail.ident});
+ this.component.trigger(this.component, Smail.events.mail.here, { mail: mail});
+
+ this.component.trigger(document, Smail.events.mail.sent);
+
+ expect(openNoMessageSelectedEvent).toHaveBeenTriggeredOn(document);
+ });
+
+});
diff --git a/web-ui/test/spec/mail_view/ui/draft_save_status.spec.js b/web-ui/test/spec/mail_view/ui/draft_save_status.spec.js
new file mode 100644
index 00000000..fb989f4c
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/draft_save_status.spec.js
@@ -0,0 +1,26 @@
+/* global Smail */
+
+describeComponent('mail_view/ui/draft_save_status', function () {
+ 'use strict';
+
+ beforeEach(setupComponent);
+
+ it('should be empty on initialization', function() {
+ expect(this.$node.text()).toBe('');
+ });
+
+ it('should display status when saving a draft', function() {
+ $(document).trigger(Smail.events.mail.saveDraft);
+ expect(this.$node.text()).toBe('Saving to Drafts...');
+ });
+
+ it('should display status when draft is saved', function() {
+ $(document).trigger(Smail.events.mail.draftSaved);
+ expect(this.$node.text()).toBe('Draft Saved.');
+ });
+
+ it('should reset status when mail is changed since last save', function() {
+ $(document).trigger(Smail.events.ui.mail.changedSinceLastSave);
+ expect(this.$node.text()).toBe('');
+ });
+});
diff --git a/web-ui/test/spec/mail_view/ui/forward_box.spec.js b/web-ui/test/spec/mail_view/ui/forward_box.spec.js
new file mode 100644
index 00000000..fc878447
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/forward_box.spec.js
@@ -0,0 +1,90 @@
+/*global jasmine */
+/*global Smail */
+
+describeComponent('mail_view/ui/forward_box', function () {
+ 'use strict';
+
+ var attrs;
+ var testMail;
+ beforeEach(function () {
+ testMail = Smail.testData().parsedMail.simpleTextPlain;
+
+ Smail.mockBloodhound();
+ });
+
+ it('should have a subject of Fwd: <original_message>', function() {
+ testMail.header.subject = 'Very interesting';
+ setupComponent({ mail: testMail });
+
+ expect(this.component.select('subjectDisplay').text()).toEqual('Fwd: '+ testMail.header.subject);
+ });
+
+ it('should have no recipients', function () {
+ var Recipients = require('mail_view/ui/recipients/recipients');
+ spyOn(Recipients, 'attachTo');
+
+ setupComponent({ mail: testMail });
+
+ expect(Recipients.attachTo.calls[0].args[1]).toEqual({name: 'to', addresses: []});
+ expect(Recipients.attachTo.calls[1].args[1]).toEqual({name: 'cc', addresses: []});
+ expect(Recipients.attachTo.calls[2].args[1]).toEqual({name: 'bcc', addresses: []});
+ });
+
+ it('should populate body text area with quote of email being forwarded', function() {
+ var viewHelper = require('helpers/view_helper');
+ spyOn(viewHelper, 'quoteMail').andReturn('quoted email');
+
+ setupComponent({ mail: testMail });
+
+ expect(viewHelper.quoteMail).toHaveBeenCalledWith(testMail);
+ expect(this.component.select('bodyBox').val()).toBe('quoted email');
+ });
+
+ it('should show subject field when clicking on subject display', function() {
+ setupComponent({ mail: testMail });
+
+ this.component.select('subjectDisplay').click();
+
+ expect(this.component.select('subjectInput')).not.toBeHidden();
+ expect(this.component.select('subjectDisplay')).toBeHidden();
+ });
+
+ it('should copy original message headers', function() {
+ var mailSendEvent = spyOnEvent(document, Smail.events.mail.send);
+
+ testMail.header.bcc = 'original_bcc@email.com';
+ testMail.header.cc = 'original_cc@email.com';
+ testMail.header.date = 'original_date';
+ testMail.header.from = 'original_from';
+ testMail.header.message_id = 'original_message_id';
+ testMail.header.reply_to = 'original_reply_to@email.com';
+ testMail.header.sender = 'original_sender';
+ testMail.header.to = 'original_to@email.com';
+
+ setupComponent({ mail: testMail });
+
+ this.component.attr.recipientValues.to.push('forward_to@email.com');
+ $(document).trigger(Smail.events.ui.mail.send);
+
+ expect(mailSendEvent).toHaveBeenTriggeredOn(document);
+ var sentMail = mailSendEvent.mostRecentCall.data;
+
+ expect(sentMail.header).toEqual(jasmine.objectContaining({
+ resent_bcc: 'original_bcc@email.com',
+ resent_cc: 'original_cc@email.com',
+ resent_date: 'original_date',
+ resent_from: 'original_from',
+ resent_message_id: 'original_message_id',
+ resent_reply_to: 'original_reply_to@email.com',
+ resent_sender: 'original_sender',
+ resent_to: 'original_to@email.com'
+ }));
+ });
+
+ it('triggers openMail when email is sent', function() {
+ var eventSpy = spyOnEvent(document, Smail.events.ui.mail.open);
+ setupComponent({ mail: testMail });
+ $(document).trigger(Smail.events.mail.sent, {ident: testMail.ident});
+ expect(eventSpy).toHaveBeenTriggeredOnAndWith(document, {ident: testMail.ident});
+ });
+});
diff --git a/web-ui/test/spec/mail_view/ui/mail_actions.spec.js b/web-ui/test/spec/mail_view/ui/mail_actions.spec.js
new file mode 100644
index 00000000..93db0193
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/mail_actions.spec.js
@@ -0,0 +1,63 @@
+/*global Smail */
+
+describeComponent('mail_view/ui/mail_actions', function () {
+ 'use strict';
+
+ var testData;
+
+ beforeEach(function(){
+ testData = Smail.testData();
+ setupComponent(testData);
+ });
+
+ it('verifies if more actions list is hidden when rendering mail view', function() {
+
+ var moreActionsComponent = this.component.select('moreActions');
+ expect(moreActionsComponent.attr('style').trim()).toEqual('display: none;');
+
+ });
+
+ it('show more actions list when click on view more actions button', function(){
+
+ this.component.select('viewMoreActions').click();
+
+ var moreActionsComponent = this.component.select('moreActions');
+ expect(moreActionsComponent.attr('style').trim()).not.toEqual('display: none;');
+ });
+
+ it('triggers a show reply box event when clicking on reply-button-top', function(){
+
+ var showReplyBoxEvent = spyOnEvent(document, Smail.events.ui.replyBox.showReply);
+
+ this.component.select('replyButtonTop').click();
+
+ expect(showReplyBoxEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('triggers a show reply all box event when clicking on reply-button-top and hide more actions list', function(){
+
+ var showReplyAllEvent = spyOnEvent(document, Smail.events.ui.replyBox.showReplyAll);
+
+ this.component.select('viewMoreActions').click();
+ this.component.select('replyAllButtonTop').click();
+
+ expect(showReplyAllEvent).toHaveBeenTriggeredOn(document);
+
+ var moreActionsComponent = this.component.select('moreActions');
+ expect(moreActionsComponent.attr('style').trim()).toEqual('display: none;');
+ });
+
+ it('triggers a delete event when clicking on delete-button-top', function(){
+
+ var deleteEvent = spyOnEvent(document, Smail.events.ui.mail.delete);
+
+ this.component.select('viewMoreActions').click();
+ this.component.select('deleteButtonTop').click();
+
+ expect(deleteEvent).toHaveBeenTriggeredOnAndWith(document, {mail: testData.mail});
+
+ var moreActionsComponent = this.component.select('moreActions');
+ expect(moreActionsComponent.attr('style').trim()).toEqual('display: none;');
+ });
+
+});
diff --git a/web-ui/test/spec/mail_view/ui/mail_view.spec.js b/web-ui/test/spec/mail_view/ui/mail_view.spec.js
new file mode 100644
index 00000000..80f253e0
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/mail_view.spec.js
@@ -0,0 +1,247 @@
+/*global Smail */
+
+describeComponent('mail_view/ui/mail_view', function () {
+ 'use strict';
+
+ var mail;
+
+ var testData;
+
+ beforeEach(function () {
+ mail = {ident: 1, header: { date: '12/12/12T12:12' }, tags: ['inbox']};
+ testData = {mail: Smail.testData().parsedMail.simpleTextPlain};
+ Smail.mockBloodhound();
+ setupComponent('<div></div>', {mail: mail});
+ });
+
+ it('triggers mail:want on ui:openMail', function () {
+ var spyEvent = spyOnEvent(document, Smail.events.mail.want);
+
+ setupComponent('<div></div>', {ident: mail.ident });
+
+ expect(spyEvent).toHaveBeenTriggeredOn(document);
+ expect(spyEvent.mostRecentCall.data.mail).toEqual(1);
+ });
+
+ it('triggers dispatchers.rightPane.openNoMessageSelected when getting mail.notFound', function () {
+ var openNoMessageSelectedEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ this.component.trigger(this.component, Smail.events.mail.notFound);
+
+ expect(openNoMessageSelectedEvent).toHaveBeenTriggeredOn(document);
+ });
+
+
+ it('removes the tag from the mail when the tag label is clicked', function() {
+ var updateSpy = spyOnEvent(document, Smail.events.mail.tags.update);
+
+ this.component.displayMail({}, testData);
+ this.component.removeTag('inbox');
+
+ expect(updateSpy).toHaveBeenTriggeredOn(document);
+ });
+
+ it('verifies if new tag input is hidden when rendering mail view', function() {
+
+ this.component.displayMail({}, testData);
+
+ var newTagInputComponent = this.component.select('newTagInput');
+ expect(newTagInputComponent.attr('style').trim()).toEqual('display: none;');
+ });
+
+ it('verifies if new tag input is shown when clicking on new tag button ', function() {
+ this.component.displayMail({}, testData);
+
+ var newTagInputComponent = this.component.select('newTagInput');
+ var addNewComponent = this.component.select('addNew');
+
+ this.component.select('newTagButton').click();
+
+ expect(newTagInputComponent.attr('style').trim()).not.toEqual('display: none;');
+ expect(addNewComponent.attr('style').trim()).toEqual('display: none;');
+ });
+
+ it('hides new tag button when pressing esc key', function(){
+ this.component.displayMail({}, testData);
+ this.component.select('newTagButton').click();
+
+ var e = creatingEvent("keydown", 27);
+ var newTagInputComponent = this.component.select('newTagInput');
+ var addNewComponent = this.component.select('addNew');
+
+ newTagInputComponent.trigger(e);
+
+ expect(newTagInputComponent.attr('style').trim()).toEqual('display: none;');
+ expect(addNewComponent.attr('style').trim()).not.toEqual('display: none;');
+ });
+
+ it('assumes that the mail is encrypted and valid if at least one of the locks are valid', function() {
+ var email = testData;
+ email.security_casing = {locks: [{state: 'valid'}, {state: 'failure'}]};
+ expect(this.component.checkEncrypted(email)).toEqual('encrypted encryption-valid');
+ });
+
+ it('assumes that the mail is encrypted and failure if all the locks are failed', function() {
+ var email = testData;
+ email.security_casing = {locks: [{state: 'failure'}, {state: 'failure'}]};
+ expect(this.component.checkEncrypted(email)).toEqual('encrypted encryption-failure');
+ });
+
+ it('assumes that the mail is not encrypted if it doesn\'t have any locks', function() {
+ var email = testData;
+ email.security_casing = {locks: []};
+ expect(this.component.checkEncrypted(email)).toEqual('not-encrypted');
+ });
+
+ it('assumes that the mail is signed only if all imprints are valid', function() {
+ var email = testData;
+ email.security_casing = {imprints: [{state: 'valid', seal: {trust: 'marginal', validity: 'marginal'}}, {state: 'valid', seal: {trust: 'marginal', validity: 'marginal'}}]};
+ expect(this.component.checkSigned(email)).toEqual('signed');
+ });
+
+ it('assumes that the mail is signed with failures if there is a revoke or expire', function() {
+ var email = testData;
+ email.security_casing = {imprints: [{state: 'valid', seal: {trust: 'marginal', validity: 'marginal'}}, {state: 'from_revoked', seal: {trust: 'marginal', validity: 'marginal'}}]};
+ expect(this.component.checkSigned(email)).toEqual('signed signature-revoked');
+ });
+
+ it('assumes that mail is not trusted if its signature contains no_trust from the user', function() {
+ var email = testData;
+ email.security_casing = {imprints: [{seal: {trust: "no_trust", validity: "ultimate"}}]};
+ expect(this.component.checkSigned(email)).toEqual('signed signature-not-trusted');
+ });
+
+ it('uses validity when trust is not present', function() {
+ var email = testData;
+ email.security_casing = {imprints: [{seal: { validity: "no_trust"}}]};
+ expect(this.component.checkSigned(email)).toEqual('signed signature-not-trusted');
+ });
+
+ it('assumes not trusted when the signature is not found', function(){
+ var email = testData;
+ email.security_casing = {imprints: [{seal: null}]};
+ expect(this.component.checkSigned(email)).toEqual('signed signature-not-trusted');
+ });
+
+ it('assumes that the mail is not signed if there are no imprints', function() {
+ var email = testData
+ email.security_casing = {imprints: []}
+ expect(this.component.checkSigned(email)).toEqual('not-signed');
+ });
+
+ it('shows that mail is encrypted if it is', function() {
+ spyOn(this.component, 'checkEncrypted').andReturn('encrypted');
+ this.component.displayMail({}, testData);
+ expect(this.component.$node.find('.encrypted')).toExist();
+ });
+
+ it('shows that mail is signed if it is', function() {
+ spyOn(this.component, 'checkSigned').andReturn('signed');
+ this.component.displayMail({}, testData);
+ expect(this.component.$node.find('.signed')).toExist();
+ });
+
+ it('shows that mail is not encrypted if it isn\'t', function() {
+ spyOn(this.component, 'checkEncrypted').andReturn('not-encrypted');
+ this.component.displayMail({}, testData);
+ expect(this.component.$node.find('.not-encrypted')).toExist();
+ });
+
+ it('shows that mail is not signed if it isn\'t', function() {
+ spyOn(this.component, 'checkEncrypted').andReturn('not-signed');
+ this.component.displayMail({}, testData);
+ expect(this.component.$node.find('.not-signed')).toExist();
+ });
+
+ it('creates new tag when pressing Enter key on new tag input', function(){
+ var tagsUpdateEvent = spyOnEvent(document, Smail.events.mail.tags.update);
+ var tagListRefreshEvent = spyOnEvent(document, Smail.events.dispatchers.tags.refreshTagList);
+ var e = creatingEvent("keydown", 13);
+
+ this.component.displayMail({}, testData);
+ this.component.select('newTagButton').click();
+
+ var newTagInputComponent = this.component.select('newTagInput');
+ newTagInputComponent.val('Test');
+ newTagInputComponent.trigger(e);
+
+ var tags = testData.mail.tags.slice();
+ tags.push('Test');
+
+ expect(tagListRefreshEvent).toHaveBeenTriggeredOn(document);
+ expect(tagsUpdateEvent).toHaveBeenTriggeredOnAndWith(document, { ident: testData.mail.ident, tags: tags});
+ });
+
+ it('trigger mail delete event when moving email to trash', function(){
+ var mailDeleteEvent = spyOnEvent(document, Smail.events.ui.mail.delete);
+
+ Foundation.global.namespace = '';
+ $(document).foundation();
+
+ this.component.displayMail({}, testData);
+ this.component.moveToTrash();
+
+ expect(mailDeleteEvent).toHaveBeenTriggeredOnAndWith(document, { mail: this.component.attr.mail });
+ });
+
+ it('shows no message selected pane when deleting the email being composed', function() {
+ var openNoMessageSelectedPaneEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+ var mails = [{ident: 123}];
+ this.component.attr.mail = mails[0];
+
+ this.component.trigger(document, Smail.events.mail.deleted, {mails: mails});
+
+ expect(openNoMessageSelectedPaneEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('does not show no message selected pane when deleting a different set of emails', function() {
+ var openNoMessageSelectedPaneEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+ var mails = [{ident: 321}];
+ this.component.attr.mail = {ident: 123};
+
+ this.component.trigger(document, Smail.events.mail.deleted, {mails: mails});
+
+ expect(openNoMessageSelectedPaneEvent).not.toHaveBeenTriggeredOn(document);
+ });
+
+ describe('archiving email', function() {
+ it('trigger tag updates events with no tags', function(){
+ var tagsUpdateEvent = spyOnEvent(document, Smail.events.mail.tags.update);
+
+ Foundation.global.namespace = '';
+ $(document).foundation();
+
+ this.component.displayMail({}, testData);
+ this.component.archiveIt();
+
+ expect(tagsUpdateEvent).toHaveBeenTriggeredOnAndWith(document, { ident: testData.mail.ident, tags: []});
+ });
+
+ it('opens no message selected pane', function() {
+ var openNoMessageSelectedEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ Foundation.global.namespace = '';
+ $(document).foundation();
+
+ this.component.displayMail({}, testData);
+ this.component.archiveIt();
+
+ expect(openNoMessageSelectedEvent).toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ it('opens the no message selected pane when clicking the close button', function() {
+ var openNoMessageSelectedEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ this.component.displayMail({}, testData);
+ this.component.select('closeMailButton').click();
+
+ expect(openNoMessageSelectedEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ function creatingEvent(event, keyCode) {
+ var e = $.Event(event);
+ e.which = keyCode;
+ return e;
+ }
+});
diff --git a/web-ui/test/spec/mail_view/ui/recipients/recipients.spec.js b/web-ui/test/spec/mail_view/ui/recipients/recipients.spec.js
new file mode 100644
index 00000000..692bf0eb
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/recipients/recipients.spec.js
@@ -0,0 +1,36 @@
+/* global Smail */
+
+describeComponent('mail_view/ui/recipients/recipients',function () {
+ 'use strict';
+ var recipientsUpdatedEvent;
+
+ describe('initialization', function() {
+ it('adds recipients', function() {
+ setupComponent({name: 'to', addresses: ['foobar@gmail.com'] });
+ expect(this.component.attr.recipients.length).toBe(1);
+ });
+
+ it('does not trigger recipients updated events on initialization', function() {
+ recipientsUpdatedEvent = spyOnEvent(document, Smail.events.ui.recipients.updated);
+
+ setupComponent({name: 'to', addresses: ['foobar@gmail.com'] });
+ expect(recipientsUpdatedEvent).not.toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ describe('adding recipients from the ui', function() {
+ beforeEach(function () {
+ setupComponent();
+ recipientsUpdatedEvent = spyOnEvent(document, Smail.events.ui.recipients.updated);
+ this.component.trigger(Smail.events.ui.recipients.entered, {name: 'to', addresses: ['foobar@gmail.com'] });
+ });
+
+ it('triggers recipients updated', function() {
+ expect(recipientsUpdatedEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('adds recipients', function() {
+ expect(this.component.attr.recipients.length).toBe(1);
+ });
+ });
+});
diff --git a/web-ui/test/spec/mail_view/ui/recipients/recipients_input.spec.js b/web-ui/test/spec/mail_view/ui/recipients/recipients_input.spec.js
new file mode 100644
index 00000000..0f98d007
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/recipients/recipients_input.spec.js
@@ -0,0 +1,104 @@
+/* global Smail */
+
+describeComponent('mail_view/ui/recipients/recipients_input',function () {
+ 'use strict';
+
+ beforeEach(function () {
+ setupComponent({name: 'to'});
+ });
+
+ describe('keys that finish address input', function () {
+
+ _.each([
+ [186, 'semicolon'],
+ [188, 'comma'],
+ [32, 'space']
+
+ ], function (keycode) {
+
+ it(': ' + keycode[1], function () {
+ var addressEnteredEvent = spyOnEvent(this.$node, Smail.events.ui.recipients.entered);
+
+ var enterAddressKeyPressEvent = $.Event('keydown', { which: keycode[0] });
+ this.$node.val('a@b.c');
+ this.$node.trigger(enterAddressKeyPressEvent);
+
+ expect(addressEnteredEvent).toHaveBeenTriggeredOnAndWith(this, { name: 'to', address: 'a@b.c' });
+ });
+
+ it('wont add address if val is empty: ' + keycode[1], function () {
+ var addressEnteredEvent = spyOnEvent(this.$node, Smail.events.ui.recipients.entered);
+
+ var enterAddressKeyPressEvent = $.Event('keydown', { which: keycode[0] });
+ this.$node.val('');
+ this.$node.trigger(enterAddressKeyPressEvent);
+
+ expect(addressEnteredEvent).not.toHaveBeenTriggeredOnAndWith(this, { name: 'to', address: '' });
+ });
+
+ it('prevents event default regardless on input val when key is ' + keycode[1], function () {
+ var enterAddressKeyPressEvent = $.Event('keydown', { which: keycode[0] });
+ spyOn(enterAddressKeyPressEvent, 'preventDefault');
+
+ this.$node.val('')
+ this.$node.trigger(enterAddressKeyPressEvent);
+ expect(enterAddressKeyPressEvent.preventDefault).toHaveBeenCalled();
+
+ enterAddressKeyPressEvent.preventDefault.reset();
+ this.$node.val('anything')
+ this.$node.trigger(enterAddressKeyPressEvent);
+ expect(enterAddressKeyPressEvent.preventDefault).toHaveBeenCalled();
+ });
+
+ });
+
+ describe('when tab is pressed', function () {
+ it('enters an address and prevents event default if there is an input val', function () {
+ var addressEnteredEvent = spyOnEvent(this.$node, Smail.events.ui.recipients.entered);
+
+ var tabKeyPressEvent = $.Event('keydown', { which: 9});
+ spyOn(tabKeyPressEvent, 'preventDefault');
+
+ this.$node.val('a@b.c');
+ this.$node.trigger(tabKeyPressEvent);
+
+ expect(tabKeyPressEvent.preventDefault).toHaveBeenCalled();
+ expect(addressEnteredEvent).toHaveBeenTriggeredOnAndWith(this, { name: 'to', address: 'a@b.c'});
+ });
+
+ it('doesnt enter an address and doesnt prevent event default if input val is empty (so tab moves it to the next input)', function () {
+ var addressEnteredEvent = spyOnEvent(this.$node, Smail.events.ui.recipients.entered);
+
+ var tabKeyPressEvent = $.Event('keydown', { which: 9});
+ spyOn(tabKeyPressEvent, 'preventDefault');
+
+ this.$node.val('');
+ this.$node.trigger(tabKeyPressEvent);
+
+ expect(tabKeyPressEvent.preventDefault).not.toHaveBeenCalled();
+ expect(addressEnteredEvent).not.toHaveBeenTriggeredOnAndWith(this, { name: 'to', address: ''});
+ });
+ });
+ });
+
+ describe('on keyup', function () {
+ it('triggers inputHasNoMail if input is empty', function () {
+ var inputHasNoMailEvent = spyOnEvent(document, Smail.events.ui.recipients.inputHasNoMail);
+ this.$node.val('');
+
+ this.$node.trigger('keyup');
+
+ expect(inputHasNoMailEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('triggers inputHasMail if input is not empty', function () {
+ var inputHasMailEvent = spyOnEvent(document, Smail.events.ui.recipients.inputHasMail);
+ this.$node.val('lalala');
+
+ this.$node.trigger('keyup');
+
+ expect(inputHasMailEvent).toHaveBeenTriggeredOn(document, { name: 'to' });
+ });
+ });
+
+});
diff --git a/web-ui/test/spec/mail_view/ui/recipients/recipients_iterator.spec.js b/web-ui/test/spec/mail_view/ui/recipients/recipients_iterator.spec.js
new file mode 100644
index 00000000..480ea256
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/recipients/recipients_iterator.spec.js
@@ -0,0 +1,101 @@
+/* global Smail */
+
+define(['mail_view/ui/recipients/recipients_iterator'], function (RecipientsIterator) {
+ 'use strict';
+
+ function createRecipient() {
+ return jasmine.createSpyObj('recipient', ['select', 'unselect', 'destroy']);
+ }
+
+ var recipientsIterator,
+ exitInput;
+
+ function createIterator(elements) {
+ return recipientsIterator = new RecipientsIterator({ elements: elements, exitInput: exitInput });
+ }
+
+ function resetMock(m) {
+ m.destroy.reset();m.select.reset();m.unselect.reset();
+ }
+
+ beforeEach(function () {
+ exitInput = $('<input>');
+ });
+
+ describe('moving left', function () {
+ it('unselects the current element and selects the element in the left if there is one', function () {
+ var elements = _.times(2, createRecipient);
+
+ recipientsIterator = createIterator(elements);
+ recipientsIterator.moveLeft();
+
+ expect(elements[0].select).toHaveBeenCalled();
+ expect(elements[1].unselect).toHaveBeenCalled();
+ });
+
+ it('doesnt do anything if there are no elements in the left', function () {
+ var elements = _.times(2, createRecipient);
+ recipientsIterator = createIterator(elements);
+ recipientsIterator.moveLeft();
+ _.each(elements, resetMock);
+
+ recipientsIterator.moveLeft();
+
+ expect(elements[0].select).not.toHaveBeenCalled();
+ expect(elements[0].unselect).not.toHaveBeenCalled();
+ expect(elements[1].select).not.toHaveBeenCalled();
+ expect(elements[1].unselect).not.toHaveBeenCalled();
+ });
+
+ });
+
+ describe('moving right', function () {
+ it('unselects the current element and selects the one in the right if there is one', function () {
+ var elements = _.times(2, createRecipient);
+ recipientsIterator = createIterator(elements);
+ recipientsIterator.moveLeft();
+ _.each(elements, resetMock);
+
+ recipientsIterator.moveRight();
+
+ expect(elements[0].unselect).toHaveBeenCalled();
+ expect(elements[1].select).toHaveBeenCalled();
+ });
+
+ it('unselects current element and focus on exit input if there are no elements on the right', function () {
+ var elements = _.times(2, createRecipient);
+ spyOn(exitInput, 'focus');
+
+ recipientsIterator = createIterator(elements);
+ recipientsIterator.moveRight();
+
+ expect(elements[1].unselect).toHaveBeenCalled();
+ expect(exitInput.focus).toHaveBeenCalled();
+ });
+ });
+
+ describe('delete current', function () {
+ it('selects what is left in the right after deleting the current element', function () {
+ var elements = _.times(2, createRecipient);
+ var toBeDeleted = elements[0];
+ recipientsIterator = createIterator(elements);
+ recipientsIterator.moveLeft();
+
+ recipientsIterator.deleteCurrent();
+
+ expect(toBeDeleted.destroy).toHaveBeenCalled();
+ expect(elements[0].select).toHaveBeenCalled();
+ });
+
+ it('focus on the input if there are no more elements', function () {
+ recipientsIterator = createIterator([createRecipient()]);
+ spyOn(exitInput, 'focus');
+
+ recipientsIterator.deleteCurrent();
+
+ expect(exitInput.focus).toHaveBeenCalled();
+ });
+ });
+
+
+}); \ No newline at end of file
diff --git a/web-ui/test/spec/mail_view/ui/reply_box.spec.js b/web-ui/test/spec/mail_view/ui/reply_box.spec.js
new file mode 100644
index 00000000..c6db4de3
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/reply_box.spec.js
@@ -0,0 +1,105 @@
+/*global jasmine */
+/*global Smail */
+
+describeComponent('mail_view/ui/reply_box', function () {
+ 'use strict';
+
+ var attrs, i18n;
+ beforeEach(function () {
+ attrs = {
+ mail: Smail.testData().parsedMail.simpleTextPlain
+ };
+ setupComponent(attrs);
+ i18n = require('views/i18n');
+ });
+
+ describe('reply compose box', function() {
+ it('should display subject of the reply', function() {
+ expect(this.component.select('subjectDisplay').text()).toBe(i18n('Re: ') + attrs.mail.header.subject);
+ });
+
+ it('should show recipient fields when clicking on recipient display', function() {
+ this.component.select('recipientsDisplay').click();
+
+ expect(this.component.select('recipientsFields')).not.toBeHidden();
+ expect(this.component.select('recipientsDisplay')).toBeHidden();
+ });
+
+ it('should show subject field when clicking on subject display', function() {
+ this.component.select('subjectDisplay').click();
+
+ expect(this.component.select('subjectInput')).not.toBeHidden();
+ expect(this.component.select('subjectDisplay')).toBeHidden();
+ });
+
+ it('should use the from field when Reply-To header does not exist', function() {
+ attrs.mail.header.reply_to = undefined;
+
+ setupComponent(attrs);
+
+ expect(this.component.attr.recipientValues['to']).toEqual([attrs.mail.header.from]);
+ });
+
+ it('should have a subject of Re: <original_message>', function() {
+ attrs.mail.header.subject = 'Very interesting';
+
+ setupComponent(attrs);
+
+ expect(this.component.select('subjectDisplay').text()).toEqual(i18n('Re: ')+ attrs.mail.header.subject);
+ });
+
+ it('should use set In-Reply-To header when Message-Id header is set', function() {
+ var mailSendEvent = spyOnEvent(document, Smail.events.mail.send);
+
+ attrs.mail.header.message_id = '12345';
+ setupComponent(attrs);
+
+ $(document).trigger(Smail.events.ui.mail.send);
+
+ expect(mailSendEvent).toHaveBeenTriggeredOn(document);
+ expect(mailSendEvent.mostRecentCall.data.header).toEqual(jasmine.objectContaining({
+ in_reply_to: '12345'
+ }));
+ });
+
+ it('keeps the List-Id header when it exists', function() {
+ var mailSendEvent = spyOnEvent(document, Smail.events.mail.send);
+ attrs.mail.header.list_id = 'somelist';
+
+ setupComponent(attrs);
+ $(document).trigger(Smail.events.ui.mail.send);
+
+ expect(mailSendEvent.mostRecentCall.data.header).toEqual(jasmine.objectContaining({
+ list_id: 'somelist'
+ }));
+ });
+
+ it('populates body text area with quote of email being replied', function() {
+ var viewHelper = require('helpers/view_helper');
+ spyOn(viewHelper, 'quoteMail').andReturn('quoted email');
+
+ setupComponent(attrs);
+
+ expect(viewHelper.quoteMail).toHaveBeenCalledWith(attrs.mail);
+ expect(this.component.select('bodyBox').val()).toBe('quoted email');
+ });
+
+ it('triggers mail when cancelling a reply', function () {
+ var mailSaveEvent = spyOnEvent(document, Smail.events.mail.save);
+
+ this.component.select('trashButton').click();
+
+ expect(mailSaveEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('reopens the mail after the reply is sent', function () {
+ var mailOpenEvent = spyOnEvent(document, Smail.events.ui.mail.open);
+
+ this.component.trigger(document, Smail.events.mail.sent);
+
+ expect(mailOpenEvent).toHaveBeenTriggeredOnAndWith(document, {
+ ident: this.component.attr.mail.ident
+ });
+ });
+ });
+});
diff --git a/web-ui/test/spec/mail_view/ui/reply_section.spec.js b/web-ui/test/spec/mail_view/ui/reply_section.spec.js
new file mode 100644
index 00000000..e5571e2c
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/reply_section.spec.js
@@ -0,0 +1,97 @@
+/*global jasmine */
+/*global Smail */
+
+describeComponent('mail_view/ui/reply_section', function () {
+ 'use strict';
+
+ beforeEach(function () {
+ setupComponent();
+ });
+
+ describe('clicking reply buttons', function() {
+ var mailWantEvent, expectEventData;
+
+ beforeEach(function () {
+ mailWantEvent = spyOnEvent(document, Smail.events.mail.want);
+ expectEventData = {
+ mail: '12345',
+ caller: this.component
+ };
+ this.component.attr.ident = '12345';
+ });
+
+ it('should ask for email when clicking on reply button', function() {
+ this.component.select('replyButton').click();
+
+ expect(mailWantEvent).toHaveBeenTriggeredOnAndWith(document, expectEventData);
+ });
+
+ it('should ask for email when clicking on replyAll button', function() {
+ this.component.select('replyAllButton').click();
+
+ expect(mailWantEvent).toHaveBeenTriggeredOnAndWith(document, expectEventData);
+ });
+ });
+
+ describe('creating reply box when getting email back', function() {
+ var mailData, ReplyBox, ForwardBox;
+
+ beforeEach(function () {
+ mailData = Smail.testData().mail;
+ ReplyBox = require('mail_view/ui/reply_box');
+ ForwardBox = require('mail_view/ui/forward_box');
+ spyOn(ReplyBox, 'attachTo');
+ spyOn(ForwardBox, 'attachTo');
+ });
+
+ it('for normal reply', function() {
+ this.component.attr.replyType = 'reply';
+ this.component.trigger(this.component, Smail.events.mail.here, { mail: mailData });
+
+ expect(ReplyBox.attachTo).toHaveBeenCalledWith(jasmine.any(Object), {
+ mail: mailData,
+ replyType: 'reply'
+ });
+ });
+
+ it('for reply to all', function() {
+ this.component.attr.replyType = 'replyall';
+ this.component.trigger(this.component, Smail.events.mail.here, { mail: mailData });
+
+ expect(ReplyBox.attachTo).toHaveBeenCalledWith(jasmine.any(Object), {
+ mail: mailData,
+ replyType: 'replyall'
+ });
+ });
+
+ it('creates a forward box', function() {
+ this.component.attr.replyType = 'forward';
+ this.component.trigger(this.component, Smail.events.mail.here, { mail: mailData });
+
+ expect(ForwardBox.attachTo).toHaveBeenCalledWith(jasmine.any(Object), {
+ mail: mailData
+ });
+ });
+ });
+
+ it('hides the buttons when clicked', function() {
+ this.component.attr.mailIdent = 12345;
+
+ this.component.select('replyButton').click();
+
+ expect(this.component.select('replyButton')).toBeHidden();
+ expect(this.component.select('replyAllButton')).toBeHidden();
+ expect(this.component.select('forwardButton')).toBeHidden();
+ });
+
+ it('shows the buttons when reply is cancelled', function() {
+ this.component.attr.mailIdent = 12345;
+ this.component.select('replyButton').click();
+
+ $(document).trigger(Smail.events.ui.composeBox.trashReply);
+
+ expect(this.component.select('replyButton')).not.toBeHidden();
+ expect(this.component.select('replyAllButton')).not.toBeHidden();
+ expect(this.component.select('forwardButton')).not.toBeHidden();
+ });
+});
diff --git a/web-ui/test/spec/mail_view/ui/send_button.spec.js b/web-ui/test/spec/mail_view/ui/send_button.spec.js
new file mode 100644
index 00000000..27bee0f3
--- /dev/null
+++ b/web-ui/test/spec/mail_view/ui/send_button.spec.js
@@ -0,0 +1,91 @@
+describeComponent('mail_view/ui/send_button', function () {
+
+ describe('send button', function () {
+ beforeEach(function () {
+ setupComponent('<button />');
+ });
+
+ describe('when it is disabled', function () {
+ beforeEach(function () {
+ this.$node.prop('disabled', true);
+ });
+
+ it('gets enabled in a inputHasMail event', function () {
+ $(document).trigger(Smail.events.ui.recipients.inputHasMail, { name: 'to' });
+
+ expect(this.$node).not.toBeDisabled();
+ });
+
+ it('gets enabled in a recipients:updated where there are new recipients', function () {
+ $(document).trigger(Smail.events.ui.recipients.updated, { newRecipients: ['a@b.c']});
+
+ expect(this.$node).not.toBeDisabled();
+ });
+ });
+
+ describe('multiple events', function () {
+ it('gets enabled and remains enabled when a inputHasMail is followed by a recipients:updated with NO new recipients', function () {
+ this.$node.prop('disabled', true);
+
+ $(document).trigger(Smail.events.ui.recipients.inputHasMail, { name: 'to' });
+ $(document).trigger(Smail.events.ui.recipients.updated, { newRecipients: [] });
+
+ expect(this.$node).not.toBeDisabled();
+ });
+
+ it('gets enabled and remains enabled when a recipients:updated with recipients is followed by a inputHasNoMail', function () {
+ this.$node.prop('disabled', true);
+
+ $(document).trigger(Smail.events.ui.recipients.updated, { newRecipients: ['a@b.c']});
+ $(document).trigger(Smail.events.ui.recipients.inputHasNoMail, { name: 'to' });
+
+ expect(this.$node).not.toBeDisabled();
+ });
+ });
+
+ describe('when it is enabled', function () {
+ beforeEach(function () {
+ this.$node.prop('disabled', false);
+ });
+
+ it('gets disabled in a inputHasNoMail', function () {
+ $(document).trigger(Smail.events.ui.recipients.inputHasNoMail, { name: 'to' });
+
+ expect(this.$node).toBeDisabled();
+ });
+
+ it('gets disabled in a recipients:updated without new recipients', function () {
+ $(document).trigger(Smail.events.ui.recipients.updated, { newRecipients: []});
+
+ expect(this.$node).toBeDisabled();
+ });
+ });
+
+ describe('on click', function () {
+
+ it ('asks for the recipients input to complete its current input', function () {
+ var doCompleteInputEvent = spyOnEvent(document, Smail.events.ui.recipients.doCompleteInput);
+
+ this.$node.click();
+
+ expect(doCompleteInputEvent).toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ describe('after clicking', function () {
+ beforeEach(function () {
+ this.$node.click();
+ });
+
+ it('waits for ui:mail:recipientsUpdated to happen 3 times in the mail then sends the mail then stops listening to ui:mail:recipientsUpdated', function () {
+ var sendMailEvent = spyOnEvent(document, Smail.events.ui.mail.send);
+ spyOn(this.component, 'off');
+
+ _.times(3, function () { $(document).trigger(Smail.events.ui.mail.recipientsUpdated) });;
+
+ expect(sendMailEvent).toHaveBeenTriggeredOn(document);
+ expect(this.component.off).toHaveBeenCalledWith(document, Smail.events.ui.mail.recipientsUpdated);
+ });
+ });
+ });
+});
diff --git a/web-ui/test/spec/mixins/with_mail_edit_base.spec.js b/web-ui/test/spec/mixins/with_mail_edit_base.spec.js
new file mode 100644
index 00000000..43d3b7cd
--- /dev/null
+++ b/web-ui/test/spec/mixins/with_mail_edit_base.spec.js
@@ -0,0 +1,94 @@
+/*global Smail */
+/*global jasmine */
+/*global runs */
+/*global waits */
+
+describeMixin('mixins/with_mail_edit_base', function () {
+ 'use strict';
+
+ beforeEach(function () {
+ setupComponent();
+ // Stubing mixing wrongly!!! 'deprecated' while waiting for draft component extraction
+ this.component.buildMail = function (tag) {
+ return { header: { to: ['a@smth.com'], from: 'b@smth.com', subject: 'Sbject' } };
+ };
+ });
+
+ describe('initialization', function() {
+ it('should enable send button when rendering with recipients', function() {
+ var enableSendButtonEvent = spyOnEvent(document, Smail.events.ui.sendbutton.enable);
+
+ this.component.render(function() {}, {
+ recipients: { to: ['foobar@mail.com'], cc: [] }
+ });
+
+ expect(enableSendButtonEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('should not enable send button when rendering without recipients', function() {
+ var enableSendButtonEvent = spyOnEvent(document, Smail.events.ui.sendbutton.enable);
+
+ this.component.render(function() {}, {
+ recipients: { to: [], cc: [] }
+ });
+
+ expect(enableSendButtonEvent).not.toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ describe('when the user is typing in subject or body', function() {
+ beforeEach(function () {
+ this.component.attr.saveDraftInterval = 10;
+ });
+
+ it('saves the draft after the save draft interval number of seconds', function() {
+ var saveDraftSpy = spyOnEvent(document, Smail.events.mail.saveDraft);
+ runs(function () {
+ this.component.monitorInput();
+ expect(saveDraftSpy).not.toHaveBeenTriggeredOn(document);
+ });
+ waits(10);
+ runs(function () {
+ expect(saveDraftSpy).toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ it('does not save if mail is sent before the save draft interval number of seconds', function() {
+ var saveDraftSpy = spyOnEvent(document, Smail.events.mail.saveDraft);
+ runs(function () {
+ this.component.monitorInput();
+ this.component.sendMail();
+ });
+ waits(10);
+ runs(function () {
+ expect(saveDraftSpy).not.toHaveBeenTriggeredOn(document);
+ });
+ });
+ });
+
+ describe('when a mail is sent', function () {
+ it('displays a message of mail sent', function () {
+ var spy = spyOnEvent(document, Smail.events.ui.userAlerts.displayMessage);
+ this.component.trigger(document, Smail.events.mail.sent);
+ expect(spy).toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ describe('when user asks to trash the mail', function() {
+ it('triggers mail delete for this mail', function() {
+ var spy = spyOnEvent(document, Smail.events.mail.save);
+ this.component.trashMail();
+ expect(spy).toHaveBeenTriggeredOn(document);
+ });
+ });
+
+ describe('when recipients are updated', function () {
+ it('triggers an event to let the send button know that the recipients in the mail are updated', function () {
+ var uiMailRecipientsUpdated = spyOnEvent(document, Smail.events.ui.mail.recipientsUpdated);
+
+ $(document).trigger(Smail.events.ui.recipients.updated, {recipientsName: 'to', newRecipients: ['fox@somewhere.com']});
+
+ expect(uiMailRecipientsUpdated).toHaveBeenTriggeredOn(document);
+ });
+ });
+});
diff --git a/web-ui/test/spec/page/pane_contract_expand.spec.js b/web-ui/test/spec/page/pane_contract_expand.spec.js
new file mode 100644
index 00000000..803f688c
--- /dev/null
+++ b/web-ui/test/spec/page/pane_contract_expand.spec.js
@@ -0,0 +1,67 @@
+/*global Smail */
+/*global afterEach */
+
+'use strict';
+
+describeComponent('page/pane_contract_expand', function () {
+
+ var fixture;
+
+ beforeEach(function () {
+ fixture = $('<div>')
+ .append($('<div>', { id: 'middle-pane-container' }))
+ .append($('<div>', { id: 'right-pane' }));
+
+ $('body').append(fixture);
+
+
+ });
+
+ afterEach(function () {
+ fixture.remove();
+ });
+
+ describe('after initialization', function () {
+ beforeEach(function () {
+ setupComponent(document);
+ });
+
+ it('contracts middle pane and expands right pane on mail open', function () {
+ $(document).trigger(Smail.events.ui.mail.open);
+
+ expect($('#middle-pane-container').attr('class')).toEqual(this.component.attr.MIDDLE_PANE_CONTRACT_CLASSES);
+ expect($('#right-pane').attr('class')).toEqual(this.component.attr.RIGHT_PANE_EXPAND_CLASSES);
+ });
+
+ it('contracts middle pane and expands right pane on open compose box', function () {
+ $(document).trigger(Smail.events.dispatchers.rightPane.openComposeBox);
+
+ expect($('#middle-pane-container').attr('class')).toEqual(this.component.attr.MIDDLE_PANE_CONTRACT_CLASSES);
+ expect($('#right-pane').attr('class')).toEqual(this.component.attr.RIGHT_PANE_EXPAND_CLASSES);
+ });
+
+ it('contracts middle pane and expands right pane on open draft', function () {
+ $(document).trigger(Smail.events.dispatchers.rightPane.openDraft);
+
+ expect($('#middle-pane-container').attr('class')).toEqual(this.component.attr.MIDDLE_PANE_CONTRACT_CLASSES);
+ expect($('#right-pane').attr('class')).toEqual(this.component.attr.RIGHT_PANE_EXPAND_CLASSES);
+ });
+
+ it('expands middle pane and contracts right pane on event on open no message selected pane', function () {
+ $(document).trigger(Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ expect($('#middle-pane-container').attr('class')).toEqual(this.component.attr.MIDDLE_PANE_EXPAND_CLASSES);
+ expect($('#right-pane').attr('class')).toEqual(this.component.attr.RIGHT_PANE_CONTRACT_CLASSES);
+ });
+ });
+
+ describe('on initialization', function () {
+ it('expands middle pane and contracts right pane', function () {
+ setupComponent(document);
+
+ expect($('#middle-pane-container').attr('class')).toEqual(this.component.attr.MIDDLE_PANE_EXPAND_CLASSES);
+ expect($('#right-pane').attr('class')).toEqual(this.component.attr.RIGHT_PANE_CONTRACT_CLASSES);
+ });
+ });
+
+});
diff --git a/web-ui/test/spec/page/router.spec.js b/web-ui/test/spec/page/router.spec.js
new file mode 100644
index 00000000..0b5b6b32
--- /dev/null
+++ b/web-ui/test/spec/page/router.spec.js
@@ -0,0 +1,70 @@
+/*global Smail */
+/*global jasmine */
+describeComponent('page/router', function () {
+ 'use strict';
+
+ var fakeHistory;
+
+ describe('on router:pushState coming from a tag selection', function () {
+ beforeEach(function () {
+ fakeHistory = jasmine.createSpyObj('history', ['pushState']);
+ setupComponent({history: fakeHistory});
+ });
+
+ it('pushes the state with the tag and the url', function () {
+ $(document).trigger(Smail.events.router.pushState, { tag: 'inbox'});
+
+ expect(fakeHistory.pushState).toHaveBeenCalledWith(jasmine.objectContaining({ tag: 'inbox' }), '', '/#/inbox');
+ });
+
+ it('pushes the state with mailIdent', function () {
+ $(document).trigger(Smail.events.router.pushState, { tag: 'inbox', mailIdent: 1});
+
+ expect(fakeHistory.pushState).toHaveBeenCalledWith(jasmine.objectContaining({ tag: 'inbox', mailIdent: 1 }), '', '/#/inbox/mail/1');
+ });
+
+ it('pushes the state with mailIdent even if mail ident is 0 (that happens for drafts)', function () {
+ $(document).trigger(Smail.events.router.pushState, { tag: 'inbox', mailIdent: 0});
+
+ expect(fakeHistory.pushState).toHaveBeenCalledWith(jasmine.objectContaining({ tag: 'inbox', mailIdent: 0 }), '', '/#/inbox/mail/0');
+ });
+
+ it('pushes the state with the displayNoMessage boolean forwarded from the event', function () {
+ $(document).trigger(Smail.events.router.pushState, { tag: 'inbox', mailIdent: 0});
+
+ expect(fakeHistory.pushState).toHaveBeenCalledWith(jasmine.objectContaining({ isDisplayNoMessageSelected: false}), '', '/#/inbox/mail/0');
+
+ $(document).trigger(Smail.events.router.pushState, { tag: 'inbox', mailIdent: 0, isDisplayNoMessageSelected: true});
+
+ expect(fakeHistory.pushState).toHaveBeenCalledWith(jasmine.objectContaining({ isDisplayNoMessageSelected: true}), '', '/#/inbox/mail/0');
+ });
+
+ it('when popping a state with no tag should select tag from url', function () {
+ var urlParams = require("page/router/url_params");
+ spyOn(urlParams, 'getTag').andReturn('tag');
+
+ var selectTagEvent = spyOnEvent(document, Smail.events.ui.tag.select);
+
+ this.component.smailPopState({ state: {tag: undefined} });
+
+ expect(selectTagEvent).toHaveBeenTriggeredOnAndWith(document, jasmine.objectContaining({ tag: "tag"}))
+ });
+
+ it('when popping a state triggers the displayNoMessage pane if required', function () {
+ var urlParams = require("page/router/url_params");
+ spyOn(urlParams, 'getTag').andReturn('tag');
+
+ var displayNoMessageEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelectedWithoutPushState);
+
+ this.component.smailPopState({ state: {tag: undefined, isDisplayNoMessageSelected: false} });
+
+ expect(displayNoMessageEvent).not.toHaveBeenTriggeredOn(document)
+
+ this.component.smailPopState({ state: {tag: undefined, isDisplayNoMessageSelected: true} });
+
+ expect(displayNoMessageEvent).toHaveBeenTriggeredOn(document)
+ });
+
+ });
+
+});
diff --git a/web-ui/test/spec/page/router/url_params.spec.js b/web-ui/test/spec/page/router/url_params.spec.js
new file mode 100644
index 00000000..a14ba1ba
--- /dev/null
+++ b/web-ui/test/spec/page/router/url_params.spec.js
@@ -0,0 +1,68 @@
+require(['page/router/url_params'], function (urlParams) {
+
+ describe('urlParams', function () {
+
+ beforeEach(function () {
+ //preventing the hash change to fire the onpopstate event in other components
+ //in this case in the router component
+ window.onpopstate = function () {};
+ });
+
+ afterEach(function () {
+ document.location.hash = '';
+ });
+
+ describe('getTag', function () {
+ it('returns inbox if there is no tag in the url hash', function () {
+ expect(urlParams.getTag()).toEqual('inbox');
+ });
+
+ it('returns the tag in the hash if there is one', function () {
+ document.location.hash = '/Drafts';
+
+ expect(urlParams.getTag()).toEqual('Drafts');
+ });
+
+ it('returns tag with slash', function () {
+ document.location.hash = '/Events/2011';
+
+ expect(urlParams.getTag()).toEqual('Events/2011');
+ });
+
+ it('returns tag even if there is an mail ident', function () {
+ document.location.hash = '/Events/2011/mail/1';
+
+ expect(urlParams.getTag()).toEqual('Events/2011');
+ });
+
+ it('returns the tag even if there is a trailing slash', function () {
+ document.location.hash = '/Events/';
+
+ expect(urlParams.getTag()).toEqual('Events');
+ });
+ });
+
+ describe('hasMailIdent', function () {
+ it('is true if hash has mailIdent', function () {
+ document.location.hash = '/inbox/mail/1';
+
+ expect(urlParams.hasMailIdent()).toBeTruthy();
+ });
+
+ it('is false if hash has no mail ident', function () {
+ document.location.hash = '/Drafts';
+
+ expect(urlParams.hasMailIdent()).toBeFalsy();
+ });
+ });
+
+ describe('getMailIdent', function () {
+ it('returns the mail ident that is in the hash', function () {
+ document.location.hash = '/inbox/mail/123';
+
+ expect(urlParams.getMailIdent()).toEqual('123');
+ });
+ });
+ });
+
+});
diff --git a/web-ui/test/spec/search/search_trigger.spec.js b/web-ui/test/spec/search/search_trigger.spec.js
new file mode 100644
index 00000000..aaeba3b1
--- /dev/null
+++ b/web-ui/test/spec/search/search_trigger.spec.js
@@ -0,0 +1,78 @@
+/*global jasmine */
+/*global Smail */
+
+describeComponent('search/search_trigger', function () {
+ 'use strict';
+ var self;
+
+ beforeEach(function () {
+ setupComponent();
+ self = this;
+ });
+
+ function submitSearch(queryString) {
+ self.component.select('input').val(queryString);
+ self.component.select('form').submit();
+ }
+
+ it('should trigger search when the submit occurs', function () {
+ var spy = spyOnEvent(document, Smail.events.search.perform);
+
+ submitSearch('tanana');
+ expect(spy).toHaveBeenTriggeredOnAndWith(document, { query: 'tanana' });
+ });
+
+ it('should select the "all" tag when submit occurs but should skip mail list refresh', function (){
+ var tagSelectEvent = spyOnEvent(document, Smail.events.ui.tag.select);
+
+ submitSearch('tanana');
+
+ expect(tagSelectEvent).toHaveBeenTriggeredOnAndWith(document, {
+ tag: 'all',
+ skipMailListRefresh: true
+ });
+ });
+
+ it('should select the "all" tag when an empty submit occurs and shoud refresh mail list', function() {
+ var tagSelectEvent = spyOnEvent(document, Smail.events.ui.tag.select);
+ var emptySearchEvent = spyOnEvent(document, Smail.events.search.empty);
+
+ submitSearch('');
+
+ expect(emptySearchEvent).toHaveBeenTriggeredOn(document);
+ expect(tagSelectEvent).toHaveBeenTriggeredOnAndWith(document, { tag: 'all'});
+
+ });
+
+ it('should clear input when selecting a new tag', function(){
+ submitSearch('tanana');
+ $(document).trigger(Smail.events.ui.tag.selected, { tag: 'inbox'});
+ expect(self.component.select('input').val()).toBe('');
+ });
+
+ it('should add place holder on input value after doing a search', function(){
+ submitSearch('teste');
+ expect(self.component.select('input').val()).toBe('Search results for: teste');
+ });
+
+ it('should remove place holder on input value when input is on focus', function(){
+ submitSearch('teste');
+ this.component.select('input').focus();
+ expect(self.component.select('input').val()).toBe('teste');
+ });
+
+ it('should remove place holder on input value when input is not on focus', function(){
+ submitSearch('teste');
+ this.component.select('input').focus();
+ this.component.select('input').blur();
+ expect(self.component.select('input').val()).toBe('Search results for: teste');
+ });
+
+ it('should not change input value when input is empty', function(){
+ submitSearch('');
+ this.component.select('input').focus();
+ expect(self.component.select('input').val()).toBe('');
+ });
+
+
+});
diff --git a/web-ui/test/spec/services/delete_service.spec.js b/web-ui/test/spec/services/delete_service.spec.js
new file mode 100644
index 00000000..3e098877
--- /dev/null
+++ b/web-ui/test/spec/services/delete_service.spec.js
@@ -0,0 +1,54 @@
+/*global jasmine */
+/*global Smail */
+
+describeComponent('services/delete_service', function () {
+ 'use strict';
+
+ var i18n;
+
+ beforeEach( function () {
+ setupComponent();
+ i18n = require('views/i18n');
+ });
+
+ var mailWithoutTrashTag = {
+ ident: 42,
+ isInTrash: function() { return false; },
+ tags: ['inbox', 'test']
+ };
+
+ var mailWithTrashTag = {
+ ident: 34,
+ isInTrash: function() { return true; },
+ tags: ['inbox', 'test', 'trash']
+ };
+
+ it('add Trash tag when deleting an email that does not have it', function () {
+ var mailDeleteEvent = spyOnEvent(document, Smail.events.mail.delete);
+ var openNoMessageSelectedEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ this.component.trigger(document, Smail.events.ui.mail.delete, {mail: mailWithoutTrashTag});
+
+ var expectedDeleteEventData = {
+ mail: mailWithoutTrashTag,
+ successMessage: i18n('Your message was moved to trash!')
+ };
+
+ expect(mailDeleteEvent).toHaveBeenTriggeredOnAndWith(document, expectedDeleteEventData);
+ });
+
+ it('removes permanently email that has Trash tag', function(){
+ var mailDeleteEvent = spyOnEvent(document, Smail.events.mail.delete);
+ var openNoMessageSelectedEvent = spyOnEvent(document, Smail.events.dispatchers.rightPane.openNoMessageSelected);
+
+ this.component.trigger(document, Smail.events.ui.mail.delete, {mail: mailWithTrashTag});
+
+ var expectedDeleteEventData = {
+ mail: mailWithTrashTag,
+ successMessage: i18n('Your message was permanently deleted!')
+ };
+
+ expect(mailDeleteEvent).toHaveBeenTriggeredOnAndWith(document, expectedDeleteEventData );
+ });
+
+});
diff --git a/web-ui/test/spec/services/mail_service.spec.js b/web-ui/test/spec/services/mail_service.spec.js
new file mode 100644
index 00000000..31e130fa
--- /dev/null
+++ b/web-ui/test/spec/services/mail_service.spec.js
@@ -0,0 +1,307 @@
+/*global jasmine */
+/*global Smail */
+'use strict';
+
+describeComponent('services/mail_service', function () {
+
+ var email1, i18n;
+
+ beforeEach( function () {
+ setupComponent();
+ email1 = Smail.testData().parsedMail.simpleTextPlain;
+ i18n = require('views/i18n');
+ } );
+
+ it('marks the desired message as read', function () {
+ var readRequest = spyOn($, 'ajax').andReturn({});
+
+ this.component.trigger(Smail.events.mail.read, {ident: 1});
+
+ expect(readRequest.mostRecentCall.args[0]).toEqual('/mail/1/read');
+ });
+
+ describe('when marks many emails as read', function () {
+ var readRequest, checkedMails, uncheckedEmailsEvent, setCheckAllEvent, doneMarkAsRead;
+
+ beforeEach(function () {
+ readRequest = spyOn($, 'ajax').andReturn({done: function(f) { doneMarkAsRead = f; return {fail: function() {}};}});
+ uncheckedEmailsEvent = spyOnEvent(document, Smail.events.ui.mail.unchecked);
+ setCheckAllEvent = spyOnEvent(document, Smail.events.ui.mails.hasMailsChecked);
+ spyOn(this.component, 'refreshResults');
+
+ checkedMails = {
+ 1: {ident: 1},
+ 2: {ident: 2}
+ };
+
+ this.component.trigger(Smail.events.mail.read, {checkedMails: checkedMails});
+ });
+
+ it('makes the correct request to the backend', function () {
+ expect(readRequest.mostRecentCall.args[0]).toEqual('/mails/read');
+ expect(readRequest.mostRecentCall.args[1].data).toEqual({idents: '[1,2]'});
+ });
+
+ it('will trigger that a message has been deleted when it is done deleting', function() {
+ doneMarkAsRead({mails: checkedMails});
+ expect(this.component.refreshResults).toHaveBeenCalled();
+ });
+
+ it('unchecks read emails', function () {
+ doneMarkAsRead({mails: checkedMails});
+ expect(uncheckedEmailsEvent).toHaveBeenTriggeredOnAndWith(document, {mails: checkedMails});
+ });
+
+ it('clears the check all checkbox', function () {
+ doneMarkAsRead({mails: checkedMails});
+ expect(setCheckAllEvent).toHaveBeenTriggeredOnAndWith(document, false);
+ });
+ });
+
+ it('fetches a single email', function () {
+ var me = {};
+ var spyAjax = spyOn($, 'ajax').andReturn({done: function(f) { f(email1); return {fail: function() {}};}});
+ var mailHereEvent = spyOnEvent(me, Smail.events.mail.here);
+
+ this.component.trigger(Smail.events.mail.want, { caller: me, mail: email1.ident });
+
+ expect(mailHereEvent).toHaveBeenTriggeredOn(me);
+ expect(spyAjax.mostRecentCall.args[0]).toEqual('/mail/' + email1.ident);
+ });
+
+ it('answers mail:notFound if mail returned from server is null', function () {
+ var me = {};
+ var spyAjax = spyOn($, 'ajax').andReturn({done: function(f) { f(null); return {fail: function() {}};}});
+ var mailNotFound = spyOnEvent(me, Smail.events.mail.notFound);
+
+ this.component.trigger(Smail.events.mail.want, { caller: me, mail: email1.ident });
+
+ expect(mailNotFound).toHaveBeenTriggeredOn(me);
+ });
+
+ it('updates the tags of the desired message', function () {
+ spyOn(this.component, 'refreshResults');
+ var spyAjax = spyOn($, 'ajax').andReturn({done: function(f) { f(); return {fail: function() {}};}});
+
+ var spyEvent = spyOnEvent(document, Smail.events.mail.tags.updated);
+ var component = jasmine.createSpyObj('component',['successUpdateTags']);
+ spyOn(this.component, 'fetchMail');
+
+ this.component.trigger(Smail.events.mail.tags.update, { ident: email1.ident, tags: email1.tags });
+
+ expect(spyEvent).toHaveBeenTriggeredOn(document);
+ expect(spyAjax.calls[0].args[0]).toEqual('/mail/1/tags');
+ expect(spyAjax.calls[0].args[1].data).toEqual(JSON.stringify({ newtags: email1.tags } ));
+ expect(this.component.refreshResults).toHaveBeenCalled();
+ });
+
+ it('triggers an error message when it can\'t update the tags', function () {
+ var spyAjax = spyOn($, 'ajax').andReturn({done: function() { return {fail: function(f) {f();}};}});
+
+ var spyEvent = spyOnEvent(document, Smail.events.ui.userAlerts.displayMessage);
+ var component = jasmine.createSpyObj('component',['failureUpdateTags']);
+
+ this.component.trigger(Smail.events.mail.tags.update, { ident: email1.ident, tags: email1.tags });
+
+ expect(spyEvent).toHaveBeenTriggeredOn(document);
+ expect(spyAjax.mostRecentCall.args[0]).toEqual('/mail/1/tags');
+ expect(spyAjax.mostRecentCall.args[1].data).toEqual(JSON.stringify({ newtags: email1.tags } ));
+ });
+
+ it('will try to delete a message when requested to', function() {
+ var spyAjax = spyOn($, 'ajax').andReturn({done: function() { return {fail: function(f) {}};}});
+ this.component.trigger(Smail.events.mail.delete, {mail: {ident: '43'}});
+ expect(spyAjax).toHaveBeenCalled();
+ expect(spyAjax.mostRecentCall.args[0]).toEqual('/mail/43');
+ expect(spyAjax.mostRecentCall.args[1].type).toEqual('DELETE');
+ });
+
+ describe('when successfuly deletes an email', function () {
+ var displayMessageEvent, uncheckedEmailsEvent, setCheckAllEvent, mailsDeletedEvent;
+
+ beforeEach(function () {
+ displayMessageEvent = spyOnEvent(document, Smail.events.ui.userAlerts.displayMessage);
+ uncheckedEmailsEvent = spyOnEvent(document, Smail.events.ui.mail.unchecked);
+ setCheckAllEvent = spyOnEvent(document, Smail.events.ui.mails.hasMailsChecked);
+ mailsDeletedEvent = spyOnEvent(document, Smail.events.mail.deleted);
+ spyOn(this.component, 'refreshResults');
+
+ this.component.triggerDeleted({
+ successMessage: 'A success message',
+ mails: {1: 'email 1', 2: 'email 2'}
+ })();
+ });
+
+ it('will trigger that a message has been deleted when it is done deleting', function() {
+ expect(this.component.refreshResults).toHaveBeenCalled();
+ });
+
+ it('displays a success message', function () {
+ expect(displayMessageEvent).toHaveBeenTriggeredOnAndWith(document, {message: 'A success message'});
+ });
+
+ it('unchecks deleted emails', function () {
+ expect(uncheckedEmailsEvent).toHaveBeenTriggeredOnAndWith(document, { mails: {1: 'email 1', 2: 'email 2'} });
+ });
+
+ it('tells about deleted emails', function () {
+ expect(mailsDeletedEvent).toHaveBeenTriggeredOnAndWith(document, { mails: {1: 'email 1', 2: 'email 2'} });
+ });
+
+ it('clears the check all checkbox', function () {
+ expect(setCheckAllEvent).toHaveBeenTriggeredOnAndWith(document, false);
+ });
+ });
+
+ it('will trigger an error message when a message cannot be deleted', function() {
+ spyOn($, 'ajax').andReturn({done: function() { return {fail: function(f) { f(); }};}});
+ var spyEvent = spyOnEvent(document, Smail.events.ui.userAlerts.displayMessage);
+
+ this.component.trigger(Smail.events.mail.delete, {mail: {ident: '43'}});
+
+ expect(spyEvent).toHaveBeenTriggeredOnAndWith(document, {message: i18n('Could not delete email')} );
+ });
+
+ it('triggers mails:available with received mails and keeps that tag as the current tag', function() {
+ var g;
+ var eventSpy = spyOnEvent(document, Smail.events.mails.available);
+
+ spyOn($, 'ajax').andReturn({done: function(f) { g = f; return {fail: function(){}};}});
+ this.component.trigger(Smail.events.ui.mails.fetchByTag, {tag: 'inbox'});
+
+ g({stats: {}, mails: [email1]});
+ expect(eventSpy.mostRecentCall.data.stats).toEqual({});
+ expect(eventSpy.mostRecentCall.data.tag).toEqual('inbox');
+ expect(this.component.attr.currentTag).toEqual('inbox');
+ });
+
+ it('wraps the tag in quotes before fetching by tag (to support tags with spaces)', function () {
+ spyOn($, 'ajax').andReturn({done: function(f) { return {fail: function(){}};}});
+
+ this.component.trigger(Smail.events.ui.mails.fetchByTag, {tag: 'new tag'});
+
+ expect($.ajax.mostRecentCall.args[0]).toContain(encodeURI('tag:"new tag"'));
+ });
+
+ it('sends the previous tag when mails:refresh is called without a tag (this happens when the refresher calls it)', function () {
+ var g;
+ var eventSpy = spyOnEvent(document, Smail.events.mails.availableForRefresh);
+ this.component.attr.currentTag = 'sent';
+
+ spyOn($, 'ajax').andReturn({done: function(f) { g = f; return {fail: function(){}};}});
+ this.component.trigger(Smail.events.ui.mails.refresh);
+
+ g({stats: {}, mails: [email1]});
+ expect(eventSpy.mostRecentCall.data.tag).toEqual('sent');
+ expect(eventSpy.mostRecentCall.data.stats).toEqual({});
+ });
+
+ describe('pagination', function() {
+ var pageChangedEvent;
+ var g;
+
+ beforeEach(function () {
+ pageChangedEvent = spyOnEvent(document, Smail.events.ui.page.changed);
+ spyOn($, 'ajax').andReturn({done: function(f) {
+ g = f;
+ return {fail: function(){}};
+ }});
+ spyOn(this.component, 'fetchMail').andCallThrough();
+ });
+
+ it('changes to the previous page and refetch email when ui:page:previous is fired', function() {
+ this.component.attr.currentPage = 1;
+
+ this.component.trigger(Smail.events.ui.page.previous);
+
+ expect(this.component.fetchMail).toHaveBeenCalled();
+ expect(this.component.attr.currentPage).toEqual(0);
+ });
+
+ it('won\'t change the page if it was already at the first page and trying to go to previous', function() {
+ this.component.attr.currentPage = 0;
+
+ this.component.trigger(Smail.events.ui.page.previous);
+
+ expect(this.component.fetchMail).not.toHaveBeenCalled();
+ expect(this.component.attr.currentPage).toEqual(0);
+ });
+
+ it('changes to the next page and refetch email when ui:page:next is fired', function() {
+ this.component.attr.numPages = 10;
+ this.component.attr.currentPage = 1;
+
+ this.component.trigger(Smail.events.ui.page.next);
+
+ expect(this.component.fetchMail).toHaveBeenCalled();
+ expect(this.component.attr.currentPage).toEqual(2);
+ });
+
+ it('won\'t change the page if it was already at the first page and trying to go to previous', function() {
+ this.component.attr.numPages = 10;
+ this.component.attr.currentPage = 9;
+
+ this.component.trigger(Smail.events.ui.page.next);
+
+ expect(this.component.fetchMail).not.toHaveBeenCalled();
+ expect(this.component.attr.currentPage).toEqual(9);
+ });
+
+
+ it('triggers pageChanged event when going to next page', function() {
+ this.component.attr.numPages = 10;
+ this.component.trigger(Smail.events.ui.page.next);
+
+ expect(pageChangedEvent).toHaveBeenTriggeredOnAndWith(document, {currentPage: 1, numPages: 10});
+ });
+
+ it('triggers pageChanged event when going to previous page', function() {
+ this.component.attr.numPages = 10;
+ this.component.attr.currentPage = 1;
+ this.component.trigger(Smail.events.ui.page.previous);
+
+ expect(pageChangedEvent).toHaveBeenTriggeredOnAndWith(document, {currentPage: 0, numPages: 10});
+ });
+
+ it('resets currentPage when fetching mails by tag', function() {
+ this.component.attr.numPages = 10;
+ this.component.attr.currentPage = 999;
+ this.component.trigger(Smail.events.ui.mails.fetchByTag, {tag: 'inbox'});
+
+ expect(this.component.attr.currentPage).toEqual(0);
+ expect(pageChangedEvent).toHaveBeenTriggeredOnAndWith(document, {currentPage: 0, numPages: 10});
+ });
+
+ describe('total page numbers', function() {
+ var mailSetData = {
+ tag: 'inbox',
+ stats: { },
+ mails: [],
+ timing: {}
+ };
+
+ it('should have 5 pages with a 100 results and w 20', function() {
+ mailSetData.stats.total = 100;
+ this.component.attr.w = 20;
+ this.component.attr.numPages = 0;
+
+ this.component.trigger(Smail.events.ui.mails.fetchByTag, {tag: 'another tag'});
+
+ g(mailSetData);
+ expect(this.component.attr.numPages).toBe(5);
+ });
+
+ it('should have 6 pages with a 101 results and w 20', function() {
+ mailSetData.stats.total = 101;
+ this.component.attr.w = 20;
+ this.component.attr.numPages = 0;
+
+ this.component.trigger(Smail.events.ui.mails.fetchByTag, {tag: 'another tag'});
+
+ g(mailSetData);
+ expect(this.component.attr.numPages).toBe(6);
+ });
+ });
+
+ });
+});
diff --git a/web-ui/test/spec/services/model/mail.spec.js b/web-ui/test/spec/services/model/mail.spec.js
new file mode 100644
index 00000000..5bdad88b
--- /dev/null
+++ b/web-ui/test/spec/services/model/mail.spec.js
@@ -0,0 +1,116 @@
+/*global Smail */
+
+require(['services/model/mail'], function (Mail) {
+ var testData;
+
+ describe('services/model/mail', function () {
+ describe('reply addresses', function () {
+ it('returns the "to" and "cc" addresses if the mail was sent', function () {
+ var mail = Mail.create({
+ header: { to: ['a@b.c', 'e@f.g'], cc: ['x@x.x'] },
+ tags: ['sent']
+ });
+
+ var addresses = mail.replyToAddress();
+
+ expect(addresses).toEqual({ to: ['a@b.c', 'e@f.g'], cc: ['x@x.x']});
+ });
+ });
+
+ describe('parsing', function () {
+ describe('a single email', function () {
+ var sentMail, draftMail, recievedMail, recievedMailWithCC;
+ beforeEach(function () {
+ sentMail = Mail.create(Smail.testData().rawMail.sent);
+ draftMail = Mail.create(Smail.testData().rawMail.draft);
+ recievedMail = Mail.create(Smail.testData().rawMail.recieved);
+ recievedMailWithCC = Mail.create(Smail.testData().rawMail.recievedWithCC);
+ });
+
+ it('correctly identifies a sent mail', function () {
+ expect(sentMail.isSentMail()).toBe(true);
+ });
+
+ it('correctly identifies a draft mail', function () {
+ expect(draftMail.isDraftMail()).toBe(true);
+ });
+
+ it('correctly identifies a recieved mail', function () {
+ expect(recievedMail.isSentMail()).toBe(false);
+ expect(recievedMail.isDraftMail()).toBe(false);
+ });
+
+ it('reply to of a sent mail should be original recipient', function () {
+ expect(sentMail.replyToAddress()).toEqual({to: ['mariane_dach@davis.info'], cc: ['duda@la.lu']});
+ });
+
+ it('reply to of a mail should be the reply_to field if existent', function () {
+ expect(recievedMail.replyToAddress()).toEqual({to: ['afton_braun@botsford.biz'], cc: [] });
+ });
+
+ it('reply to of a mail should be the from field if no reply_to present', function () {
+ expect(recievedMailWithCC.replyToAddress()).toEqual({to: ['cleve_jaskolski@schimmelhirthe.net'], cc: []});
+ });
+
+ it('reply to all should include all email addresses in the header', function () {
+ expect(recievedMailWithCC.replyToAllAddress()).toEqual({
+ to: ['cleve_jaskolski@schimmelhirthe.net', 'stanford@sipes.com'],
+ cc: ['mariane_dach@davis.info']
+ });
+ });
+ });
+
+ describe('multipart email', function () {
+ var parsedMultipartMail;
+
+ beforeEach(function () {
+ parsedMultipartMail = Mail.create(Smail.testData().rawMail.multipart);
+ });
+
+ it('parses the mail as multipart/alternative', function () {
+ expect(parsedMultipartMail.isMailMultipartAlternative()).toBe(true);
+ });
+
+ it('lists the correct available content-type of the parts', function () {
+ expect(parsedMultipartMail.availableBodyPartsContentType()).toEqual(['text/plain;', 'text/html;']);
+ });
+
+ it('gets the list of parts', function () {
+ var expectedParts = [
+ {
+ headers: { 'Content-Type': 'text/plain;' },
+ body: 'Hello everyone!\n'
+ },
+ {
+ headers: {
+ 'Content-Type': 'text/html;',
+ 'Content-Transfer-Encoding': 'quoted-printable'
+ },
+ body: '<p><b>Hello everyone!</b></p>\n'
+ }
+ ];
+
+ expect(parsedMultipartMail.getMailMultiParts()).toEqual(expectedParts);
+ });
+
+ it('gets the text/plain body by the content-type', function () {
+ expect(parsedMultipartMail.getMailPartByContentType('text/plain;')).toEqual(
+ {
+ headers: { 'Content-Type': 'text/plain;' },
+ body: 'Hello everyone!\n'
+ });
+ });
+
+ it('parses the content type of a text/html body', function () {
+ expect(parsedMultipartMail.getMailPartByContentType('text/html;')).toEqual({
+ headers: {
+ 'Content-Type': 'text/html;',
+ 'Content-Transfer-Encoding': 'quoted-printable'
+ },
+ body: '<p><b>Hello everyone!</b></p>\n'
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/web-ui/test/spec/tags/data/tags.spec.js b/web-ui/test/spec/tags/data/tags.spec.js
new file mode 100644
index 00000000..8e7d33c3
--- /dev/null
+++ b/web-ui/test/spec/tags/data/tags.spec.js
@@ -0,0 +1,39 @@
+describeComponent('tags/data/tags', function () {
+ 'use strict';
+
+ beforeEach(function () {
+ setupComponent();
+ });
+
+ it('asks the server for tags when receiving the tags:want event', function() {
+ spyOn($, 'ajax').andReturn({done: function() {}});
+
+ this.component.trigger(Smail.events.tags.want);
+
+ expect($.ajax.mostRecentCall.args[0]).toEqual('/tags');
+ });
+
+ it('triggers an event on the initial sender after receiving tags', function() {
+ var f;
+ spyOn($, 'ajax').andReturn({done: function(d) { f = d; }});
+ var me = {};
+ var eventSpy = spyOnEvent(me, Smail.events.tags.received);
+
+ this.component.trigger(Smail.events.tags.want, { caller: me});
+
+ f(['foo', 'bar', 'quux/bar']);
+ expect(eventSpy).toHaveBeenTriggeredOn(me);
+ });
+
+ it('triggers an event containing the returned tags', function() {
+ var f;
+ spyOn($, 'ajax').andReturn({done: function(d) { f = d; }});
+ var me = {};
+ var eventSpy = spyOnEvent(me, Smail.events.tags.received);
+ this.component.trigger(Smail.events.tags.want, { caller: me });
+ var tags = ['foo', 'bar', 'quux/bar'];
+ f(tags);
+ tags.push(this.component.all);
+ expect(eventSpy.mostRecentCall.data).toEqual({tags: tags});
+ });
+});
diff --git a/web-ui/test/spec/tags/ui/tag.spec.js b/web-ui/test/spec/tags/ui/tag.spec.js
new file mode 100644
index 00000000..12f16330
--- /dev/null
+++ b/web-ui/test/spec/tags/ui/tag.spec.js
@@ -0,0 +1,151 @@
+/*global Smail */
+/*global _ */
+
+describeComponent('tags/ui/tag', function () {
+ 'use strict';
+
+ describe('inbox tag', function() {
+ beforeEach(function () {
+ setupComponent('<li></li>', {
+ tag: {
+ name: 'inbox',
+ ident: '1',
+ counts: {
+ total: 100,
+ read: 0
+ }
+ }
+ });
+ });
+
+ it('selects the tag on click', function () {
+ var tagSelectEvent = spyOnEvent(document, Smail.events.ui.tag.select);
+ var cleanSelectedEvent = spyOnEvent(document, Smail.events.ui.mails.cleanSelected);
+
+ this.component.$node.click();
+
+ expect(this.component.attr.selected).toBeTruthy();
+ expect(this.$node.attr('class')).toMatch('selected');
+ expect(tagSelectEvent).toHaveBeenTriggeredOnAndWith(document, { tag: 'inbox' });
+ expect(cleanSelectedEvent).toHaveBeenTriggeredOn(document);
+ });
+
+ it('should remove selected class when selecting a different tag', function () {
+ this.$node.click();
+
+ $(document).trigger(Smail.events.ui.tag.select, {tag: 'drafts'});
+
+ expect(this.$node).not.toHaveClass('selected');
+ });
+
+ it('triggers tag selected on tag select', function () {
+ var tagSelectedEvent = spyOnEvent(document, Smail.events.ui.tag.select);
+
+ $(document).trigger(Smail.events.ui.tag.select, { tag: 'drafts'});
+
+ expect(tagSelectedEvent).toHaveBeenTriggeredOnAndWith(document, { tag: 'drafts'});
+ });
+
+ it('increases the read count when there is an email read and the email has that tag', function () {
+ $(document).trigger(Smail.events.mail.read, { tags: ['inbox'] });
+
+ expect(this.component.attr.tag.counts.read).toEqual(1);
+ expect(this.$node.html()).toMatch('<span class="unread-count">99</span>');
+ });
+
+ it('doesnt increase the read count when the read email doesnt have the tag', function () {
+ $(document).trigger(Smail.events.mail.read, { tags: ['amazing']});
+
+ expect(this.component.attr.tag.counts.read).toEqual(0);
+ expect(this.$node.html()).not.toMatch('<span class="unread-count">99</span>');
+ });
+
+ it('doesnt display the unread count when there are no unreads', function () {
+ this.component.attr.tag.counts.read = 100;
+ $(document).trigger(Smail.events.mail.read, { tags: ['inbox']});
+ expect(this.$node.html()).not.toMatch('"unread-count"');
+ });
+ });
+
+ describe('drafts tag', function () {
+ var containerFordrafts;
+ beforeEach(function () {
+ setupComponent('<li></li>', {
+ tag: {
+ name: 'drafts',
+ ident: '42',
+ counts: {
+ total: 100,
+ read: 50
+ }
+ }
+ });
+ });
+
+ it('shows the total count instead of the unread count', function () {
+ $(document).trigger(Smail.events.mail.read, { tags: ['drafts']});
+ expect(this.$node.html()).toMatch('<span class="total-count">100</span>');
+ expect(this.$node.html()).not.toMatch('"unread-count"');
+ });
+ });
+
+ describe('all tag', function(){
+ beforeEach(function () {
+ setupComponent('<li></li>', {
+ tag: {
+ name: 'all',
+ ident: '45',
+ counts: {
+ total: 100,
+ read: 50
+ }
+ }
+ });
+ });
+
+ it('adds searching class when user is doing a search', function(){
+ $(document).trigger(Smail.events.search.perform, {});
+ expect(this.$node.attr('class')).toMatch('searching');
+ });
+
+ it('removes searching class when user searches for empty string', function(){
+ $(document).trigger(Smail.events.search.perform, {});
+ $(document).trigger(Smail.events.search.empty);
+ expect(this.$node.attr('class')).not.toMatch('searching');
+ });
+
+ it('removes searching class when user clicks in any tag', function(){
+ $(document).trigger(Smail.events.search.perform, {});
+ this.$node.click();
+ expect(this.$node.attr('class')).not.toMatch('searching');
+ });
+
+ });
+
+ _.each(['sent', 'trash'], function(tag_name) {
+ describe(tag_name + ' tag', function() {
+ beforeEach(function () {
+ setupComponent('<li></li>', {
+ tag: {
+ name: tag_name,
+ ident: '42',
+ counts: {
+ total: 100,
+ read: 50
+ }
+ }
+ });
+ });
+
+ it('doesn\'t display unread count for special folder', function () {
+ $(document).trigger(Smail.events.mail.read, { tags: [tag_name]});
+ expect(this.$node.html()).not.toMatch('unread-count');
+ });
+
+ it('doesn\'t display read count for special folder', function () {
+ $(document).trigger(Smail.events.mail.read, { tags: [tag_name]});
+ expect(this.$node.html()).not.toMatch('total-count');
+ });
+ });
+ });
+});
diff --git a/web-ui/test/spec/tags/ui/tag_list.spec.js b/web-ui/test/spec/tags/ui/tag_list.spec.js
new file mode 100644
index 00000000..af3ddd3a
--- /dev/null
+++ b/web-ui/test/spec/tags/ui/tag_list.spec.js
@@ -0,0 +1,75 @@
+describeComponent('tags/ui/tag_list', function () {
+ 'use strict';
+
+ var tag = function(name, ident, def) {
+ def = def || false;
+ return {name: name, counts: {read: 0, total: 0, replied: 0, starred: 0}, ident: ident, default: def};
+ };
+
+
+ describe('post initialization', function() {
+ beforeEach(function () {
+ setupComponent();
+ });
+
+ it('should render tags when tagsList:load is received', function() {
+ this.component.attr.default = false;
+ var tagList = [tag('tag1', 1), tag('tag2', 2), tag('tag3', 3)];
+
+ $(document).trigger(Smail.events.ui.tagList.load, {tags: tagList});
+
+ var items = _.map(this.$node.find('li'), function(el) {
+ return $(el).attr('id');
+ });
+
+ expect(items).toEqual(['tag-1', 'tag-2', 'tag-3']);
+ });
+
+ it('should render the default tags when tagsList:load is received and default attribute is true', function() {
+ var tagList = [tag('tag1', 1, false), tag('tag2', 2, true), tag('tag3', 3, true)];
+
+ $(document).trigger(Smail.events.ui.tagList.load, {tags: tagList});
+
+ var items = _.map(this.component.select('defaultTagList').find('li'), function(el) {
+ return $(el).attr('id');
+ });
+
+ expect(items).toEqual(['tag-2', 'tag-3']);
+ });
+
+ it('should render the custom tags when tagsList:load is received and default attribute is false', function() {
+ var tagList = [tag('tag1', 1, false), tag('tag2', 2, true), tag('tag3', 3, true)];
+
+ $(document).trigger(Smail.events.ui.tagList.load, {tags: tagList});
+
+ var items = _.map(this.component.select('customTagList').find('li'), function(el) {
+ return $(el).attr('id');
+ });
+
+ expect(items).toEqual(['tag-1']);
+ });
+
+ it('should trigger event to tell that tags were loaded sending the current tag', function () {
+ this.component.attr.currentTag = 'Drafts';
+ var tagsLoadedEvent = spyOnEvent(document, Smail.events.ui.tags.loaded);
+
+ $(document).trigger(Smail.events.ui.tagList.load, {tags: [] });
+
+ expect(tagsLoadedEvent).toHaveBeenTriggeredOnAndWith(document, { tag: 'Drafts'});
+ });
+
+ it('should send tag as undefined when tags are loaded and no tag was selected yet', function () {
+ var tagsLoadedEvent = spyOnEvent(document, Smail.events.ui.tags.loaded);
+
+ $(document).trigger(Smail.events.ui.tagList.load, {tags: [] });
+
+ expect(tagsLoadedEvent).toHaveBeenTriggeredOnAndWith(document, { tag: undefined });
+ });
+
+ it('should save the current tag when a tag is selected', function () {
+ $(document).trigger(Smail.events.ui.tag.selected, { tag: 'amazing'});
+
+ expect(this.component.attr.currentTag).toEqual('amazing');
+ });
+ });
+});
diff --git a/web-ui/test/spec/tags/ui/tag_shortcut.spec.js b/web-ui/test/spec/tags/ui/tag_shortcut.spec.js
new file mode 100644
index 00000000..7df35631
--- /dev/null
+++ b/web-ui/test/spec/tags/ui/tag_shortcut.spec.js
@@ -0,0 +1,35 @@
+describeComponent("tags/ui/tag_shortcut", function () {
+
+ var parent, shortcut, component, TagShortcut;
+
+ beforeEach(function () {
+ TagShortcut = require('tags/ui/tag_shortcut');
+
+ component = jasmine.createSpyObj('tagComponent', ['triggerSelect']);
+ parent = $("<ul>");
+ shortcut = TagShortcut.appendedTo(parent, { linkTo: { name: 'inbox', counts: { total: 15 }}, trigger: component });
+ });
+
+ it('renders the shortcut inside the parent', function () {
+ expect(parent.html()).toMatch('<a title="inbox">');
+ expect(parent.html()).toMatch('<i class="fa fa-inbox"></i>');
+ expect(parent.html()).toMatch('<div class="shortcut-label">inbox</div>');
+ });
+
+ it('selects and unselect on tag.select', function () {
+ $(document).trigger(Smail.events.ui.tag.select, { tag: 'inbox'});
+
+ expect(shortcut.$node).toHaveClass("selected");
+
+ $(document).trigger(Smail.events.ui.tag.select, { tag: 'sent'});
+
+ expect(shortcut.$node).not.toHaveClass("selected");
+ });
+
+ it('delegates the click to linked tag', function (){
+ shortcut.$node.click();
+
+ expect(component.triggerSelect).toHaveBeenCalled();
+ });
+
+});
diff --git a/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js b/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js
new file mode 100644
index 00000000..f013b744
--- /dev/null
+++ b/web-ui/test/spec/user_alerts/ui/user_alerts.spec.js
@@ -0,0 +1,23 @@
+describeComponent('user_alerts/ui/user_alerts', function () {
+ 'use strict';
+
+ beforeEach(function () {
+ setupComponent('<div id="userAlerts"></div>', { dismissTimeout: 100 });
+ });
+
+ it('should render message when ui:user_alerts:displayMessage is triggered', function () {
+ this.component.trigger(Smail.events.ui.userAlerts.displayMessage, { message: 'a message' });
+
+ expect(this.component.$node.html()).toMatch('a message');
+ });
+
+ it('should be emptied and hidden when hide is called', function() {
+ expect(this.$node).not.toBeHidden();
+ this.component.hide()
+ expect(this.$node).toBeHidden();
+ expect(this.$node.html()).toEqual('')
+ });
+
+
+
+});
diff --git a/web-ui/test/test-main.js b/web-ui/test/test-main.js
new file mode 100644
index 00000000..e07d292c
--- /dev/null
+++ b/web-ui/test/test-main.js
@@ -0,0 +1,57 @@
+'use strict';
+
+var tests = Object.keys(window.__karma__.files).filter(function (file) {
+ return (/\.spec\.js$/.test(file));
+});
+
+requirejs.config({
+
+ baseUrl: '/base',
+
+ paths: {
+ 'page': 'app/js/page',
+ 'js': 'app/js',
+ 'lib': 'app/js/lib',
+ 'hbs': 'app/js/generated/hbs',
+ 'flight': 'app/bower_components/flight',
+ 'views': 'app/js/views',
+ 'helpers': 'app/js/helpers',
+ 'tags': 'app/js/tags',
+ 'mail_list': 'app/js/mail_list',
+ 'mail_list_actions': 'app/js/mail_list_actions',
+ 'user_alerts': 'app/js/user_alerts',
+ 'mail_view': 'app/js/mail_view',
+ 'dispatchers': 'app/js/dispatchers',
+ 'mixins': 'app/js/mixins',
+ 'services': 'app/js/services',
+ 'search': 'app/js/search',
+ 'monkey_patching': 'app/js/monkey_patching',
+ 'i18next': 'app/bower_components/i18next/i18next.amd',
+ 'quoted-printable': 'app/bower_components/quoted-printable',
+ 'test': 'test'
+ },
+
+
+ deps: tests,
+
+ callback: function () {
+ require(['page/events','test/test_data', 'views/i18n', 'monkey_patching/array', 'views/recipientListFormatter'], function (events, testData, i18n, mp, recipientListFormatter) {
+ window['Smail'] = window['Smail'] || {};
+ window['Smail'].events = events;
+ window['Smail'].testData = testData;
+ window['Smail'].mockBloodhound = function() {
+ window.Bloodhound = function() {};
+ window.Bloodhound.prototype.initialize = function() {};
+ window.Bloodhound.prototype.ttAdapter = function() {};
+ window.Bloodhound.prototype.clear = function() {};
+ window.Bloodhound.prototype.clearPrefetchCache = function() {};
+ window.Bloodhound.prototype.clearRemoteCache = function() {};
+ $.fn.typeahead = function() {};
+ };
+
+ i18n.init('/base/app/');
+ // start test run, once Require.js is done
+ window.__karma__.start();
+ });
+ }
+});
diff --git a/web-ui/test/test_data.js b/web-ui/test/test_data.js
new file mode 100644
index 00000000..8c7b6f21
--- /dev/null
+++ b/web-ui/test/test_data.js
@@ -0,0 +1,170 @@
+/*global _ */
+'use strict';
+
+define(function() {
+ var rawMail = {
+ header: {
+ to:'jed_waelchi@cummerata.info',
+ from:'laurel@hamill.info',
+ subject:'Velit aut tempora animi ut nulla esse.',
+ date:'2014-06-04T14:41:13-03:00'
+ },
+ ident:2048,
+ tags:['gang_family','garden','nailartaddicts','inbox'],
+ status:[],
+ body: 'Porro quam minus. Doloribus odio vel. Placeat alias sed est assumenda qui esse. Tenetur tempora deserunt est consequatur ducimus laborum. Velit dolor voluptatibus.\n\nRerum repellendus tempore. Aliquam dolores laudantium amet et dolor voluptas. Quod eos magni mollitia et ex. Corrupti quis reprehenderit quasi. Quam cum nobis voluptas accusamus quisquam ut asperiores.\n\nFacilis dicta mollitia non molestiae. Eligendi perspiciatis aut qui eos qui. Laborum cumque odit velit nobis. Cumque quo impedit dignissimos quia.',
+ security_casing: {
+ locks: [],
+ imprints: []
+ }
+ };
+
+ var rawSentMail = {
+ 'header':{'to':'mariane_dach@davis.info', 'cc': 'duda@la.lu', 'from':'afton_braun@botsford.biz','subject':'Consectetur sit omnis veniam blanditiis.','date':'2014-06-17T11:56:53-03:00'},
+ 'ident':9359,
+ 'tags':['sent','photography','sky'],
+ 'status':['read'],
+ 'body':'Illum eos nihil commodi voluptas. Velit consequatur odio quibusdam. Beatae aliquam hic quos.'
+ };
+
+ var rawDraftMail = {
+ 'header':{'to':'mariane_dach@davis.info','from':'afton_braun@botsford.biz','subject':'Consectetur sit omnis veniam blanditiis.','date':'2014-06-17T11:56:53-03:00'},
+ 'ident':9360,
+ 'tags':['drafts','photography','sky'],
+ 'status':['read'],
+ 'body':'Illum eos nihil commodi voluptas. Velit consequatur odio quibusdam. Beatae aliquam hic quos.'
+ };
+
+ var rawRecievedMail = {
+ 'header':{'to':'stanford@sipes.com','from':'cleve_jaskolski@schimmelhirthe.net','reply_to':'afton_braun@botsford.biz','subject':'Cumque pariatur vel consequuntur deleniti ex.','date':'2014-06-17T05:40:29-03:00'},
+ 'ident':242,
+ 'tags':['garden','instalovers','popularpic'],
+ 'status':['read'],
+ 'body':'Sed est neque tempore. Alias officiis pariatur ullam porro corporis. Tempore eum quia placeat. Sapiente fuga cum.'
+ };
+
+ var rawRecievedWithCCMail = {
+ 'header':{'to':'stanford@sipes.com','from':'cleve_jaskolski@schimmelhirthe.net','cc':'mariane_dach@davis.info','subject':'Cumque pariatur vel consequuntur deleniti ex.','date':'2014-06-17T05:40:29-03:00'},
+ 'ident':242,
+ 'tags':['garden','instalovers','popularpic'],
+ 'status':['read'],
+ 'body':'Sed est neque tempore. Alias officiis pariatur ullam porro corporis. Tempore eum quia placeat. Sapiente fuga cum.'
+ };
+
+ var rawMultipartMail = {
+ header: {
+ to:'multipart@multipart.info',
+ from:'laurel@hamill.info',
+ subject:'multipart email with text/plain and text/html',
+ content_type: 'multipart/alternative; boundary=asdfghjkl',
+ date:'2014-06-04T14:41:13-03:00'
+ },
+ ident: 11,
+ tags:['multipart','inbox'],
+ status:[],
+ body: '--asdfghjkl\n' +
+ 'Content-Type: text/plain;\n' +
+ '\n' +
+ 'Hello everyone!\n' +
+ '--asdfghjkl\n' +
+ 'Content-Type: text/html;\n' +
+ 'Content-Transfer-Encoding: quoted-printable\n' +
+ '\n' +
+ '<p><b>Hello everyone!</b></p>\n' +
+ '--asdfghjkl--\n'
+ };
+
+ var simpleTextPlainMail = {
+ header: {
+ to:'jed_waelchi@cummerata.info',
+ from:'laurel@hamill.info',
+ subject:'Velit aut tempora animi ut nulla esse.',
+ date:'2014-06-04T14:41:13-03:00'
+ },
+ ident:1,
+ tags:['textplain','inbox'],
+ status:[],
+ body: 'Porro quam minus. Doloribus odio vel. Placeat alias sed est assumenda qui esse. Tenetur tempora deserunt est consequatur ducimus laborum. Velit dolor voluptatibus.\n\nRerum repellendus tempore. Aliquam dolores laudantium amet et dolor voluptas. Quod eos magni mollitia et ex. Corrupti quis reprehenderit quasi. Quam cum nobis voluptas accusamus quisquam ut asperiores.\n\nFacilis dicta mollitia non molestiae. Eligendi perspiciatis aut qui eos qui. Laborum cumque odit velit nobis. Cumque quo impedit dignissimos quia.',
+ isSentMail: function() { return false; },
+ isDraftMail: function() { return false; },
+ replyToAddress: function() { return { to: ['laurel@hamill.info'], cc: [] }; },
+ replyToAllAddress: function() { return { to: ['laurel@hamill.info'], cc: [] }; },
+ isMailMultipartAlternative: function() { return false; },
+ availableBodyPartsContentType: function() { return []; },
+ getMailPartByContentType: function() { return; }
+ };
+
+ var htmlNoEncodingMail = {
+ header: {
+ to:'jed_waelchi@cummerata.info',
+ from:'laurel@hamill.info',
+ subject:'Velit aut tempora animi ut nulla esse.',
+ content_type: 'multipart/alternative; boundary=asdfghjkl',
+ date:'2014-06-04T14:41:13-03:00'
+ },
+ ident:2,
+ tags:['html','noencoding','inbox'],
+ status:[],
+ body: '--asdfghjkl\nContent-Type: text/html; charset=utf8\n\n<DOCTYPE html>\n<body> <div> <p>Hello everyone!</p> </div> </body>\n--asdfghjkl--\n',
+ isSentMail: function() { return false; },
+ isDraftMail: function() { return false; },
+ replyToAddress: function() { return { to: ['laurel@hamill.info'], cc: [] }; },
+ replyToAllAddress: function() { return { to: ['laurel@hamill.info'], cc: [] }; },
+ isMailMultipartAlternative: function () { return true; },
+ availableBodyPartsContentType: function () { return ['text/html']; },
+ getMailPartByContentType: function () {
+ return {
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ body: '<!DOCTYPE html> <body> <div> <p>Hello everyone!</p> </div> </body>'
+ };
+ }
+ };
+
+ var htmlQuotedPrintableMail = {
+ header: {
+ to:'jed_waelchi@cummerata.info',
+ from:'laurel@hamill.info',
+ subject:'Velit aut tempora animi ut nulla esse.',
+ content_type: 'multipart/alternative; boundary=asdfghjkl',
+ date:'2014-06-04T14:41:13-03:00'
+ },
+ ident:3,
+ tags:['html','quotedprintable','inbox'],
+ status:[],
+ body: '--asdfghjkl\nContent-Type: text/html; charset=utf8\nContent-Transfer-Encoding: quoted-printable\n\n<DOCTYPE html>\n<body> <div style=3D"border: 5px;"> <p>Hello everyone!</p> </div> </body>\n--asdfghjkl--\n',
+ isSentMail: function() { return false; },
+ isDraftMail: function() { return false; },
+ replyToAddress: function() { return { to: ['laurel@hamill.info'], cc: [] }; },
+ replyToAllAddress: function() { return { to: ['laurel@hamill.info'], cc: [] }; },
+ isMailMultipartAlternative: function () { return true; },
+ availableBodyPartsContentType: function () { return ['text/html']; },
+ getMailPartByContentType: function () {
+ return {
+ headers: {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Content-Transfer-Encoding': 'quoted-printable'},
+ body: '<!DOCTYPE html> <body> <div> <p style=3D"border: 5px;">Hello everyone!</p> </div> </body>'
+ };
+ }
+ };
+
+ var testData = {
+ rawMail: {
+ mail: rawMail,
+ sent: rawSentMail,
+ draft: rawDraftMail,
+ recieved: rawRecievedMail,
+ recievedWithCC: rawRecievedWithCCMail,
+ multipart: rawMultipartMail
+ },
+ parsedMail: {
+ simpleTextPlain: simpleTextPlainMail,
+ html: htmlNoEncodingMail,
+ htmlQuotedPrintable: htmlQuotedPrintableMail
+ }
+ };
+
+ return function () {
+ return _.cloneDeep(testData);
+ };
+});