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