summaryrefslogtreecommitdiff
path: root/web-ui/app
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 /web-ui/app
parent639a663a4c37020003586438fdcd7ac529a00f10 (diff)
Add web-ui based on previous code
Diffstat (limited to 'web-ui/app')
-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
124 files changed, 10808 insertions, 0 deletions
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>