summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/static/src/js')
-rw-r--r--addons/mail/static/src/js/activity.js293
-rw-r--r--addons/mail/static/src/js/chat_window.js199
-rw-r--r--addons/mail/static/src/js/chatter.js137
-rw-r--r--addons/mail/static/src/js/chatter_composer.js254
-rw-r--r--addons/mail/static/src/js/composer.js922
-rw-r--r--addons/mail/static/src/js/composers/basic_composer.js640
-rw-r--r--addons/mail/static/src/js/composers/chatter_composer.js295
-rw-r--r--addons/mail/static/src/js/composers/extended_composer.js100
-rw-r--r--addons/mail/static/src/js/composers/mention_manager.js432
-rw-r--r--addons/mail/static/src/js/discuss.js948
-rw-r--r--addons/mail/static/src/js/discuss_mobile.js176
-rw-r--r--addons/mail/static/src/js/emojis.js149
-rw-r--r--addons/mail/static/src/js/extended_chat_window.js63
-rw-r--r--addons/mail/static/src/js/followers.js107
-rw-r--r--addons/mail/static/src/js/form_controller.js8
-rw-r--r--addons/mail/static/src/js/models/channel.js442
-rw-r--r--addons/mail/static/src/js/models/dm.js94
-rw-r--r--addons/mail/static/src/js/models/document_thread.js276
-rw-r--r--addons/mail/static/src/js/models/mail_failure.js102
-rw-r--r--addons/mail/static/src/js/models/mail_model.js20
-rw-r--r--addons/mail/static/src/js/models/mailbox.js213
-rw-r--r--addons/mail/static/src/js/models/messages/abstract_message.js409
-rw-r--r--addons/mail/static/src/js/models/messages/message.js780
-rw-r--r--addons/mail/static/src/js/models/thread.js446
-rw-r--r--addons/mail/static/src/js/models/thread_with_cache.js204
-rw-r--r--addons/mail/static/src/js/services/chat_manager.js2016
-rw-r--r--addons/mail/static/src/js/services/chat_window_manager.js534
-rw-r--r--addons/mail/static/src/js/services/mail_manager.js1246
-rw-r--r--addons/mail/static/src/js/services/mail_notification_manager.js484
-rw-r--r--addons/mail/static/src/js/services/mail_service.js15
-rw-r--r--addons/mail/static/src/js/services/mail_window_manager.js684
-rw-r--r--addons/mail/static/src/js/systray.js353
-rw-r--r--addons/mail/static/src/js/systray/systray_activity_menu.js150
-rw-r--r--addons/mail/static/src/js/systray/systray_messaging_menu.js329
-rw-r--r--addons/mail/static/src/js/thread.js636
-rw-r--r--addons/mail/static/src/js/thread_field.js225
-rw-r--r--addons/mail/static/src/js/thread_windows/abstract_thread_window.js390
-rw-r--r--addons/mail/static/src/js/thread_windows/thread_window.js428
-rw-r--r--addons/mail/static/src/js/tour.js14
-rw-r--r--addons/mail/static/src/js/utils.js64
40 files changed, 9853 insertions, 5424 deletions
diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js
index fce7eb29f6f..828e29e195d 100644
--- a/addons/mail/static/src/js/activity.js
+++ b/addons/mail/static/src/js/activity.js
@@ -1,12 +1,13 @@
odoo.define('mail.Activity', function (require) {
"use strict";
+var mailUtils = require('mail.utils');
+
var AbstractField = require('web.AbstractField');
var BasicModel = require('web.BasicModel');
var core = require('web.core');
var field_registry = require('web.field_registry');
var time = require('web.time');
-var utils = require('mail.utils');
var QWeb = core.qweb;
var _t = core._t;
@@ -71,38 +72,49 @@ BasicModel.include({
/**
* Set the 'label_delay' entry in activity data according to the deadline date
+ *
* @param {Array} activities list of activity Object
* @return {Array} : list of modified activity Object
*/
-var setDelayLabel = function(activities){
+var setDelayLabel = function (activities){
var today = moment().startOf('day');
- _.each(activities, function(activity){
- var to_display = '';
+ _.each(activities, function (activity){
+ var toDisplay = '';
var diff = activity.date_deadline.diff(today, 'days', true); // true means no rounding
- if(diff === 0){
- to_display = _t('Today');
- }else{
- if(diff < 0){ // overdue
- if(diff === -1){
- to_display = _t('Yesterday');
- }else{
- to_display = _.str.sprintf(_t('%d days overdue'), Math.abs(diff));
+ if (diff === 0){
+ toDisplay = _t("Today");
+ } else {
+ if (diff < 0){ // overdue
+ if (diff === -1){
+ toDisplay = _t("Yesterday");
+ } else {
+ toDisplay = _.str.sprintf(_t("%d days overdue"), Math.abs(diff));
}
- }else{ // due
- if(diff === 1){
- to_display = _t('Tomorrow');
- }else{
- to_display = _.str.sprintf(_t('Due in %d days'), Math.abs(diff));
+ } else { // due
+ if (diff === 1){
+ toDisplay = _t("Tomorrow");
+ } else {
+ toDisplay = _.str.sprintf(_t("Due in %d days"), Math.abs(diff));
}
}
}
- activity.label_delay = to_display;
+ activity.label_delay = toDisplay;
});
return activities;
};
var AbstractActivityField = AbstractField.extend({
- // private
+
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {integer} id
+ * @param {string} feedback
+ * @return {$.Promise}
+ */
_markActivityDone: function (id, feedback) {
return this._rpc({
model: 'mail.activity',
@@ -112,7 +124,14 @@ var AbstractActivityField = AbstractField.extend({
context: this.record.getContext(),
});
},
- _scheduleActivity: function (id, previous_activity_type_id, callback) {
+ /**
+ * @private
+ * @param {integer} id
+ * @param {integer} previousActivityTypeID
+ * @param {function} callback
+ * @return {$.Promise}
+ */
+ _scheduleActivity: function (id, previousActivityTypeID, callback) {
var action = {
type: 'ir.actions.act_window',
res_model: 'mail.activity',
@@ -123,7 +142,7 @@ var AbstractActivityField = AbstractField.extend({
context: {
default_res_id: this.res_id,
default_res_model: this.model,
- default_previous_activity_type_id: previous_activity_type_id,
+ default_previous_activity_type_id: previousActivityTypeID,
},
res_id: id || false,
};
@@ -144,18 +163,49 @@ var Activity = AbstractActivityField.extend({
},
specialData: '_fetchSpecialActivity',
- // inherited
+ /**
+ * @override
+ */
init: function () {
this._super.apply(this, arguments);
- this.activities = this.record.specialData[this.name];
+ this._activities = this.record.specialData[this.name];
+ },
+
+ //------------------------------------------------------------
+ // Public
+ //------------------------------------------------------------
+
+ /**
+ * @param {integer} previousActivityTypeID
+ * @return {$.Promise}
+ */
+ scheduleActivity: function (previousActivityTypeID) {
+ var callback = this._reload.bind(this, { activity: true, thread: true });
+ return this._scheduleActivity(false, previousActivityTypeID, callback);
},
+
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} fieldsToReload
+ */
+ _reload: function (fieldsToReload) {
+ this.trigger_up('reload_mail_fields', fieldsToReload);
+ },
+ /**
+ * @override
+ * @private
+ */
_render: function () {
- _.each(this.activities, function (activity) {
+ _.each(this._activities, function (activity) {
if (activity.note) {
- activity.note = utils.parse_and_transform(activity.note, utils.add_link);
+ activity.note = mailUtils.parseAndTransform(activity.note, mailUtils.addLink);
}
});
- var activities = setDelayLabel(this.activities);
+ var activities = setDelayLabel(this._activities);
if (activities.length) {
var nbActivities = _.countBy(activities, 'state');
this.$el.html(QWeb.render('mail.activity_items', {
@@ -163,31 +213,33 @@ var Activity = AbstractActivityField.extend({
nbPlannedActivities: nbActivities.planned,
nbTodayActivities: nbActivities.today,
nbOverdueActivities: nbActivities.overdue,
- date_format: time.getLangDateFormat(),
- datetime_format: time.getLangDatetimeFormat(),
+ dateFormat: time.getLangDateFormat(),
+ datetimeFormat: time.getLangDatetimeFormat(),
}));
} else {
this.$el.empty();
}
},
+ /**
+ * @override
+ * @private
+ * @param {Object} record
+ */
_reset: function (record) {
this._super.apply(this, arguments);
- this.activities = this.record.specialData[this.name];
+ this._activities = this.record.specialData[this.name];
// the mail widgets being persistent, one need to update the res_id on reset
this.res_id = record.res_id;
},
- // public
- scheduleActivity: function (previous_activity_type_id) {
- var callback = this._reload.bind(this, {activity: true, thread: true});
- return this._scheduleActivity(false, previous_activity_type_id, callback);
- },
- // private
- _reload: function (fieldsToReload) {
- this.trigger_up('reload_mail_fields', fieldsToReload);
- },
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
- // handlers
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
_onClickRedirect: function (ev) {
var id = $(ev.target).data('oe-id');
if (id) {
@@ -199,10 +251,15 @@ var Activity = AbstractActivityField.extend({
});
}
},
- _onEditActivity: function (event, options) {
- event.preventDefault();
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @param {Object} options
+ */
+ _onEditActivity: function (ev, options) {
+ ev.preventDefault();
var self = this;
- var activity_id = $(event.currentTarget).data('activity-id');
+ var activity_id = $(ev.currentTarget).data('activity-id');
var action = _.defaults(options || {}, {
type: 'ir.actions.act_window',
res_model: 'mail.activity',
@@ -222,12 +279,17 @@ var Activity = AbstractActivityField.extend({
},
});
},
- _onUnlinkActivity: function (event, options) {
- event.preventDefault();
- var activity_id = $(event.currentTarget).data('activity-id');
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @param {Object} options
+ */
+ _onUnlinkActivity: function (ev, options) {
+ ev.preventDefault();
+ var activityID = $(ev.currentTarget).data('activity-id');
options = _.defaults(options || {}, {
model: 'mail.activity',
- args: [[activity_id]],
+ args: [[activityID]],
});
return this._rpc({
model: options.model,
@@ -236,47 +298,51 @@ var Activity = AbstractActivityField.extend({
})
.then(this._reload.bind(this, {activity: true}));
},
- _onMarkActivityDone: function (event) {
- event.preventDefault();
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMarkActivityDone: function (ev) {
+ ev.preventDefault();
var self = this;
- var $popover_el = $(event.currentTarget);
- var activity_id = $popover_el.data('activity-id');
- var previous_activity_type_id = $popover_el.data('previous-activity-type-id');
- if (!$popover_el.data('bs.popover')) {
- $popover_el.popover({
- title : _t('Feedback'),
+ var $popoverElement = $(ev.currentTarget);
+ var activityID = $popoverElement.data('activity-id');
+ var previousActivityTypeID = $popoverElement.data('previous-activity-type-id');
+ if (!$popoverElement.data('bs.popover')) {
+ $popoverElement.popover({
+ title : _t("Feedback"),
html: 'true',
trigger:'click',
- content : function() {
- var $popover = $(QWeb.render("mail.activity_feedback_form", {'previous_activity_type_id': previous_activity_type_id}));
+ content : function () {
+ var $popover = $(QWeb.render('mail.activity_feedback_form', { 'previous_activity_type_id': previousActivityTypeID }));
$popover.on('click', '.o_activity_popover_done_next', function () {
var feedback = _.escape($popover.find('#activity_feedback').val());
- var previous_activity_type_id = $popover_el.data('previous-activity-type-id');
- self._markActivityDone(activity_id, feedback)
- .then(self.scheduleActivity.bind(self, previous_activity_type_id));
+ var previousActivityTypeID = $popoverElement.data('previous-activity-type-id');
+ self._markActivityDone(activityID, feedback)
+ .then(self.scheduleActivity.bind(self, previousActivityTypeID));
});
$popover.on('click', '.o_activity_popover_done', function () {
var feedback = _.escape($popover.find('#activity_feedback').val());
- self._markActivityDone(activity_id, feedback)
- .then(self._reload.bind(self, {activity: true, thread: true}));
+ self._markActivityDone(activityID, feedback)
+ .then(self._reload.bind(self, { activity: true, thread: true }));
});
$popover.on('click', '.o_activity_popover_discard', function () {
- $popover_el.popover('hide');
+ $popoverElement.popover('hide');
});
return $popover;
},
- }).on("show.bs.popover", function (e) {
- var $popover = $(this).data("bs.popover").tip();
+ }).on('show.bs.popover', function (e) {
+ var $popover = $(this).data('bs.popover').tip();
$popover.addClass('o_mail_activity_feedback').attr('tabindex', 0);
- $(".o_mail_activity_feedback.popover").not(e.target).popover("hide");
- }).on("shown.bs.popover", function () {
- var $popover = $(this).data("bs.popover").tip();
+ $('.o_mail_activity_feedback.popover').not(e.target).popover('hide');
+ }).on('shown.bs.popover', function () {
+ var $popover = $(this).data('bs.popover').tip();
$popover.find('#activity_feedback').focus();
$popover.off('focusout');
$popover.focusout(function (e) {
// outside click of popover hide the popover
// e.relatedTarget is the element receiving the focus
- if(!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) {
+ if (!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) {
$popover.popover('hide');
}
});
@@ -296,7 +362,9 @@ var KanbanActivity = AbstractActivityField.extend({
'click .o_mark_as_done': '_onMarkActivityDone',
},
- // inherited
+ /**
+ * @override
+ */
init: function (parent, name, record) {
this._super.apply(this, arguments);
var selection = {};
@@ -306,59 +374,98 @@ var KanbanActivity = AbstractActivityField.extend({
this.selection = selection;
this._setState(record);
},
+
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _reload: function () {
+ this.trigger_up('reload', { db_id: this.record_id });
+ },
+ /**
+ * @override
+ * @private
+ */
_render: function () {
- var $span = this.$(".o_activity_btn > span");
+ var $span = this.$('.o_activity_btn > span');
$span.removeClass(function (index, classNames) {
return classNames.split(/\s+/).filter(function (className) {
return _.str.startsWith(className, 'o_activity_color_');
}).join(' ');
});
- $span.addClass('o_activity_color_' + (this.activity_state || 'default'));
+ $span.addClass('o_activity_color_' + (this.activityState || 'default'));
if (this.$el.hasClass('open')) {
// note: this part of the rendering might be asynchronous
this._renderDropdown();
}
},
- _reset: function (record) {
- this._super.apply(this, arguments);
- this._setState(record);
- },
-
- // private
- _reload: function () {
- this.trigger_up('reload', {db_id: this.record_id});
- },
+ /**
+ * @private
+ */
_renderDropdown: function () {
var self = this;
- this.$('.o_activity').html(QWeb.render("mail.KanbanActivityLoading"));
+ this.$('.o_activity').html(QWeb.render('mail.KanbanActivityLoading'));
return _readActivities(this, this.value.res_ids).then(function (activities) {
- self.$('.o_activity').html(QWeb.render("mail.KanbanActivityDropdown", {
+ self.$('.o_activity').html(QWeb.render('mail.KanbanActivityDropdown', {
selection: self.selection,
records: _.groupBy(setDelayLabel(activities), 'state'),
uid: self.getSession().uid,
}));
});
},
+ /**
+ * @override
+ * @private
+ * @param {Object} record
+ */
+ _reset: function (record) {
+ this._super.apply(this, arguments);
+ this._setState(record);
+ },
+ /**
+ * @private
+ * @param {Object} record
+ */
_setState: function (record) {
this.record_id = record.id;
- this.activity_state = this.recordData.activity_state;
+ this.activityState = this.recordData.activity_state;
},
- // handlers
- _onButtonClicked: function (event) {
- event.preventDefault();
+
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onButtonClicked: function (ev) {
+ ev.preventDefault();
if (!this.$el.hasClass('open')) {
this._renderDropdown();
}
},
- _onMarkActivityDone: function (event) {
- event.stopPropagation();
- var activity_id = $(event.currentTarget).data('activity-id');
- this._markActivityDone(activity_id).then(this._reload.bind(this));
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMarkActivityDone: function (ev) {
+ ev.stopPropagation();
+ var activityID = $(ev.currentTarget).data('activity-id');
+ this._markActivityDone(activityID).then(this._reload.bind(this));
},
- _onScheduleActivity: function (event) {
- var activity_id = $(event.currentTarget).data('activity-id') || false;
- return this._scheduleActivity(activity_id, false, this._reload.bind(this));
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {$.Promise}
+ */
+ _onScheduleActivity: function (ev) {
+ var activityID = $(ev.currentTarget).data('activity-id') || false;
+ return this._scheduleActivity(activityID, false, this._reload.bind(this));
},
});
diff --git a/addons/mail/static/src/js/chat_window.js b/addons/mail/static/src/js/chat_window.js
deleted file mode 100644
index 9e080f3a248..00000000000
--- a/addons/mail/static/src/js/chat_window.js
+++ /dev/null
@@ -1,199 +0,0 @@
-odoo.define('mail.ChatWindow', function (require) {
-"use strict";
-
-var ChatThread = require('mail.ChatThread');
-
-var config = require('web.config');
-var core = require('web.core');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var HEIGHT_OPEN = '400px';
-var HEIGHT_FOLDED = '34px';
-
-return Widget.extend({
- template: "mail.ChatWindow",
- custom_events: {
- escape_pressed: '_onEscapePressed',
- document_viewer_closed: '_onDocumentViewerClose',
- },
- events: {
- 'click .o_chat_composer': '_onComposerClick',
- "click .o_mail_thread": "_onChatWindowClicked",
- "keydown .o_chat_composer": "on_keydown",
- "keypress .o_chat_composer": "on_keypress",
- "click .o_chat_window_close": "on_click_close",
- "click .o_chat_title": "on_click_fold",
- },
-
- init: function (parent, channel_id, title, is_folded, unread_msgs, options) {
- this._super(parent);
- this.title = title;
- this.channel_id = channel_id;
- this.folded = is_folded;
- this.options = _.defaults(options || {}, {
- autofocus: true,
- display_stars: true,
- display_reply_icon: false,
- display_email_icon: false,
- placeholder: _t("Say something"),
- input_less: false,
- });
- this.status = this.options.status;
- this.unread_msgs = unread_msgs || 0;
- this.is_hidden = false;
- this.isMobile = config.device.isMobile;
- },
- start: function () {
- this.$input = this.$('.o_composer_text_field');
- this.$header = this.$('.o_chat_header');
-
- this.thread = new ChatThread(this, {
- channel_id: this.channel_id,
- display_needactions: false,
- display_stars: this.options.display_stars,
- });
- this.thread.on('toggle_star_status', null, this.trigger.bind(this, 'toggle_star_status'));
- this.thread.on('redirect_to_channel', null, this.trigger.bind(this, 'redirect_to_channel'));
- this.thread.on('redirect', null, this.trigger.bind(this, 'redirect'));
-
- if (this.folded) {
- this.$el.css('height', HEIGHT_FOLDED);
- } else if (this.options.autofocus) {
- this.focus_input();
- }
- if (!config.device.isMobile) {
- this.$el.css('margin-right', $.position.scrollbarWidth());
- }
- var def = this.thread.replace(this.$('.o_chat_content'));
- return $.when(this._super(), def);
- },
- render: function (messages) {
- this.update_unread(this.unread_msgs);
- this.thread.render(messages, {display_load_more: false});
- },
- update_unread: function (counter) {
- this.unread_msgs = counter;
- this.render_header();
- },
- update_status: function (status) {
- this.status = status;
- this.render_header();
- },
- render_header: function () {
- this.$header.html(QWeb.render('mail.ChatWindowHeaderContent', {
- status: this.status,
- title: this.title,
- unread_counter: this.unread_msgs,
- widget: this,
- }));
- },
- fold: function () {
- this.$el.animate({
- height: this.folded ? HEIGHT_FOLDED : HEIGHT_OPEN
- }, 200);
- },
- toggle_fold: function (fold) {
- this.folded = _.isBoolean(fold) ? fold : !this.folded;
- if (!this.folded) {
- this.thread.scroll_to();
- this.focus_input();
- }
- this.fold();
- },
- focus_input: function () {
- if (config.device.touch && config.device.size_class <= config.device.SIZES.SM) {
- return;
- }
- this.$input.focus();
- },
- do_show: function () {
- this.is_hidden = false;
- this._super.apply(this, arguments);
- },
- do_hide: function () {
- this.is_hidden = true;
- this._super.apply(this, arguments);
- },
- do_toggle: function (display) {
- this.is_hidden = _.isBoolean(display) ? !display : !this.is_hidden;
- this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- on_keypress: function (event) {
- event.stopPropagation(); // to prevent jquery's blockUI to cancel event
- },
- on_keydown: function (event) {
- event.stopPropagation(); // to prevent jquery's blockUI to cancel event
- // ENTER key (avoid requiring jquery ui for external livechat)
- if (event.which === 13) {
- var content = _.str.trim(this.$input.val());
- var message = {
- content: content,
- attachment_ids: [],
- partner_ids: [],
- };
- this.$input.val('');
- if (content) {
- this.trigger('post_message', message, {
- channelID: this.channel_id,
- });
- }
- }
- },
- on_click_close: function (event) {
- event.stopPropagation();
- event.preventDefault();
- this.trigger("close_chat_session");
- },
- on_click_fold: function () {
- if (!config.device.isMobile) {
- this.toggle_fold();
- this.trigger("fold_channel", this.channel_id, this.folded);
- }
- },
- /**
- * When a chat window is clicked on, we want to give the focus to the main
- * input. An exception is made when the user is selecting something.
- *
- * @private
- */
- _onChatWindowClicked: function () {
- var selectObj = window.getSelection();
- if (selectObj.anchorOffset === selectObj.focusOffset) {
- this.$input.focus();
- }
- },
- /**
- * Called when the composer is clicked -> forces focus on input even if
- * jquery's blockUI is enabled.
- *
- * @private
- * @param {Event} ev
- */
- _onComposerClick: function (ev) {
- if ($(ev.target).closest('a, button').length) {
- return;
- }
- this.focus_input();
- },
- _onDocumentViewerClose: function (ev) {
- this.focus_input();
- },
- /**
- * @private
- */
- _onEscapePressed: function () {
- if (!this.folded) {
- this.trigger("close_chat_session");
- }
- },
-});
-
-});
diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js
index 63948b8429a..01f6c3ec3ff 100644
--- a/addons/mail/static/src/js/chatter.js
+++ b/addons/mail/static/src/js/chatter.js
@@ -2,10 +2,10 @@ odoo.define('mail.Chatter', function (require) {
"use strict";
var Activity = require('mail.Activity');
-var ChatterComposer = require('mail.ChatterComposer');
+var ChatterComposer = require('mail.composer.Chatter');
var Followers = require('mail.Followers');
var ThreadField = require('mail.ThreadField');
-var utils = require('mail.utils');
+var mailUtils = require('mail.utils');
var concurrency = require('web.concurrency');
var config = require('web.config');
@@ -47,13 +47,13 @@ var Chatter = Widget.extend({
this._super.apply(this, arguments);
this._setState(record);
- this.dp = new concurrency.DropPrevious();
+ this._dp = new concurrency.DropPrevious();
// mention: get the prefetched partners and use them as mention suggestions
// if there is a follower widget, the followers will be added to the
// suggestions as well once fetched
- this.mentionPartnerSuggestions = this.call('chat_manager', 'getMentionPartnerSuggestions');
- this.mentionSuggestions = this.mentionPartnerSuggestions;
+ this._mentionPartnerSuggestions = this.call('mail_service', 'getMentionPartnerSuggestions');
+ this._mentionSuggestions = this._mentionPartnerSuggestions;
this.fields = {};
if (mailFields.mail_activity) {
@@ -73,19 +73,19 @@ var Chatter = Widget.extend({
* @override
*/
start: function () {
- this.$topbar = this.$('.o_chatter_topbar');
+ this._$topbar = this.$('.o_chatter_topbar');
// render and append the buttons
- this.$topbar.append(QWeb.render('mail.Chatter.Buttons', {
- new_message_btn: !!this.fields.thread,
- log_note_btn: this.hasLogButton,
- schedule_activity_btn: !!this.fields.activity,
+ this._$topbar.append(QWeb.render('mail.chatter.Buttons', {
+ newMessageButton: !!this.fields.thread,
+ logNoteButton: this.hasLogButton,
+ scheduleActivityButton: !!this.fields.activity,
isMobile: config.device.isMobile,
}));
// start and append the widgets
var fieldDefs = _.invoke(this.fields, 'appendTo', $('<div>'));
- var def = this.dp.add($.when.apply($, fieldDefs));
+ var def = this._dp.add($.when.apply($, fieldDefs));
this._render(def).then(this._updateMentionSuggestions.bind(this));
return this._super.apply(this, arguments);
@@ -132,7 +132,7 @@ var Chatter = Widget.extend({
fieldsToReset = this.fields;
}
var fieldDefs = _.invoke(fieldsToReset, 'reset', record);
- var def = this.dp.add($.when.apply($, fieldDefs));
+ var def = this._dp.add($.when.apply($, fieldDefs));
this._render(def).then(function () {
self.$el.height('auto');
self._updateMentionSuggestions();
@@ -148,14 +148,20 @@ var Chatter = Widget.extend({
* @param {boolean} force
*/
_closeComposer: function (force) {
- if (this.composer && (this.composer.is_empty() || force)) {
+ if (this._composer && (this._composer.isEmpty() || force)) {
this.$el.removeClass('o_chatter_composer_active');
this.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active');
- this.composer.do_hide();
- this.composer.clear_composer();
+ this._composer.do_hide();
+ this._composer.clearComposer();
}
},
/**
+ * @private
+ */
+ _disableChatter: function () {
+ this.$('.btn').prop('disabled', true); // disable buttons
+ },
+ /**
* Discard changes on the record.
*
* @private
@@ -176,12 +182,12 @@ var Chatter = Widget.extend({
* after posting it
*
* @private
- * @param {Object} message
+ * @param {Object} messageData
* @return {$.Deferred} resolved if no reload or proceed to discard the
* changes on the record, rejected otherwise
*/
- _discardOnReload: function (message) {
- if (this._reloadAfterPost(message)) {
+ _discardOnReload: function (messageData) {
+ if (this._reloadAfterPost(messageData)) {
return this._discardChanges();
}
return $.when();
@@ -189,12 +195,6 @@ var Chatter = Widget.extend({
/**
* @private
*/
- _disableChatter: function () {
- this.$('.btn').prop('disabled', true); // disable buttons
- },
- /**
- * @private
- */
_enableChatter: function () {
this.$('.btn').prop('disabled', false); // enable buttons
},
@@ -202,51 +202,49 @@ var Chatter = Widget.extend({
* @private
* @param {Object} options
* @param {Object[]} [options.suggested_partners=[]]
- * @param {boolean} [options.is_log]
+ * @param {boolean} [options.isLog]
*/
_openComposer: function (options) {
var self = this;
- var old_composer = this.composer;
+ var oldComposer = this._composer;
// create the new composer
- this.composer = new ChatterComposer(this, this.record.model, options.suggested_partners || [], {
- commands_enabled: false,
+ this._composer = new ChatterComposer(this, this.record.model, options.suggested_partners || [], {
+ commandsEnabled: false,
context: this.context,
- input_min_height: 50,
- input_max_height: Number.MAX_VALUE, // no max_height limit for the chatter
- input_baseline: 14,
- is_log: options && options.is_log,
- record_name: this.record_name,
- default_body: old_composer && old_composer.$input && old_composer.$input.val(),
- default_mention_selections: old_composer && old_composer.mention_get_listener_selections(),
+ inputMinHeight: 50,
+ isLog: options && options.isLog,
+ recordName: this.recordName,
+ defaultBody: oldComposer && oldComposer.$input && oldComposer.$input.val(),
+ defaultMentionSelections: oldComposer && oldComposer.getMentionListenerSelections(),
});
- this.composer.on('input_focused', this, function () {
- this.composer.mention_set_prefetched_partners(this.mentionSuggestions || []);
+ this._composer.on('input_focused', this, function () {
+ this._composer.mentionSetPrefetchedPartners(this._mentionSuggestions || []);
});
- this.composer.insertAfter(this.$('.o_chatter_topbar')).then(function () {
+ this._composer.insertAfter(this.$('.o_chatter_topbar')).then(function () {
// destroy existing composer
- if (old_composer) {
- old_composer.destroy();
+ if (oldComposer) {
+ oldComposer.destroy();
}
if (!config.device.touch) {
- self.composer.focus();
+ self._composer.focus();
}
- self.composer.on('post_message', self, function (message) {
- self._discardOnReload(message).then(function () {
- self.fields.thread.postMessage(message).then(function () {
+ self._composer.on('post_message', self, function (messageData) {
+ self._discardOnReload(messageData).then(function () {
+ self.fields.thread.postMessage(messageData).then(function () {
self._closeComposer(true);
- if (self._reloadAfterPost(message)) {
+ if (self._reloadAfterPost(messageData)) {
self.trigger_up('reload');
}
});
});
});
- self.composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload'));
- self.composer.on('close_composer', null, self._closeComposer.bind(self, true));
+ self._composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload'));
+ self._composer.on('close_composer', null, self._closeComposer.bind(self, true));
self.$el.addClass('o_chatter_composer_active');
self.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active');
- self.$('.o_chatter_button_new_message').toggleClass('o_active', !self.composer.options.is_log);
- self.$('.o_chatter_button_log_note').toggleClass('o_active', self.composer.options.is_log);
+ self.$('.o_chatter_button_new_message').toggleClass('o_active', !self._composer.options.isLog);
+ self.$('.o_chatter_button_log_note').toggleClass('o_active', self._composer.options.isLog);
});
},
/**
@@ -254,17 +252,17 @@ var Chatter = Widget.extend({
* Useful to warn the user of unsaved changes if the record is dirty.
*
* @private
- * @param {Object} message
- * @param {Array} [message.partner_ids] list of recipients of a message
+ * @param {Object} messageData
+ * @param {Array} [messageData.partner_ids] list of recipients of a message
* @return {boolean} true if record will be reloaded after posting the
* message, false otherwise
*/
- _reloadAfterPost: function (message) {
+ _reloadAfterPost: function (messageData) {
return this.postRefresh === 'always' ||
(
this.postRefresh === 'recipients' &&
- message.partner_ids &&
- message.partner_ids.length
+ messageData.partner_ids &&
+ messageData.partner_ids.length
);
},
/**
@@ -291,14 +289,14 @@ var Chatter = Widget.extend({
self.fields.activity.$el.appendTo(self.$el);
}
if (self.fields.followers) {
- self.fields.followers.$el.appendTo(self.$topbar);
+ self.fields.followers.$el.appendTo(self._$topbar);
}
if (self.fields.thread) {
self.fields.thread.$el.appendTo(self.$el);
}
}).always(function () {
// disable widgets in create mode, otherwise enable
- self.isCreateMode ? self._disableChatter() : self._enableChatter();
+ self._isCreateMode ? self._disableChatter() : self._enableChatter();
$spinner.remove();
});
},
@@ -311,8 +309,7 @@ var Chatter = Widget.extend({
*/
_setState: function (record) {
- this.wasCreateMode = !!this.isCreateMode;
- this.isCreateMode = !record.res_id;
+ this._isCreateMode = !record.res_id;
if (!this.record || this.record.res_id !== record.res_id) {
this.context = {
@@ -324,7 +321,7 @@ var Chatter = Widget.extend({
this.suggested_partners_def = undefined;
}
this.record = record;
- this.record_name = record.data.display_name;
+ this.recordName = record.data.display_name;
},
/**
* @private
@@ -335,28 +332,28 @@ var Chatter = Widget.extend({
}
var self = this;
- this.mentionSuggestions = [];
+ this._mentionSuggestions = [];
// add the followers to the mention suggestions
- var follower_suggestions = [];
+ var followerSuggestions = [];
var followers = this.fields.followers.getFollowers();
_.each(followers, function (follower) {
if (follower.res_model === 'res.partner') {
- follower_suggestions.push({
+ followerSuggestions.push({
id: follower.res_id,
name: follower.name,
email: follower.email,
});
}
});
- if (follower_suggestions.length) {
- this.mentionSuggestions.push(follower_suggestions);
+ if (followerSuggestions.length) {
+ this._mentionSuggestions.push(followerSuggestions);
}
// add the partners (followers filtered out) to the mention suggestions
- _.each(this.mentionPartnerSuggestions, function (partners) {
- self.mentionSuggestions.push(_.filter(partners, function (partner) {
- return !_.findWhere(follower_suggestions, { id: partner.id });
+ _.each(this._mentionPartnerSuggestions, function (partners) {
+ self._mentionSuggestions.push(_.filter(partners, function (partner) {
+ return !_.findWhere(followerSuggestions, { id: partner.id });
}));
});
},
@@ -390,7 +387,7 @@ var Chatter = Widget.extend({
var suggested_partners = [];
var thread_recipients = result[self.context.default_res_id];
_.each(thread_recipients, function (recipient) {
- var parsed_email = recipient[1] && utils.parse_email(recipient[1]);
+ var parsed_email = recipient[1] && mailUtils.parseEmail(recipient[1]);
suggested_partners.push({
checked: true,
partner_id: recipient[0],
@@ -404,14 +401,14 @@ var Chatter = Widget.extend({
});
}
this.suggested_partners_def.then(function (suggested_partners) {
- self._openComposer({ is_log: false, suggested_partners: suggested_partners });
+ self._openComposer({ isLog: false, suggested_partners: suggested_partners });
});
},
/**
* @private
*/
_onOpenComposerNote: function () {
- this._openComposer({is_log: true});
+ this._openComposer({ isLog: true });
},
/**
* @private
diff --git a/addons/mail/static/src/js/chatter_composer.js b/addons/mail/static/src/js/chatter_composer.js
deleted file mode 100644
index b5c17f3f593..00000000000
--- a/addons/mail/static/src/js/chatter_composer.js
+++ /dev/null
@@ -1,254 +0,0 @@
-odoo.define('mail.ChatterComposer', function (require) {
-"use strict";
-
-var composer = require('mail.composer');
-var utils = require('mail.utils');
-
-var core = require('web.core');
-var view_dialogs = require('web.view_dialogs');
-
-var _t = core._t;
-
-// Chat Composer for the Chatter
-//
-// Extends the basic Composer Widget to add 'suggested partner' layer (open
-// popup when suggested partner is selected without email, or other
-// informations), and the button to open the full composer wizard.
-var ChatterComposer = composer.BasicComposer.extend({
- template: 'mail.chatter.ChatComposer',
-
- init: function (parent, model, suggested_partners, options) {
- this._super(parent, options);
- this.model = model;
- this.suggested_partners = suggested_partners;
- this.options = _.defaults(this.options, {
- display_mode: 'textarea',
- record_name: false,
- is_log: false,
- });
- if (this.options.is_log) {
- this.options.send_text = _t('Log');
- }
- this.events = _.extend(this.events, {
- 'click .o_composer_button_full_composer': 'on_open_full_composer',
- });
- this.notInline = true;
- },
-
- should_send: function () {
- return false;
- },
-
- preprocess_message: function () {
- var self = this;
- var def = $.Deferred();
- this._super().then(function (message) {
- message = _.extend(message, {
- subtype: 'mail.mt_comment',
- message_type: 'comment',
- context: self.context,
- });
-
- // Subtype
- if (self.options.is_log) {
- message.subtype = 'mail.mt_note';
- }
-
- // Partner_ids
- if (!self.options.is_log) {
- var checked_suggested_partners = self.get_checked_suggested_partners();
- self.check_suggested_partners(checked_suggested_partners).done(function (partner_ids) {
- message.partner_ids = (message.partner_ids || []).concat(partner_ids);
- // update context
- message.context = _.defaults({}, message.context, {
- mail_post_autofollow: true,
- });
- def.resolve(message);
- });
- } else {
- def.resolve(message);
- }
-
- });
-
- return def;
- },
-
- clear_composer_on_send: function() {
- /* chatter don't clear message on sent but after successful sent */
- },
-
- /**
- * Send the message on SHIFT+ENTER, but go to new line on ENTER
- */
- prevent_send: function (event) {
- return !event.shiftKey;
- },
-
- /**
- * Get the list of selected suggested partners
- * @returns {Array} list of 'recipient' selected partners (may not be created in db)
- **/
- get_checked_suggested_partners: function () {
- var self = this;
- var checked_partners = [];
- this.$('.o_composer_suggested_partners input:checked').each(function() {
- var full_name = $(this).data('fullname');
- checked_partners = checked_partners.concat(_.filter(self.suggested_partners, function(item) {
- return full_name === item.full_name;
- }));
- });
- return checked_partners;
- },
-
- /**
- * Check the additional partners (not necessary registered partners), and open a popup form view
- * for the ones who informations is missing.
- * @param {Array} checked_suggested_partners list of 'recipient' partners to complete informations or validate
- * @returns {Deferred} resolved with the list of checked suggested partners (real partner)
- **/
- check_suggested_partners: function (checked_suggested_partners) {
- var self = this;
- var check_done = $.Deferred();
-
- var recipients = _.filter(checked_suggested_partners, function (recipient) { return recipient.checked; });
- var recipients_to_find = _.filter(recipients, function (recipient) { return (! recipient.partner_id); });
- var names_to_find = _.pluck(recipients_to_find, 'full_name');
- var recipients_to_check = _.filter(recipients, function (recipient) { return (recipient.partner_id && ! recipient.email_address); });
- var recipient_ids = _.pluck(_.filter(recipients, function (recipient) { return recipient.partner_id && recipient.email_address; }), 'partner_id');
-
- var names_to_remove = [];
- var recipient_ids_to_remove = [];
-
- // have unknown names -> call message_get_partner_info_from_emails to try to find partner_id
- var def;
- if (names_to_find.length > 0) {
- def = this._rpc({
- model: this.model,
- method: 'message_partner_info_from_emails',
- args: [[this.context.default_res_id], names_to_find],
- });
- }
-
- // for unknown names + incomplete partners -> open popup - cancel = remove from recipients
- $.when(def).pipe(function (result) {
- result = result || [];
- var emails_deferred = [];
- var recipient_popups = result.concat(recipients_to_check);
-
- _.each(recipient_popups, function (partner_info) {
- var deferred = $.Deferred();
- emails_deferred.push(deferred);
-
- var partner_name = partner_info.full_name;
- var partner_id = partner_info.partner_id;
- var parsed_email = utils.parse_email(partner_name);
-
- var dialog = new view_dialogs.FormViewDialog(self, {
- res_model: 'res.partner',
- res_id: partner_id,
- context: {
- active_model: self.model,
- active_id: self.context.default_res_id,
- force_email: true,
- ref: "compound_context",
- default_name: parsed_email[0],
- default_email: parsed_email[1],
- },
- title: _t("Please complete customer's informations"),
- disable_multiple_selection: true,
- }).open();
- dialog.on('closed', self, function () {
- deferred.resolve();
- });
- dialog.opened().then(function () {
- dialog.form_view.on('on_button_cancel', self, function () {
- names_to_remove.push(partner_name);
- if (partner_id) {
- recipient_ids_to_remove.push(partner_id);
- }
- });
- });
- });
- $.when.apply($, emails_deferred).then(function () {
- var new_names_to_find = _.difference(names_to_find, names_to_remove);
- var def;
- if (new_names_to_find.length > 0) {
- def = self._rpc({
- model: self.model,
- method: 'message_partner_info_from_emails',
- args: [[self.context.default_res_id], new_names_to_find, true],
- });
- }
- $.when(def).pipe(function (result) {
- result = result || [];
- var recipient_popups = result.concat(recipients_to_check);
- _.each(recipient_popups, function (partner_info) {
- if (partner_info.partner_id && _.indexOf(partner_info.partner_id, recipient_ids_to_remove) === -1) {
- recipient_ids.push(partner_info.partner_id);
- }
- });
- }).pipe(function () {
- check_done.resolve(recipient_ids);
- });
- });
- });
- return check_done;
- },
-
- on_open_full_composer: function() {
- if (!this.do_check_attachment_upload()){
- return false;
- }
-
- var self = this;
- var recipientDoneDef = $.Deferred();
-
- // any operation on the full-composer will reload the record, so
- // warn the user that any unsaved changes on the record will be lost.
- this.trigger_up('discard_record_changes', {
- proceed: function () {
- if (self.options.is_log) {
- recipientDoneDef.resolve([]);
- } else {
- var checked_suggested_partners = self.get_checked_suggested_partners();
- self.check_suggested_partners(checked_suggested_partners)
- .then(recipientDoneDef.resolve.bind(recipientDoneDef));
- }
- },
- });
-
- recipientDoneDef.then(function (partner_ids) {
- var context = {
- default_parent_id: self.id,
- default_body: utils.get_text2html(self.$input.val()),
- default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'),
- default_partner_ids: partner_ids,
- default_is_log: self.options.is_log,
- mail_post_autofollow: true,
- };
-
- if (self.context.default_model && self.context.default_res_id) {
- context.default_model = self.context.default_model;
- context.default_res_id = self.context.default_res_id;
- }
-
- var action = {
- type: 'ir.actions.act_window',
- res_model: 'mail.compose.message',
- view_mode: 'form',
- view_type: 'form',
- views: [[false, 'form']],
- target: 'new',
- context: context,
- };
- self.do_action(action, {
- on_close: self.trigger.bind(self, 'need_refresh'),
- }).then(self.trigger.bind(self, 'close_composer'));
- });
- }
-});
-
-return ChatterComposer;
-
-});
diff --git a/addons/mail/static/src/js/composer.js b/addons/mail/static/src/js/composer.js
deleted file mode 100644
index f89c0382554..00000000000
--- a/addons/mail/static/src/js/composer.js
+++ /dev/null
@@ -1,922 +0,0 @@
-odoo.define('mail.composer', function (require) {
-"use strict";
-
-var DocumentViewer = require('mail.DocumentViewer');
-var utils = require('mail.utils');
-
-var config = require('web.config');
-var core = require('web.core');
-var data = require('web.data');
-var dom = require('web.dom');
-var session = require('web.session');
-var Widget = require('web.Widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-var NON_BREAKING_SPACE = '\u00a0';
-
-var MENTION_PARTNER_DELIMITER = '@';
-var MENTION_CHANNEL_DELIMITER = '#';
-var MENTION_CANNED_RESPONSE_DELIMITER = ':';
-var MENTION_COMMAND_DELIMITER = '/';
-
-// The MentionManager allows the Composer to register listeners. For each
-// listener, it detects if the user is currently typing a mention (starting by a
-// given delimiter). If so, if fetches mention suggestions and renders them. On
-// suggestion clicked, it updates the selection for the corresponding listener.
-var MentionManager = Widget.extend({
- className: 'dropup o_composer_mention_dropdown',
-
- events: {
- "mouseover .o_mention_proposition": "on_hover_mention_proposition",
- "click .o_mention_proposition": "on_click_mention_item",
- },
-
- init: function (parent, options) {
- this._super.apply(this, arguments);
-
- this.composer = parent;
- this.options = _.extend({}, options, {
- min_length: 0,
- });
-
- this.open = false;
- this.listeners = [];
- this.set('mention_suggestions', []);
- this.on('change:mention_suggestions', this, this._render_suggestions);
- },
-
- // Events
- on_hover_mention_proposition: function (event) {
- var $elem = $(event.currentTarget);
- this.$('.o_mention_proposition').removeClass('active');
- $elem.addClass('active');
- },
- on_click_mention_item: function (event) {
- event.preventDefault();
-
- var text_input = this.composer.$input.val();
- var id = $(event.currentTarget).data('id');
- var selected_suggestion = _.find(_.flatten(this.get('mention_suggestions')), function (s) {
- return s.id === id;
- });
- var substitution = selected_suggestion.substitution;
- if (!substitution) { // no substitution string given, so use the mention name instead
- // replace white spaces with non-breaking spaces to facilitate mentions detection in text
- selected_suggestion.name = selected_suggestion.name.replace(/ /g, NON_BREAKING_SPACE);
- substitution = this.active_listener.delimiter + selected_suggestion.name;
- }
- var get_mention_index = function (matches, cursor_position) {
- for (var i=0; i<matches.length; i++) {
- if (cursor_position <= matches[i].index) {
- return i;
- }
- }
- return i;
- };
-
- // add the selected suggestion to the list
- if (this.active_listener.selection.length) {
- // get mention matches (ordered by index in the text)
- var matches = this._get_match(text_input, this.active_listener);
- var index = get_mention_index(matches, this._get_selection_positions().start);
- this.active_listener.selection.splice(index, 0, selected_suggestion);
- } else {
- this.active_listener.selection.push(selected_suggestion);
- }
-
- // update input text, and reset dropdown
- var cursor_position = this._get_selection_positions().start;
- var text_left = text_input.substring(0, cursor_position-(this.mention_word.length+1));
- var text_right = text_input.substring(cursor_position, text_input.length);
- var text_input_new = text_left + substitution + ' ' + text_right;
- this.composer.$input.val(text_input_new);
- this._set_cursor_position(text_left.length+substitution.length+2);
- this.set('mention_suggestions', []);
- this.composer.focus(); // to trigger autoresize
- },
-
- // Public API
- /**
- * Registers a new listener, described by an object containing the following keys
- * @param {boolean} [beginning_only] true to enable autocomplete only at first position of input
- * @param {char} [delimiter] the mention delimiter
- * @param {function} [fetch_callback] the callback to fetch mention suggestions
- * @param {boolean} [generate_links] true to wrap mentions in <a> links
- * @param {string} [model] (optional) the model used for redirection
- * @param {string} [redirect_classname] (optional) the classname of the <a> wrapping the mention
- * @param {array} [selection] (optional) initial mentions for each listener
- * @param {string} [suggestion_template] the template of suggestions' dropdown
- */
- register: function (listener) {
- this.listeners.push(_.defaults(listener, {
- model: '',
- redirect_classname: '',
- selection: [],
- }));
- },
-
- /**
- * Returns true if the mention suggestions dropdown is open, false otherwise
- */
- is_open: function () {
- return this.open;
- },
-
- /**
- * Returns the mentions of the given listener that haven't been erased from the composer's input
- */
- get_listener_selection: function (delimiter) {
- var listener = _.findWhere(this.listeners, {delimiter: delimiter});
- if (listener) {
- var input_mentions = this.composer.$input.val().match(new RegExp(delimiter+'[^ ]+(?= |&nbsp;)', 'g'));
- return this._validate_selection(listener.selection, input_mentions);
- }
- return [];
- },
-
- get_listener_selections: function () {
- var selections = {};
- _.each(this.listeners, function (listener) {
- selections[listener.delimiter] = listener.selection;
- });
- return selections;
- },
-
- /**
- * Detects if the user is currently typing a mention word
- * @return the search string if it is, false otherwise
- */
- detect_delimiter: function () {
- var self = this;
- var text_val = this.composer.$input.val();
- var cursor_position = this._get_selection_positions().start;
- var left_string = text_val.substring(0, cursor_position);
- function validate_keyword (delimiter, beginning_only) {
- var delimiter_position = left_string.lastIndexOf(delimiter) - 1;
- if (beginning_only && delimiter_position > 0) {
- return false;
- }
- var search_str = text_val.substring(delimiter_position, cursor_position);
- var pattern = "(^"+delimiter+"|(^\\s"+delimiter+"))";
- var regex_start = new RegExp(pattern, "g");
- search_str = search_str.replace(/^\s\s*|^[\n\r]/g, '');
- if (regex_start.test(search_str) && search_str.length > self.options.min_length) {
- search_str = search_str.replace(pattern, '');
- return search_str.indexOf(' ') < 0 && !/[\r\n]/.test(search_str) ? search_str.replace(delimiter, '') : false;
- }
- return false;
- }
-
- this.active_listener = undefined;
- for (var i=0; i<this.listeners.length; i++) {
- var listener = this.listeners[i];
- this.mention_word = validate_keyword(listener.delimiter, listener.beginning_only);
-
- if (this.mention_word !== false) {
- this.active_listener = listener;
- break;
- }
- }
-
- if (this.active_listener) {
- var mention_word = this.mention_word;
- $.when(this.active_listener.fetch_callback(mention_word)).then(function (suggestions) {
- if (mention_word === self.mention_word) {
- // update suggestions only if mention_word didn't change in the meantime
- self.set('mention_suggestions', suggestions);
- }
- });
- } else {
- this.set('mention_suggestions', []); // close the dropdown
- }
- },
-
- /**
- * Replaces mentions appearing in the string 's' by html links with proper redirection
- */
- generate_links: function (s) {
- var self = this;
- var base_href = session.url("/web");
- var mention_link = "<a href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'>%s%s</a>";
- _.each(this.listeners, function (listener) {
- if (!listener.generate_links) {
- return;
- }
- var selection = listener.selection;
- if (selection.length) {
- var matches = self._get_match(s, listener);
- var substrings = [];
- var start_index = 0;
- for (var i=0; i<matches.length; i++) {
- var match = matches[i];
- var end_index = match.index + match[0].length;
- var selection_id = self.get_selection_id(match, selection);
- // put back white spaces instead of non-breaking spaces in mention's name
- var match_name = match[0].substring(1).replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' ');
- var href = base_href + _.str.sprintf("#model=%s&id=%s", listener.model, selection_id);
- var processed_text = _.str.sprintf(mention_link, href, listener.redirect_classname, selection_id, listener.model, listener.delimiter, match_name);
- var subtext = s.substring(start_index, end_index).replace(match[0], processed_text);
- substrings.push(subtext);
- start_index = end_index;
- }
- substrings.push(s.substring(start_index, s.length));
- s = substrings.join('');
- }
- });
- return s;
- },
-
- get_selection_id: function (match, selection) {
- return _.findWhere(selection, {'name': match[0].slice(1)}).id;
- },
-
- reset_suggestions: function () {
- this.set('mention_suggestions', []);
- },
- reset_selections: function () {
- _.each(this.listeners, function (listener) {
- listener.selection = [];
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Returns the matches (as RexExp.exec does) for the mention in the input text
- * @param {String} input_text: the text to search matches
- * @param {Object} listener: the listener for which we want to find a match
- * @returns {Object[]} matches in the same format as RexExp.exec()
- */
- _get_match: function (input_text, listener) {
- // create the regex of all mention's names
- var names = _.pluck(listener.selection, 'name');
- var escaped_names = _.map(names, function (str) {
- return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
- });
- var regex_str = escaped_names.join('|');
- // extract matches
- var result = [];
- if (regex_str.length) {
- var myRegexp = new RegExp(regex_str, 'g');
- var match = myRegexp.exec(input_text);
- while (match !== null) {
- result.push(match);
- match = myRegexp.exec(input_text);
- }
- }
- return result;
- },
- /**
- * @private
- * @param {integer} keycode
- */
- _propositionNavigation: function (keycode) {
- var $active = this.$('.o_mention_proposition.active');
- if (keycode === $.ui.keyCode.ENTER) { // selecting proposition
- $active.click();
- } else { // navigation in propositions
- var $to;
- if (keycode === $.ui.keyCode.DOWN) {
- $to = $active.nextAll('.o_mention_proposition').first();
- } else if (keycode === $.ui.keyCode.UP) {
- $to = $active.prevAll('.o_mention_proposition').first();
- } else if (keycode === $.ui.keyCode.TAB) {
- $to = $active.nextAll('.o_mention_proposition').first();
- if (!$to.length) {
- $to = $active.prevAll('.o_mention_proposition').last();
- }
- }
- if ($to && $to.length) {
- $active.removeClass('active');
- $to.addClass('active');
- }
- }
- },
- _render_suggestions: function () {
- var suggestions = [];
- if (_.find(this.get('mention_suggestions'), _.isArray)) {
- // Array of arrays -> Flatten and insert dividers between groups
- var insert_divider = false;
- _.each(this.get('mention_suggestions'), function (suggestion_group) {
- if (suggestion_group.length > 0) {
- if (insert_divider) {
- suggestions.push({ divider: true });
- }
- suggestions = suggestions.concat(suggestion_group);
- insert_divider = true;
- }
- });
- } else {
- suggestions = this.get('mention_suggestions');
- }
- if (suggestions.length) {
- this.$el.html(QWeb.render(this.active_listener.suggestion_template, {
- suggestions: suggestions,
- }));
- this.$el
- .addClass('open')
- .find('ul').css("max-width", this.composer.$input.width())
- .find('.o_mention_proposition').first().addClass('active');
- this.open = true;
- } else {
- this.$el.removeClass('open');
- this.$el.empty();
- this.open = false;
- }
- },
- _validate_selection: function (selection, input_mentions) {
- var validated_selection = [];
- _.each(input_mentions, function (mention) {
- var validated_mention = _.findWhere(selection, {name: mention.slice(1)});
- if (validated_mention) {
- validated_selection.push(validated_mention);
- }
- });
- return validated_selection;
- },
-
- // Cursor position and selection utils
- _get_selection_positions: function () {
- var el = this.composer.$input.get(0);
- return el ? dom.getSelectionRange(el) : {start: 0, end: 0};
- },
- _set_cursor_position: function (pos) {
- this.composer.$input.each(function (index, elem) {
- dom.setSelectionRange(elem, {start: pos, end: pos});
- });
- },
-
-});
-
-var BasicComposer = Widget.extend({
- template: "mail.ChatComposer",
- events: {
- "keydown .o_composer_input textarea": "on_keydown",
- "keyup .o_composer_input": "on_keyup",
- "change input.o_input_file": "on_attachment_change",
- "click .o_composer_button_send": "send_message",
- "click .o_composer_button_add_attachment": "on_click_add_attachment",
- "click .o_attachment_delete": "on_attachment_delete",
- "click .o_attachment_download": "_onAttachmentDownload",
- "click .o_attachment_view": "_onAttachmentView",
- 'click .o_composer_button_emoji': '_onEmojiButtonClick',
- 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout',
- 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus',
- 'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick',
- },
- // RPCs done to fetch the mention suggestions are throttled with the following value
- MENTION_THROTTLE: 200,
-
- init: function (parent, options) {
- this._super.apply(this, arguments);
- this.options = _.defaults(options || {}, {
- commands_enabled: true,
- context: {},
- input_baseline: 18,
- input_max_height: 150,
- input_min_height: 28,
- mention_fetch_limit: 8,
- mention_partners_restricted: false, // set to true to only suggest prefetched partners
- send_text: _t('Send'),
- default_body: '',
- default_mention_selections: {},
- isMobile: config.device.isMobile
- });
- this.context = this.options.context;
-
- // Attachments
- this.AttachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context);
- this.fileupload_id = _.uniqueId('o_chat_fileupload');
- this.set('attachment_ids', []);
-
- // Mention
- this.mention_manager = new MentionManager(this);
- this.mention_manager.register({
- delimiter: MENTION_PARTNER_DELIMITER,
- fetch_callback: this.mention_fetch_partners.bind(this),
- generate_links: true,
- model: 'res.partner',
- redirect_classname: 'o_mail_redirect',
- selection: this.options.default_mention_selections[MENTION_PARTNER_DELIMITER],
- suggestion_template: 'mail.MentionPartnerSuggestions',
- });
- this.mention_manager.register({
- delimiter: MENTION_CHANNEL_DELIMITER,
- fetch_callback: this.mention_fetch_channels.bind(this),
- generate_links: true,
- model: 'mail.channel',
- redirect_classname: 'o_channel_redirect',
- selection: this.options.default_mention_selections[MENTION_CHANNEL_DELIMITER],
- suggestion_template: 'mail.MentionChannelSuggestions',
- });
- this.mention_manager.register({
- delimiter: MENTION_CANNED_RESPONSE_DELIMITER,
- fetch_callback: this.mention_get_canned_responses.bind(this),
- selection: this.options.default_mention_selections[MENTION_CANNED_RESPONSE_DELIMITER],
- suggestion_template: 'mail.MentionCannedResponseSuggestions',
- });
- if (this.options.commands_enabled) {
- this.mention_manager.register({
- beginning_only: true,
- delimiter: MENTION_COMMAND_DELIMITER,
- fetch_callback: this.mention_get_commands.bind(this),
- selection: this.options.default_mention_selections[MENTION_COMMAND_DELIMITER],
- suggestion_template: 'mail.MentionCommandSuggestions',
- });
- }
-
- this.isMini = options.isMini;
-
- this.avatarURL = session.uid > 0 ? session.url('/web/image', {
- model: 'res.users',
- field: 'image_small',
- id: session.uid,
- }) : '/web/static/src/img/user_menu_avatar.png';
- },
-
- start: function () {
- var self = this;
-
- this.$attachment_button = this.$(".o_composer_button_add_attachment");
- this.$attachments_list = this.$('.o_composer_attachments_list');
- this.$input = this.$('.o_composer_input textarea');
- this.$input.focus(function () {
- self.trigger('input_focused');
- });
- this.$input.val(this.options.default_body);
- dom.autoresize(this.$input, {parent: this, min_height: this.options.input_min_height});
-
- // Attachments
- $(window).on(this.fileupload_id, this.on_attachment_loaded);
- this.on("change:attachment_ids", this, this.render_attachments);
-
- // Mention
- this.mention_manager.prependTo(this.$('.o_composer'));
-
- return this._super();
- },
-
- destroy: function () {
- $(window).off(this.fileupload_id);
- return this._super.apply(this, arguments);
- },
-
- preprocess_message: function () {
- // Return a deferred as this function is extended with asynchronous
- // behavior for the chatter composer
-
- //Removing unwanted extra spaces from message
- var value = _.escape(this.$input.val()).trim();
- value = value.replace(/(\r|\n){2,}/g, '<br/><br/>');
- value = value.replace(/(\r|\n)/g, '<br/>');
-
- // prevent html space collapsing
- value = value.replace(/ /g, '&nbsp;').replace(/([^>])&nbsp;([^<])/g, '$1 $2');
- var commands = this.options.commands_enabled ? this.mention_manager.get_listener_selection('/') : [];
- return $.when({
- content: this.mention_manager.generate_links(value),
- attachment_ids: _.pluck(this.get('attachment_ids'), 'id'),
- partner_ids: _.uniq(_.pluck(this.mention_manager.get_listener_selection('@'), 'id')),
- command: commands.length > 0 ? commands[0].name : undefined,
- });
- },
-
- send_message: function () {
- if (this.is_empty() || !this.do_check_attachment_upload()) {
- return;
- }
-
- clearTimeout(this.canned_timeout);
- var self = this;
- this.preprocess_message().then(function (message) {
- self.trigger('post_message', message);
- self.clear_composer_on_send();
- self.$input.focus();
- });
- },
-
- clear_composer: function() {
- // Empty input, selected partners and attachments
- this.$input.val('');
- this.mention_manager.reset_selections();
- this.set('attachment_ids', []);
- },
-
- clear_composer_on_send: function() {
- this.clear_composer();
- },
-
- getState: function () {
- return {
- attachments: this.get('attachment_ids'),
- text: this.$input.val(),
- };
- },
-
- // Events
- on_click_add_attachment: function () {
- this.$('input.o_input_file').click();
- this.$input.focus();
- // set ignoreEscape to avoid escape_pressed event when file selector dialog is opened
- // when user press escape to cancel file selector dialog then escape_pressed event should not be trigerred
- this.ignoreEscape = true;
- },
-
- setState: function (state) {
- this.set('attachment_ids', state.attachments);
- this.$input.val(state.text);
- },
-
- /**
- * Send the message on ENTER, but go to new line on SHIFT+ENTER
- */
- should_send: function (event) {
- return !event.shiftKey;
- },
-
- on_keydown: function (event) {
- switch(event.which) {
- // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions
- case $.ui.keyCode.UP:
- case $.ui.keyCode.DOWN:
- case $.ui.keyCode.TAB:
- if (this.mention_manager.is_open()) {
- event.preventDefault();
- }
- break;
- // ENTER: submit the message only if the dropdown mention proposition is not displayed
- case $.ui.keyCode.ENTER:
- if (this.mention_manager.is_open()) {
- event.preventDefault();
- } else {
- var send_message = event.ctrlKey || this.should_send(event);
- if (send_message) {
- event.preventDefault();
- this.send_message();
- }
- }
- break;
- }
- },
-
- on_keyup: function (event) {
- switch(event.which) {
- // ESCAPED KEYS: do nothing
- case $.ui.keyCode.END:
- case $.ui.keyCode.PAGE_UP:
- case $.ui.keyCode.PAGE_DOWN:
- break;
- // ESCAPE: close mention propositions
- case $.ui.keyCode.ESCAPE:
- if (this.mention_manager.is_open()) {
- event.stopPropagation();
- this.mention_manager.reset_suggestions();
- } else if (this.ignoreEscape) {
- this.ignoreEscape = false;
- } else {
- this.trigger_up("escape_pressed");
- }
- break;
- // ENTER, UP, DOWN, TAB: check if navigation in mention propositions
- case $.ui.keyCode.ENTER:
- case $.ui.keyCode.UP:
- case $.ui.keyCode.DOWN:
- case $.ui.keyCode.TAB:
- if (this.mention_manager.is_open()) {
- this.mention_manager._propositionNavigation(event.which);
- }
- break;
- // Otherwise, check if a mention is typed
- default:
- this.mention_manager.detect_delimiter();
- }
- },
-
- // Attachments
- on_attachment_change: function(event) {
- var self = this,
- files = event.target.files,
- attachments = self.get('attachment_ids');
-
- _.each(files, function(file){
- var attachment = _.findWhere(attachments, {name: file.name});
- // if the files already exits, delete the file before upload
- if(attachment){
- self.AttachmentDataSet.unlink([attachment.id]);
- attachments = _.without(attachments, attachment);
- }
- });
-
- this.$('form.o_form_binary_form').submit();
- this.$attachment_button.prop('disabled', true);
- var upload_attachments = _.map(files, function(file){
- return {
- 'id': 0,
- 'name': file.name,
- 'filename': file.name,
- 'url': '',
- 'upload': true,
- 'mimetype': '',
- };
- });
- attachments = attachments.concat(upload_attachments);
- this.set('attachment_ids', attachments);
- },
- on_attachment_loaded: function(event) {
- var self = this,
- attachments = this.get('attachment_ids'),
- files = Array.prototype.slice.call(arguments, 1);
-
- _.each(files, function(file){
- if(file.error || !file.id){
- this.do_warn(file.error);
- attachments = _.filter(attachments, function (attachment) { return !attachment.upload; });
- }else{
- var attachment = _.findWhere(attachments, {filename: file.filename, upload: true});
- if(attachment){
- attachments = _.without(attachments, attachment);
- attachments.push({
- 'id': file.id,
- 'name': file.name || file.filename,
- 'filename': file.filename,
- 'mimetype': file.mimetype,
- 'url': session.url('/web/content', {'id': file.id, download: true}),
- });
- }
- }
- }.bind(this));
- this.set('attachment_ids', attachments);
- this.$attachment_button.prop('disabled', false);
- },
- on_attachment_delete: function(event){
- event.stopPropagation();
- var self = this;
- var attachment_id = $(event.target).data("id");
- if (attachment_id) {
- var attachments = [];
- _.each(this.get('attachment_ids'), function(attachment){
- if (attachment_id !== attachment.id) {
- attachments.push(attachment);
- } else {
- self.AttachmentDataSet.unlink([attachment_id]);
- }
- });
- this.set('attachment_ids', attachments);
- }
- },
- do_check_attachment_upload: function () {
- if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) {
- this.do_warn(_t("Uploading error"), _t("Please, wait while the file is uploading."));
- return false;
- }
- return true;
- },
- render_attachments: function() {
- this.$attachments_list.html(QWeb.render('mail.ChatComposer.Attachments', {
- attachments: this.get('attachment_ids'),
- }));
- },
-
- // Mention
- mention_fetch_throttled: function (model, method, kwargs) {
- var self = this;
- // Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing
- var def = $.Deferred();
- clearTimeout(this.mention_fetch_timer);
- this.mention_fetch_timer = setTimeout(function () {
- return self._rpc({model: model, method: method, kwargs: kwargs})
- .then(function (results) {
- def.resolve(results);
- });
- }, this.MENTION_THROTTLE);
- return def;
- },
- mention_fetch_channels: function (search) {
- return this.mention_fetch_throttled('mail.channel', 'get_mention_suggestions', {
- limit: this.options.mention_fetch_limit,
- search: search,
- }).then(function (suggestions) {
- return _.partition(suggestions, function (suggestion) {
- return _.contains(['public', 'groups'], suggestion.public);
- });
- });
- },
- mention_fetch_partners: function (search) {
- var self = this;
- return $.when(this.mention_prefetched_partners).then(function (prefetched_partners) {
- // filter prefetched partners with the given search string
- var suggestions = [];
- var limit = self.options.mention_fetch_limit;
- var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i');
- _.each(prefetched_partners, function (partners) {
- if (limit > 0) {
- var filtered_partners = _.filter(partners, function (partner) {
- return partner.email && search_regexp.test(partner.email) ||
- partner.name && search_regexp.test(utils.unaccent(partner.name));
- });
- if (filtered_partners.length) {
- suggestions.push(filtered_partners.slice(0, limit));
- limit -= filtered_partners.length;
- }
- }
- });
- if (!suggestions.length && !self.options.mention_partners_restricted) {
- // no result found among prefetched partners, fetch other suggestions
- suggestions = self.mention_fetch_throttled('res.partner', 'get_mention_suggestions', {
- limit: limit,
- search: search,
- });
- }
- return suggestions;
- });
- },
- mention_get_canned_responses: function (search) {
- var self = this;
- var def = $.Deferred();
- clearTimeout(this.canned_timeout);
- this.canned_timeout = setTimeout(function () {
- var cannedResponses = self.call('chat_manager', 'getCannedResponses');
- var matches = fuzzy.filter(utils.unaccent(search), _.pluck(cannedResponses, 'source'));
- var indexes = _.pluck(matches.slice(0, self.options.mention_fetch_limit), 'index');
- def.resolve(_.map(indexes, function (i) {
- return cannedResponses[i];
- }));
- }, 500);
- return def;
- },
- mention_get_commands: function (search) {
- var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i');
- return _.filter(this.mention_commands, function (command) {
- return search_regexp.test(command.name);
- }).slice(0, this.options.mention_fetch_limit);
- },
- mention_set_prefetched_partners: function (prefetched_partners) {
- this.mention_prefetched_partners = prefetched_partners;
- },
- mention_set_enabled_commands: function (commands) {
- this.mention_commands = commands;
- },
- mention_get_listener_selections: function () {
- return this.mention_manager.get_listener_selections();
- },
-
- // Others
- is_empty: function () {
- return !this.$input.val().trim() && !this.$('.o_attachments').children().length;
- },
- focus: function () {
- this.$input.focus();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Hides the emojis container.
- *
- * @private
- */
- _hideEmojis: function () {
- this.$emojisContainer.remove();
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onAttachmentDownload: function (event) {
- event.stopPropagation();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onAttachmentView: function (event) {
- event.stopPropagation();
- var activeAttachmentID = $(event.currentTarget).data('id');
- var attachments = this.get('attachment_ids');
- if (activeAttachmentID) {
- var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID);
- attachmentViewer.appendTo($('body'));
- }
- },
- /**
- * Called when the emoji button is clicked -> opens/hides the emoji panel.
- * Also, this method is in charge of the rendering of this panel the first
- * time it is opened.
- *
- * @private
- */
- _onEmojiButtonClick: function () {
- if (!this.$emojisContainer) { // lazy rendering
- this.$emojisContainer = $(QWeb.render('mail.ChatComposer.emojis', {
- emojis: this.call('chat_manager', 'getEmojis'),
- }));
- }
- if (this.$emojisContainer.parent().length) {
- this._hideEmojis();
- } else {
- this.$emojisContainer.appendTo(this.$('.o_composer'));
- }
- },
- /**
- * Called when the emoji button is blurred -> closes the emoji panel. The
- * closing is scheduled to be done at the end of the current execution
- * stack to allow stoping this closing if the button was focusout to select
- * an emoji (for example).
- *
- * @private
- */
- _onEmojiButtonFocusout: function () {
- if (this.$emojisContainer) {
- this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0);
- }
- },
- /**
- * Called when an emoji is focused -> @see _onEmojiButtonFocusout
- *
- * @private
- */
- _onEmojiImageFocus: function () {
- clearTimeout(this._hideEmojisTimeout);
- },
- /**
- * Called when an emoji is clicked -> adds it in the <input/>, focuses the
- * <input/> and closes the emoji panel.
- *
- * @private
- * @param {Event} ev
- */
- _onEmojiImageClick: function (ev) {
- this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " ");
- this.$input.focus();
- this._hideEmojis();
- },
-});
-
-var ExtendedComposer = BasicComposer.extend({
- init: function (parent, options) {
- options = _.defaults(options || {}, {
- input_min_height: 120,
- });
- this._super(parent, options);
- this.extended = true;
- },
-
- start: function () {
- this.$subject_input = this.$(".o_composer_subject input");
- return this._super.apply(this, arguments);
- },
-
- preprocess_message: function () {
- var self = this;
- return this._super().then(function (message) {
- var subject = self.$subject_input.val();
- self.$subject_input.val("");
- message.subject = subject;
- return message;
- });
- },
- clear_composer: function () {
- this._super.apply(this, arguments);
- this.$subject_input.val('');
- },
- getState: function () {
- var state = this._super.apply(this, arguments);
- state.subject = this.$subject_input.val();
- return state;
- },
- should_send: function () {
- return false;
- },
- focus: function (target) {
- if (target === 'body') {
- this.$input.focus();
- } else {
- this.$subject_input.focus();
- }
- },
- set_subject: function(subject) {
- this.$('.o_composer_subject input').val(subject);
- },
- setState: function (state) {
- this._super.apply(this, arguments);
- this.set_subject(state.subject);
- },
-});
-
-return {
- BasicComposer: BasicComposer,
- ExtendedComposer: ExtendedComposer,
-};
-
-});
diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js
new file mode 100644
index 00000000000..ce7c1017ffa
--- /dev/null
+++ b/addons/mail/static/src/js/composers/basic_composer.js
@@ -0,0 +1,640 @@
+odoo.define('mail.composer.Basic', function (require) {
+"use strict";
+
+var emojis = require('mail.emojis');
+var MentionManager = require('mail.composer.MentionManager');
+var DocumentViewer = require('mail.DocumentViewer');
+var mailUtils = require('mail.utils');
+
+var config = require('web.config');
+var core = require('web.core');
+var data = require('web.data');
+var dom = require('web.dom');
+var session = require('web.session');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var BasicComposer = Widget.extend({
+ template: "mail.Composer",
+ events: {
+ 'change input.o_input_file': '_onAttachmentChange',
+ 'click .o_attachment_delete': '_onAttachmentDelete',
+ 'click .o_attachment_download': '_onAttachmentDownload',
+ 'click .o_attachment_view': '_onAttachmentView',
+ 'click .o_composer_button_add_attachment': '_onClickAddAttachment',
+ 'click .o_composer_button_emoji': '_onEmojiButtonClick',
+ 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout',
+ 'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick',
+ 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus',
+ 'keydown .o_composer_input textarea': '_onKeydown',
+ 'keyup .o_composer_input': '_onKeyup',
+ 'click .o_composer_button_send': '_sendMessage',
+ },
+ // RPCs done to fetch the mention suggestions are throttled with the
+ // following value
+ MENTION_THROTTLE: 200,
+
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.options = _.defaults(options || {}, {
+ commandsEnabled: true,
+ context: {},
+ inputMinHeight: 28,
+ mentionFetchLimit: 8,
+ // set to true to only suggest prefetched partners
+ mentionPartnersRestricted: false,
+ sendText: _t("Send"),
+ defaultBody: '',
+ defaultMentionSelections: {},
+ isMobile: config.device.isMobile
+ });
+ this.context = this.options.context;
+
+ // Attachments
+ this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context);
+ this.fileuploadID = _.uniqueId('o_chat_fileupload');
+ this.set('attachment_ids', []);
+
+ // Mention
+ this._mentionManager = new MentionManager(this);
+ this._mentionManager.register({
+ delimiter: '@',
+ fetchCallback: this._mentionFetchPartners.bind(this),
+ generateLinks: true,
+ model: 'res.partner',
+ redirectClassname: 'o_mail_redirect',
+ selection: this.options.defaultMentionSelections['@'],
+ suggestionTemplate: 'mail.MentionSuggestions.Partner',
+ });
+ this._mentionManager.register({
+ delimiter: '#',
+ fetchCallback: this._mentionFetchChannels.bind(this),
+ generateLinks: true,
+ model: 'mail.channel',
+ redirectClassname: 'o_channel_redirect',
+ selection: this.options.defaultMentionSelections['#'],
+ suggestionTemplate: 'mail.MentionSuggestions.Channel',
+ });
+ this._mentionManager.register({
+ delimiter: ':',
+ fetchCallback: this._mentionGetCannedResponses.bind(this),
+ selection: this.options.defaultMentionSelections[':'],
+ suggestionTemplate: 'mail.MentionSuggestions.CannedResponse',
+ });
+ if (this.options.commandsEnabled) {
+ this._mentionManager.register({
+ beginningOnly: true,
+ delimiter: '/',
+ fetchCallback: this._mentionGetCommands.bind(this),
+ selection: this.options.defaultMentionSelections['/'],
+ suggestionTemplate: 'mail.MentionSuggestions.Command',
+ });
+ }
+
+ this.isMini = options.isMini;
+
+ this.avatarURL = session.uid > 0 ? session.url('/web/image', {
+ model: 'res.users',
+ field: 'image_small',
+ id: session.uid,
+ }) : '/web/static/src/img/user_menu_avatar.png';
+ },
+ start: function () {
+ var self = this;
+
+ this._$attachmentButton = this.$('.o_composer_button_add_attachment');
+ this._$attachmentsList = this.$('.o_composer_attachments_list');
+ this.$input = this.$('.o_composer_input textarea');
+ this.$input.focus(function () {
+ self.trigger('input_focused');
+ });
+ this.$input.val(this.options.defaultBody);
+ dom.autoresize(this.$input, {
+ parent: this,
+ min_height: this.options.inputMinHeight
+ });
+
+ // Attachments
+ $(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this));
+ this.on('change:attachment_ids', this, this._renderAttachments);
+
+ // Mention
+ this._mentionManager.prependTo(this.$('.o_composer'));
+
+ return this._super();
+ },
+ destroy: function () {
+ $(window).off(this.fileuploadID);
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Empty input, selected partners and attachments
+ */
+ clearComposer: function () {
+ this.$input.val('');
+ this._mentionManager.resetSelections();
+ this.set('attachment_ids', []);
+ },
+ /**
+ * Set the focus on the input
+ */
+ focus: function () {
+ this.$input.focus();
+ },
+ /**
+ * Get the state of the composer.
+ *
+ * This is useful in order to (re)store its state when switchng to another
+ * thread with a composer, and coming back to the thread with this composer.
+ *
+ * @returns {Object}
+ */
+ getState: function () {
+ return {
+ attachments: this.get('attachment_ids'),
+ text: this.$input.val(),
+ };
+ },
+ /**
+ * @returns {integer} it returns length of attachment and trim content of
+ * input box to check for empty.
+ */
+ isEmpty: function () {
+ return !this.$input.val().trim() &&
+ !this.$('.o_attachments').children().length;
+ },
+ /**
+ * Set the state of the composer.
+ *
+ * This is useful in order to (re)store its state when switching to another
+ * thread with a composer, and coming back to the thread with this composer.
+ *
+ * @param {Object} state
+ * @param {Array} state.attachments
+ * @param {string} state.text
+ */
+ setState: function (state) {
+ this.set('attachment_ids', state.attachments);
+ this.$input.val(state.text);
+ },
+ /**
+ * Set the list of command suggestions on the thread.
+ *
+ * When the user partially types a command, matching commands are displayed
+ * to the user (@see _mentionGetCommands).
+ *
+ * @param {Object[]} commands
+ */
+ mentionSetCommands: function (commands) {
+ this._mentionCommands = commands;
+ },
+ /**
+ * Set the list of partner mentions suggestions that has been prefetched.
+ *
+ * When the user partially types a mention, matching prefetched partners are
+ * displayed to the user. If none of them match, then it will fetch for more
+ * partner suggestions (@see _mentionFetchPartners).
+ *
+ * @param {$.Deferred<Object[]>} prefetchedPartners
+ */
+ mentionSetPrefetchedPartners: function (prefetchedPartners) {
+ this._mentionPrefetchedPartners = prefetchedPartners;
+ },
+ /**
+ * @returns {Object} containing listener selections, grouped by delimiter
+ * as key of the object.
+ */
+ getMentionListenerSelections: function () {
+ return this._mentionManager.getListenerSelections();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _clearComposerOnSend: function () {
+ this.clearComposer();
+ },
+ /**
+ * @private
+ */
+ _doCheckAttachmentUpload: function () {
+ if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) {
+ this.do_warn(_t("Uploading error"), _t("Please, wait while the file is uploading."));
+ return false;
+ }
+ return true;
+ },
+ /**
+ * Hides the emojis container.
+ *
+ * @private
+ */
+ _hideEmojis: function () {
+ this._$emojisContainer.remove();
+ },
+ /**
+ * @private
+ * @param {string} search
+ * @returns {$.Deferred<Object[]>}
+ */
+ _mentionGetCannedResponses: function (search) {
+ var self = this;
+ var def = $.Deferred();
+ clearTimeout(this._cannedTimeout);
+ this._cannedTimeout = setTimeout(function () {
+ var cannedResponses = self.call('mail_service', 'getCannedResponses');
+ var matches = fuzzy.filter(mailUtils.unaccent(search), _.pluck(cannedResponses, 'source'));
+ var indexes = _.pluck(matches.slice(0, self.options.mentionFetchLimit), 'index');
+ def.resolve(_.map(indexes, function (index) {
+ return cannedResponses[index];
+ }));
+ }, 500);
+ return def;
+ },
+ /**
+ * @private
+ * @param {string} search
+ * @returns {Array}
+ */
+ _mentionGetCommands: function (search) {
+ var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i');
+ return _.filter(this._mentionCommands, function (command) {
+ return searchRegexp.test(command.name);
+ }).slice(0, this.options.mentionFetchLimit);
+ },
+ /**
+ * @private
+ * @param {string} search
+ */
+ _mentionFetchChannels: function (search) {
+ return this._mentionFetchThrottled('mail.channel', 'get_mention_suggestions', {
+ limit: this.options.mentionFetchLimit,
+ search: search,
+ }).then(function (suggestions) {
+ return _.partition(suggestions, function (suggestion) {
+ return _.contains(['public', 'groups'], suggestion.public);
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {string} search
+ */
+ _mentionFetchPartners: function (search) {
+ var self = this;
+ return $.when(this._mentionPrefetchedPartners).then(function (partners) {
+ // filter prefetched partners with the given search string
+ var suggestions = [];
+ var limit = self.options.mentionFetchLimit;
+ var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i');
+ if (limit > 0) {
+ var filteredPartners = _.filter(partners, function (partner) {
+ return partner.email && searchRegexp.test(partner.email) ||
+ partner.name && searchRegexp.test(mailUtils.unaccent(partner.name));
+ });
+ if (filteredPartners.length) {
+ suggestions.push(filteredPartners.slice(0, limit));
+ limit -= filteredPartners.length;
+ }
+ }
+ if (!suggestions.length && !self.options.mentionPartnersRestricted) {
+ // no result found among prefetched partners, fetch other suggestions
+ suggestions = self._mentionFetchThrottled(
+ 'res.partner',
+ 'get_mention_suggestions',
+ { limit: limit, search: search }
+ );
+ }
+ return suggestions;
+ });
+ },
+ /**
+ * @private
+ * @param {string} model
+ * @param {string} method
+ * @param {Object} kwargs
+ * @return {$.Deferred}
+ */
+ _mentionFetchThrottled: function (model, method, kwargs) {
+ var self = this;
+ // Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing
+ var def = $.Deferred();
+ clearTimeout(this.mentionFetchTimer);
+ this.mentionFetchTimer = setTimeout(function () {
+ return self._rpc({model: model, method: method, kwargs: kwargs})
+ .then(function (results) {
+ def.resolve(results);
+ });
+ }, this.MENTION_THROTTLE);
+ return def;
+ },
+ /**
+ * @private
+ * @returns {$.Deferred}
+ */
+ _preprocessMessage: function () {
+ // Return a deferred as this function is extended with asynchronous
+ // behavior for the chatter composer
+
+ //Removing unwanted extra spaces from message
+ var value = _.escape(this.$input.val()).trim();
+ value = value.replace(/(\r|\n){2,}/g, '<br/><br/>');
+ value = value.replace(/(\r|\n)/g, '<br/>');
+
+ // prevent html space collapsing
+ value = value.replace(/ /g, '&nbsp;').replace(/([^>])&nbsp;([^<])/g, '$1 $2');
+ var commands = this.options.commandsEnabled ?
+ this._mentionManager.getListenerSelection('/') :
+ [];
+ return $.when({
+ content: this._mentionManager.generateLinks(value),
+ attachment_ids: _.pluck(this.get('attachment_ids'), 'id'),
+ partner_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelection('@'), 'id')),
+ command: commands.length > 0 ? commands[0].name : undefined,
+ });
+ },
+ /**
+ * @private
+ */
+ _renderAttachments: function () {
+ this._$attachmentsList.html(QWeb.render('mail.composer.Attachments', {
+ attachments: this.get('attachment_ids'),
+ }));
+ },
+ /**
+ * @private
+ */
+ _sendMessage: function () {
+ if (this.isEmpty() || !this._doCheckAttachmentUpload()) {
+ return;
+ }
+
+ clearTimeout(this._cannedTimeout);
+ var self = this;
+ this._preprocessMessage().then(function (message) {
+ self.trigger('post_message', message);
+ self._clearComposerOnSend();
+ self.$input.focus();
+ });
+ },
+ /**
+ * Send the message on ENTER, but go to new line on SHIFT+ENTER
+ *
+ * @private
+ */
+ _shouldSend: function (ev) {
+ return !ev.shiftKey;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQuery.Event} ev
+ */
+ _onAttachmentChange: function (ev) {
+ var self = this;
+ var files = ev.target.files;
+ var attachments = this.get('attachment_ids');
+
+ _.each(files, function (file){
+ var attachment = _.findWhere(attachments, {name: file.name});
+ // if the files already exits, delete the file before upload
+ if (attachment){
+ self._attachmentDataSet.unlink([attachment.id]);
+ attachments = _.without(attachments, attachment);
+ }
+ });
+
+ this.$('form.o_form_binary_form').submit();
+ this._$attachmentButton.prop('disabled', true);
+ var uploadAttachments = _.map(files, function (file){
+ return {
+ id: 0,
+ name: file.name,
+ filename: file.name,
+ url: '',
+ upload: true,
+ mimetype: '',
+ };
+ });
+ attachments = attachments.concat(uploadAttachments);
+ this.set('attachment_ids', attachments);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAttachmentDelete: function (ev){
+ ev.stopPropagation();
+ var self = this;
+ var attachmentID = $(ev.target).data('id');
+ if (attachmentID) {
+ var attachments = [];
+ _.each(this.get('attachment_ids'), function (attachment){
+ if (attachmentID !== attachment.id) {
+ attachments.push(attachment);
+ } else {
+ self._attachmentDataSet.unlink([attachmentID]);
+ }
+ });
+ this.set('attachment_ids', attachments);
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAttachmentDownload: function (ev) {
+ ev.stopPropagation();
+ },
+ /**
+ * @private
+ */
+ _onAttachmentLoaded: function () {
+ var attachments = this.get('attachment_ids');
+ var files = Array.prototype.slice.call(arguments, 1);
+
+ _.each(files, function (file) {
+ if (file.error || !file.id){
+ this.do_warn(file.error);
+ attachments = _.filter(attachments, function (attachment) {
+ return !attachment.upload;
+ });
+ } else {
+ var attachment = _.findWhere(attachments, { filename: file.filename, upload: true });
+ if (attachment) {
+ attachments = _.without(attachments, attachment);
+ attachments.push({
+ id: file.id,
+ name: file.name || file.filename,
+ filename: file.filename,
+ mimetype: file.mimetype,
+ url: session.url('/web/content', { id: file.id, download: true }),
+ });
+ }
+ }
+ }.bind(this));
+ this.set('attachment_ids', attachments);
+ this._$attachmentButton.prop('disabled', false);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAttachmentView: function (ev) {
+ var activeAttachmentID = $(ev.currentTarget).data('id');
+ var attachments = this.get('attachment_ids');
+ if (activeAttachmentID) {
+ var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID);
+ attachmentViewer.appendTo($('body'));
+ }
+ },
+ _onClickAddAttachment: function () {
+ this.$('input.o_input_file').click();
+ this.$input.focus();
+ },
+ /**
+ * Called when the emoji button is clicked -> opens/hides the emoji panel.
+ * Also, this method is in charge of the rendering of this panel the first
+ * time it is opened.
+ *
+ * @private
+ */
+ _onEmojiButtonClick: function () {
+ if (!this._$emojisContainer) { // lazy rendering
+ this._$emojisContainer = $(QWeb.render('mail.Composer.emojis', {
+ emojis: emojis,
+ }));
+ }
+ if (this._$emojisContainer.parent().length) {
+ this._hideEmojis();
+ } else {
+ this._$emojisContainer.appendTo(this.$('.o_composer'));
+ }
+ },
+ /**
+ * Called when the emoji button is blurred -> closes the emoji panel. The
+ * closing is scheduled to be done at the end of the current execution
+ * stack to allow stoping this closing if the button was focusout to select
+ * an emoji (for example).
+ *
+ * @private
+ */
+ _onEmojiButtonFocusout: function () {
+ if (this._$emojisContainer) {
+ this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0);
+ }
+ },
+ /**
+ * Called when an emoji is clicked -> adds it in the <input/>, focuses the
+ * <input/> and closes the emoji panel.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onEmojiImageClick: function (ev) {
+ this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " ");
+ this.$input.focus();
+ this._hideEmojis();
+ },
+ /**
+ * Called when an emoji is focused -> @see _onEmojiButtonFocusout
+ *
+ * @private
+ */
+ _onEmojiImageFocus: function () {
+ clearTimeout(this._hideEmojisTimeout);
+ },
+ /**
+ * _onKeydown event is triggered when is key is pressed
+ * - on UP and DOWN arrow is pressed then event prevents it's default
+ * behaviour if mention manager is open else it break it.
+ * - on ENTER key is pressed and mention manager is open then event
+ * prevents it's default behaviour else check if ControlKey is
+ * pressed or not if yes then it send a message
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown: function (ev) {
+ switch (ev.which) {
+ // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN:
+ case $.ui.keyCode.TAB:
+ if (this._mentionManager.isOpen()) {
+ ev.preventDefault();
+ }
+ break;
+ // ENTER: submit the message only if the dropdown mention proposition is not displayed
+ case $.ui.keyCode.ENTER:
+ if (this._mentionManager.isOpen()) {
+ ev.preventDefault();
+ } else {
+ var sendMessage = ev.ctrlKey || this._shouldSend(ev);
+ if (sendMessage) {
+ ev.preventDefault();
+ this._sendMessage();
+ }
+ }
+ break;
+ }
+ },
+ /**
+ * _onKeyup event is triggered when key is released.
+ * on ESCAP key it close the mention suggestion dropdown menu.
+ * on ENTER key it selects that mention and add to a content editable div.
+ * on UP or DOWN key it highlights previous or next mention respectively.
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeyup: function (ev) {
+ switch (ev.which) {
+ // ESCAPED KEYS: do nothing
+ case $.ui.keyCode.END:
+ case $.ui.keyCode.PAGE_UP:
+ case $.ui.keyCode.PAGE_DOWN:
+ break;
+ // ESCAPE: close mention propositions
+ case $.ui.keyCode.ESCAPE:
+ if (this._mentionManager.isOpen()) {
+ ev.stopPropagation();
+ this._mentionManager.resetSuggestions();
+ } else {
+ this.trigger_up('escape_pressed');
+ }
+ break;
+ // ENTER, UP, DOWN, TAB: check if navigation in mention propositions
+ case $.ui.keyCode.ENTER:
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN:
+ case $.ui.keyCode.TAB:
+ if (this._mentionManager.isOpen()) {
+ this._mentionManager.propositionNavigation(ev.which);
+ }
+ break;
+ // Otherwise, check if a mention is typed
+ default:
+ this._mentionManager.detectDelimiter();
+ }
+ },
+});
+
+return BasicComposer;
+
+});
diff --git a/addons/mail/static/src/js/composers/chatter_composer.js b/addons/mail/static/src/js/composers/chatter_composer.js
new file mode 100644
index 00000000000..d1a990b31e4
--- /dev/null
+++ b/addons/mail/static/src/js/composers/chatter_composer.js
@@ -0,0 +1,295 @@
+odoo.define('mail.composer.Chatter', function (require) {
+"use strict";
+
+var BasicComposer = require('mail.composer.Basic');
+var mailUtils = require('mail.utils');
+
+var core = require('web.core');
+var viewDialogs = require('web.view_dialogs');
+
+var _t = core._t;
+
+/**
+ * Chat Composer for the Chatter
+ *
+ * Extends the basic Composer Widget to add 'suggested partner' layer (open
+ * popup when suggested partner is selected without email, or other
+ * informations), and the button to open the full composer wizard.
+ */
+var ChatterComposer = BasicComposer.extend({
+ template: 'mail.chatter.Composer',
+ events: _.extend({}, BasicComposer.prototype.events, {
+ 'click .o_composer_button_full_composer': '_onOpenFullComposer',
+ }),
+ init: function (parent, model, suggestedPartners, options) {
+ this._super(parent, options);
+ this._model = model;
+ this.suggestedPartners = suggestedPartners;
+ this.options = _.defaults(this.options, {
+ display_mode: 'textarea',
+ recordName: false,
+ isLog: false,
+ });
+ if (this.options.isLog) {
+ this.options.sendText = _t("Log");
+ }
+ this.notInline = true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Chatter don't clear message on sent but after successful sent
+ *
+ * @override
+ */
+ clearComposerOnSend: function () {},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Check the additional partners (not necessary registered partners), and
+ * open a popup form view for the ones who informations is missing.
+ *
+ * @private
+ * @param {Array} checkedSuggestedPartners list of 'recipient' partners to
+ * complete informations or validate
+ * @returns {Deferred} resolved with the list of checked suggested partners
+ * (real partner)
+ **/
+ _checkSuggestedPartners: function (checkedSuggestedPartners) {
+ var self = this;
+ var checkDone = $.Deferred();
+
+ var recipients = _.filter(checkedSuggestedPartners, function (recipient) {
+ return recipient.checked;
+ });
+ var recipientsToFind = _.filter(recipients, function (recipient) {
+ return (! recipient.partner_id);
+ });
+ var namesToFind = _.pluck(recipientsToFind, 'full_name');
+ var recipientsToCheck = _.filter(recipients, function (recipient) {
+ return (recipient.partner_id && ! recipient.email_address);
+ });
+ var recipientIDs = _.pluck(_.filter(recipients, function (recipient) {
+ return recipient.partner_id && recipient.email_address;
+ }), 'partner_id');
+
+ var namesToRemove = [];
+ var recipientIDsToRemove = [];
+
+ // have unknown names
+ // -> call message_get_partner_info_from_emails to try to find
+ // partner_id
+ var def;
+ if (namesToFind.length > 0) {
+ def = this._rpc({
+ model: this._model,
+ method: 'message_partner_info_from_emails',
+ args: [[this.context.default_res_id], namesToFind],
+ });
+ }
+
+ // for unknown names + incomplete partners
+ // -> open popup - cancel = remove from recipients
+ $.when(def).pipe(function (result) {
+ result = result || [];
+ var emailDefs = [];
+ var recipientPopups = result.concat(recipientsToCheck);
+
+ _.each(recipientPopups, function (partnerInfo) {
+ var deferred = $.Deferred();
+ emailDefs.push(deferred);
+
+ var partnerName = partnerInfo.full_name;
+ var partnerID = partnerInfo.partner_id;
+ var parsedEmail = mailUtils.parseEmail(partnerName);
+
+ var dialog = new viewDialogs.FormViewDialog(self, {
+ res_model: 'res.partner',
+ res_id: partnerID,
+ context: {
+ active_model: self._model,
+ active_id: self.context.default_res_id,
+ force_email: true,
+ ref: 'compound_context',
+ default_name: parsedEmail[0],
+ default_email: parsedEmail[1],
+ },
+ title: _t("Please complete customer's informations"),
+ disable_multiple_selection: true,
+ }).open();
+ dialog.on('closed', self, function () {
+ deferred.resolve();
+ });
+ dialog.opened().then(function () {
+ dialog.form_view.on('on_button_cancel', self, function () {
+ namesToRemove.push(partnerName);
+ if (partnerID) {
+ recipientIDsToRemove.push(partnerID);
+ }
+ });
+ });
+ });
+ $.when.apply($, emailDefs).then(function () {
+ var newNamesToFind = _.difference(namesToFind, namesToRemove);
+ var def;
+ if (newNamesToFind.length > 0) {
+ def = self._rpc({
+ model: self._model,
+ method: 'message_partner_info_from_emails',
+ args: [[self.context.default_res_id], newNamesToFind, true],
+ });
+ }
+ $.when(def).pipe(function (result) {
+ result = result || [];
+ var recipientPopups = result.concat(recipientsToCheck);
+ _.each(recipientPopups, function (partnerInfo) {
+ if (
+ partnerInfo.partner_id &&
+ _.indexOf(partnerInfo.partner_id, recipientIDsToRemove) === -1
+ ) {
+ recipientIDs.push(partnerInfo.partner_id);
+ }
+ });
+ }).pipe(function () {
+ checkDone.resolve(recipientIDs);
+ });
+ });
+ });
+ return checkDone;
+ },
+ /**
+ * Get the list of selected suggested partners
+ *
+ * @private
+ * @returns {Array} list of 'recipient' selected partners (may not be
+ * created in db)
+ **/
+ _getCheckedSuggestedPartners: function () {
+ var self = this;
+ var checkedPartners = [];
+ this.$('.o_composer_suggested_partners input:checked').each(function () {
+ var fullName = $(this).data('fullname');
+ checkedPartners = checkedPartners.concat(
+ _.filter(self.suggestedPartners, function (item) {
+ return fullName === item.full_name;
+ })
+ );
+ });
+ return checkedPartners;
+ },
+ /**
+ * @override
+ * @private
+ * @returns {$.Deferred}
+ */
+ _preprocessMessage: function () {
+ var self = this;
+ var def = $.Deferred();
+ this._super().then(function (message) {
+ message = _.extend(message, {
+ subtype: 'mail.mt_comment',
+ message_type: 'comment',
+ context: self.context,
+ });
+
+ // Subtype
+ if (self.options.isLog) {
+ message.subtype = 'mail.mt_note';
+ }
+
+ // Partner_ids
+ if (!self.options.isLog) {
+ var checkedSuggestedPartners = self._getCheckedSuggestedPartners();
+ self._checkSuggestedPartners(checkedSuggestedPartners).done(function (partnerIDs) {
+ message.partner_ids = (message.partner_ids || []).concat(partnerIDs);
+ // update context
+ message.context = _.defaults({}, message.context, {
+ mail_post_autofollow: true,
+ });
+ def.resolve(message);
+ });
+ } else {
+ def.resolve(message);
+ }
+
+ });
+
+ return def;
+ },
+ /**
+ * @override
+ * @private
+ */
+ _shouldSend: function () {
+ return false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onOpenFullComposer: function () {
+ if (!this._doCheckAttachmentUpload()){
+ return false;
+ }
+
+ var self = this;
+ var recipientDoneDef = $.Deferred();
+
+ // any operation on the full-composer will reload the record, so
+ // warn the user that any unsaved changes on the record will be lost.
+ this.trigger_up('discard_record_changes', {
+ proceed: function () {
+ if (self.options.isLog) {
+ recipientDoneDef.resolve([]);
+ } else {
+ var checkedSuggestedPartners = self._getCheckedSuggestedPartners();
+ self._checkSuggestedPartners(checkedSuggestedPartners)
+ .then(recipientDoneDef.resolve.bind(recipientDoneDef));
+ }
+ },
+ });
+
+ recipientDoneDef.then(function (partnerIDs) {
+ var context = {
+ default_parent_id: self.id,
+ default_body: mailUtils.getTextToHTML(self.$input.val()),
+ default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'),
+ default_partner_ids: partnerIDs,
+ default_is_log: self.options.isLog,
+ mail_post_autofollow: true,
+ };
+
+ if (self.context.default_model && self.context.default_res_id) {
+ context.default_model = self.context.default_model;
+ context.default_res_id = self.context.default_res_id;
+ }
+
+ var action = {
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ view_mode: 'form',
+ view_type: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: context,
+ };
+ self.do_action(action, {
+ on_close: self.trigger.bind(self, 'need_refresh'),
+ }).then(self.trigger.bind(self, 'close_composer'));
+ });
+ }
+});
+
+return ChatterComposer;
+
+});
diff --git a/addons/mail/static/src/js/composers/extended_composer.js b/addons/mail/static/src/js/composers/extended_composer.js
new file mode 100644
index 00000000000..f6e8560da8f
--- /dev/null
+++ b/addons/mail/static/src/js/composers/extended_composer.js
@@ -0,0 +1,100 @@
+odoo.define('mail.composer.Extended', function (require) {
+"use strict";
+
+var BasicComposer = require('mail.composer.Basic');
+
+var ExtendedComposer = BasicComposer.extend({
+ /**
+ * @override
+ */
+ init: function (parent, options) {
+ options = _.defaults(options || {}, {
+ inputMinHeight: 120,
+ });
+ this._super(parent, options);
+ this.extended = true;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._$subjectInput = this.$('.o_composer_subject input');
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ clearComposer: function () {
+ this._super.apply(this, arguments);
+ this._$subjectInput.val('');
+ },
+ /**
+ * @override
+ * @param {string} target
+ */
+ focus: function (target) {
+ if (target === 'body') {
+ this.$input.focus();
+ } else {
+ this._$subjectInput.focus();
+ }
+ },
+ /**
+ * @override
+ */
+ getState: function () {
+ var state = this._super.apply(this, arguments);
+ state.subject = this._$subjectInput.val();
+ return state;
+ },
+ /**
+ * @override
+ * @param {Object} state
+ * @param {string} state.subject
+ */
+ setState: function (state) {
+ this._super.apply(this, arguments);
+ this.setSubject(state.subject);
+ },
+ /**
+ * @param {string} subject
+ */
+ setSubject: function (subject) {
+ this.$('.o_composer_subject input').val(subject);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ * @returns {$.Deferred}
+ */
+ _preprocessMessage: function () {
+ var self = this;
+ return this._super().then(function (message) {
+ var subject = self._$subjectInput.val();
+ self._$subjectInput.val("");
+ message.subject = subject;
+ return message;
+ });
+ },
+ /**
+ * @override
+ * @private
+ */
+ _shouldSend: function () {
+ return false;
+ },
+});
+
+return ExtendedComposer;
+
+});
diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js
new file mode 100644
index 00000000000..fb8d9ddc065
--- /dev/null
+++ b/addons/mail/static/src/js/composers/mention_manager.js
@@ -0,0 +1,432 @@
+odoo.define('mail.composer.MentionManager', function (require) {
+"use strict";
+
+var core = require('web.core');
+var dom = require('web.dom');
+var session = require('web.session');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+var NON_BREAKING_SPACE = '\u00a0';
+
+// The MentionManager allows the Composer to register listeners. For each
+// listener, it detects if the user is currently typing a mention (starting by a
+// given delimiter). If so, if fetches mention suggestions and renders them. On
+// suggestion clicked, it updates the selection for the corresponding listener.
+var MentionManager = Widget.extend({
+ className: 'dropup o_composer_mention_dropdown',
+ events: {
+ 'click .o_mention_proposition': '_onClickMentionItem',
+ 'mouseover .o_mention_proposition': '_onHoverMentionProposition',
+ },
+ init: function (parent) {
+ this._super.apply(this, arguments);
+
+ this._composer = parent;
+
+ this._open = false;
+ this._listeners = [];
+ this.set('mention_suggestions', []);
+ this.on('change:mention_suggestions', this, this._renderSuggestions);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns true if the mention suggestions dropdown is open, false otherwise
+ *
+ * @returns {boolean}
+ */
+ isOpen: function () {
+ return this._open;
+ },
+ /**
+ * Returns the mentions of the given listener that haven't been erased from
+ * the composer's input
+ *
+ * @returns {Array}
+ */
+ getListenerSelection: function (delimiter) {
+ var listener = _.findWhere(this._listeners, { delimiter: delimiter });
+ if (listener) {
+ var inputMentions = this._composer
+ .$input
+ .val()
+ .match(new RegExp(delimiter+'[^ ]+(?= |&nbsp;)', 'g'));
+ return this._validateSelection(listener.selection, inputMentions);
+ }
+ return [];
+ },
+ /**
+ * @returns {Object} containing listener selections, grouped by delimiter
+ * as key of the object.
+ */
+ getListenerSelections: function () {
+ var selections = {};
+ _.each(this._listeners, function (listener) {
+ selections[listener.delimiter] = listener.selection;
+ });
+ return selections;
+ },
+ /**
+ * Detects if the user is currently typing a mention word
+ */
+ detectDelimiter: function () {
+ var self = this;
+ var textVal = this._composer.$input.val();
+ var cursorPosition = this._getSelectionPositions().start;
+ var leftString = textVal.substring(0, cursorPosition);
+
+ function validateKeyword(delimiter, beginningOnly) {
+ // use position before delimiter because there should be whitespaces
+ // or line feed/carriage return before the delimiter
+ var beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1;
+ if (beginningOnly && beforeDelimiterPosition > 0) {
+ return false;
+ }
+ var searchStr = textVal.substring(beforeDelimiterPosition, cursorPosition);
+ // regex string start with delimiter or whitespace then delimiter
+ var pattern = "^"+delimiter+"|^\\s"+delimiter;
+ var regexStart = new RegExp(pattern, 'g');
+ // trim any left whitespaces or the left line feed/ carriage return
+ // at the beginning of the string
+ searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, '');
+ if (regexStart.test(searchStr) && searchStr.length) {
+ searchStr = searchStr.replace(pattern, '');
+ return searchStr.indexOf(' ') < 0 && !/[\r\n]/.test(searchStr) ?
+ searchStr.replace(delimiter, '') :
+ false;
+ }
+ return false;
+ }
+
+ this._activeListener = undefined;
+ for (var i = 0; i < this._listeners.length; i++) {
+ var listener = this._listeners[i];
+ this._mentionWord = validateKeyword(listener.delimiter, listener.beginningOnly);
+
+ if (this._mentionWord !== false) {
+ this._activeListener = listener;
+ break;
+ }
+ }
+
+ if (this._activeListener) {
+ var mentionWord = this._mentionWord;
+ $.when(this._activeListener.fetchCallback(mentionWord))
+ .then(function (suggestions) {
+ if (mentionWord === self._mentionWord) {
+ // update suggestions only if mentionWord didn't change
+ // in the meantime
+ self.set('mention_suggestions', suggestions);
+ }
+ });
+ } else {
+ this.set('mention_suggestions', []); // close the dropdown
+ }
+ },
+ /**
+ * Replaces mentions appearing in the string 's' by html links with proper
+ * redirection
+ *
+ * @param {string} s
+ * @returns {string}
+ */
+ generateLinks: function (s) {
+ var self = this;
+ var baseHREF = session.url('/web');
+ var linkParams = "href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'";
+ var mentionLink = "<a " + linkParams + " >" +
+ "%s%s" +
+ "</a>";
+ _.each(this._listeners, function (listener) {
+ if (!listener.generateLinks) {
+ return;
+ }
+ var selection = listener.selection;
+ if (selection.length) {
+ var matches = self._getMatch(s, listener);
+ var substrings = [];
+ var startIndex = 0;
+ for (var i = 0; i < matches.length; i++) {
+ var match = matches[i];
+ var endIndex = match.index + match[0].length;
+ var selectionID = self._getSelectionID(match, selection);
+ // put back white spaces instead of non-breaking spaces in
+ // mention's name
+ var matchName = match[0].substring(1)
+ .replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' ');
+ var href = baseHREF +
+ _.str.sprintf("#model=%s&id=%s", listener.model, selectionID);
+ var processedText = _.str.sprintf(mentionLink,
+ href,
+ listener.redirectClassname,
+ selectionID,
+ listener.model,
+ listener.delimiter,
+ matchName);
+ var subtext = s.substring(startIndex, endIndex)
+ .replace(match[0], processedText);
+ substrings.push(subtext);
+ startIndex = endIndex;
+ }
+ substrings.push(s.substring(startIndex, s.length));
+ s = substrings.join('');
+ }
+ });
+ return s;
+ },
+ /**
+ * @param {integer} keycode
+ */
+ propositionNavigation: function (keycode) {
+ var $active = this.$('.o_mention_proposition.active');
+ if (keycode === $.ui.keyCode.ENTER) {
+ // selecting proposition
+ $active.click();
+ } else {
+ // navigation in propositions
+ var $to;
+ if (keycode === $.ui.keyCode.DOWN) {
+ $to = $active.nextAll('.o_mention_proposition').first();
+ } else if (keycode === $.ui.keyCode.UP) {
+ $to = $active.prevAll('.o_mention_proposition').first();
+ } else if (keycode === $.ui.keyCode.TAB) {
+ $to = $active.nextAll('.o_mention_proposition').first();
+ if (!$to.length) {
+ $to = $active.prevAll('.o_mention_proposition').last();
+ }
+ }
+ if ($to && $to.length) {
+ $active.removeClass('active');
+ $to.addClass('active');
+ }
+ }
+ },
+ /**
+ * Registers a new listener, described by an object containing the following
+ * keys
+ *
+ * @param {Object} listener
+ * @param {boolean} [listener.beginningOnly] true to enable autocomplete
+ * only at first position of input
+ * @param {char} [listener.delimiter] the mention delimiter
+ * @param {function} [listener.fetchCallback] the callback to fetch mention
+ * suggestions
+ * @param {boolean} [listener.generateLinks] true to wrap mentions in <a>
+ * links
+ * @param {string} [listener.model=''] (optional) the model used for
+ * redirection
+ * @param {string} [listener.redirectClassname=''] (optional) the classname
+ * of the <a> wrapping the mention
+ * @param {Array} [listener.selection=[]] (optional) initial mentions for
+ * each listener
+ * @param {string} [listener.suggestionTemplate] the template of
+ * suggestions' dropdown
+ */
+ register: function (listener) {
+ this._listeners.push(_.defaults(listener, {
+ model: '',
+ redirectClassname: '',
+ selection: [],
+ }));
+ },
+ resetSuggestions: function () {
+ this.set('mention_suggestions', []);
+ },
+ resetSelections: function () {
+ _.each(this._listeners, function (listener) {
+ listener.selection = [];
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns the matches (as RexExp.exec does) for the mention in the input
+ * text
+ *
+ * @private
+ * @param {string} inputText: the text to search matches
+ * @param {Object} listener: the listener for which we want to find a match
+ * @returns {Object[]} matches in the same format as RexExp.exec()
+ */
+ _getMatch: function (inputText, listener) {
+ // create the regex of all mention's names
+ var names = _.pluck(listener.selection, 'name');
+ var escapedNames = _.map(names, function (str) {
+ return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
+ });
+ var regexStr = escapedNames.join('|');
+ // extract matches
+ var result = [];
+ if (regexStr.length) {
+ var myRegexp = new RegExp(regexStr, 'g');
+ var match = myRegexp.exec(inputText);
+ while (match !== null) {
+ result.push(match);
+ match = myRegexp.exec(inputText);
+ }
+ }
+ return result;
+ },
+ /**
+ * @private
+ * @param {string[]} match
+ * @param {Object} selection
+ * @returns {Object}
+ */
+ _getSelectionID: function (match, selection) {
+ return _.findWhere(selection, { name: match[0].slice(1) }).id;
+ },
+ /**
+ * Get cursor position and selection
+ *
+ * @private
+ * @returns a current cursor position
+ */
+ _getSelectionPositions: function () {
+ var InputElement = this._composer.$input.get(0);
+ return InputElement ? dom.getSelectionRange(InputElement) : { start: 0, end: 0 };
+ },
+ /**
+ * @private
+ */
+ _renderSuggestions: function () {
+ var suggestions = [];
+ if (_.find(this.get('mention_suggestions'), _.isArray)) {
+ // Array of arrays -> Flatten and insert dividers between groups
+ var insertDivider = false;
+ _.each(this.get('mention_suggestions'), function (suggestionGroup) {
+ if (suggestionGroup.length > 0) {
+ if (insertDivider) {
+ suggestions.push({ divider: true });
+ }
+ suggestions = suggestions.concat(suggestionGroup);
+ insertDivider = true;
+ }
+ });
+ } else {
+ suggestions = this.get('mention_suggestions');
+ }
+ if (suggestions.length) {
+ this.$el.html(QWeb.render(this._activeListener.suggestionTemplate, {
+ suggestions: suggestions,
+ }));
+ this.$el
+ .addClass('open')
+ .find('ul')
+ .css('max-width', this._composer.$input.width())
+ .find('.o_mention_proposition')
+ .first()
+ .addClass('active');
+ this._open = true;
+ } else {
+ this.$el.removeClass('open');
+ this.$el.empty();
+ this._open = false;
+ }
+ },
+ /*
+ * Set cursor position
+ *
+ * @private
+ * @param {integer} pos
+ */
+ _setCursorPosition: function (pos) {
+ this._composer.$input.each(function (index, elem) {
+ dom.setSelectionRange(elem, { start: pos, end: pos });
+ });
+ },
+ /**
+ * @private
+ * @param {Object} selection
+ * @param {Array} inputMentions
+ * @returns {Array}
+ */
+ _validateSelection: function (selection, inputMentions) {
+ var validatedSelection = [];
+ _.each(inputMentions, function (mention) {
+ var validatedMention = _.findWhere(selection, { name: mention.slice(1) });
+ if (validatedMention) {
+ validatedSelection.push(validatedMention);
+ }
+ });
+ return validatedSelection;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev - to get a current selected mention from list
+ * and eventlistener.
+ */
+ _onClickMentionItem: function (ev) {
+ ev.preventDefault();
+
+ var textInput = this._composer.$input.val();
+ var id = $(ev.currentTarget).data('id');
+ var suggestions = _.flatten(this.get('mention_suggestions'));
+ var selectedSuggestion = _.find(suggestions, function (s) {
+ return s.id === id;
+ });
+ var substitution = selectedSuggestion.substitution;
+ if (!substitution) {
+ // no substitution string given, so use the mention name instead
+ // replace white spaces with non-breaking spaces to facilitate
+ // mentions detection in text
+ selectedSuggestion.name = selectedSuggestion.name.replace(/ /g, NON_BREAKING_SPACE);
+ substitution = this._activeListener.delimiter + selectedSuggestion.name;
+ }
+ var getMentionIndex = function (matches, cursorPosition) {
+ for (var i = 0; i < matches.length; i++) {
+ if (cursorPosition <= matches[i].index) {
+ return i;
+ }
+ }
+ return i;
+ };
+
+ // add the selected suggestion to the list
+ if (this._activeListener.selection.length) {
+ // get mention matches (ordered by index in the text)
+ var matches = this._getMatch(textInput, this._activeListener);
+ var index = getMentionIndex(matches, this._getSelectionPositions().start);
+ this._activeListener.selection.splice(index, 0, selectedSuggestion);
+ } else {
+ this._activeListener.selection.push(selectedSuggestion);
+ }
+
+ // update input text, and reset dropdown
+ var cursorPosition = this._getSelectionPositions().start;
+ var textLeft = textInput.substring(0, cursorPosition-(this._mentionWord.length+1));
+ var textRight = textInput.substring(cursorPosition, textInput.length);
+ var textInputNew = textLeft + substitution + ' ' + textRight;
+ this._composer.$input.val(textInputNew);
+ this._setCursorPosition(textLeft.length+substitution.length+2);
+ this.set('mention_suggestions', []);
+ this._composer.focus(); // to trigger autoresize
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onHoverMentionProposition: function (ev) {
+ var $elem = $(ev.currentTarget);
+ this.$('.o_mention_proposition').removeClass('active');
+ $elem.addClass('active');
+ },
+
+});
+
+return MentionManager;
+
+});
diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js
index def90b01595..ba0ad31a849 100644
--- a/addons/mail/static/src/js/discuss.js
+++ b/addons/mail/static/src/js/discuss.js
@@ -1,8 +1,9 @@
-odoo.define('mail.chat_discuss', function (require) {
+odoo.define('mail.Discuss', function (require) {
"use strict";
-var ChatThread = require('mail.ChatThread');
-var composer = require('mail.composer');
+var BasicComposer = require('mail.composer.Basic');
+var ExtendedComposer = require('mail.composer.Extended');
+var ThreadWidget = require('mail.widget.Thread');
var AbstractAction = require('web.AbstractAction');
var config = require('web.config');
@@ -26,24 +27,24 @@ var _t = core._t;
* validated.
*/
var PartnerInviteDialog = Dialog.extend({
- dialog_title: _t('Invite people'),
- template: "mail.PartnerInviteDialog",
+ dialog_title: _t("Invite people"),
+ template: 'mail.PartnerInviteDialog',
/**
* @override
* @param {integer|string} channelID id of the channel,
- * a string for static channels (e.g. 'channel_inbox').
+ * a string for static channels (e.g. 'mailbox_inbox').
*/
init: function (parent, title, channelID) {
- this.channelID = channelID;
+ this._channelID = channelID;
this._super(parent, {
title: title,
- size: "medium",
+ size: 'medium',
buttons: [{
text: _t("Invite"),
close: true,
- classes: "btn-primary",
+ classes: 'btn-primary',
click: this._addChannel.bind(this),
}],
});
@@ -53,17 +54,17 @@ var PartnerInviteDialog = Dialog.extend({
*/
start: function () {
var self = this;
- this.$input = this.$('.o_mail_chat_partner_invite_input');
+ this.$input = this.$('.o_mail_discuss_partner_invite_input');
this.$input.select2({
width: '100%',
allowClear: true,
multiple: true,
formatResult: function (item) {
- var status = QWeb.render('mail.chat.UserStatus', {status: item.im_status});
+ var status = QWeb.render('mail.UserStatus', { status: item.im_status });
return $('<span>').text(item.text).prepend(status);
},
query: function (query) {
- self.call('chat_manager', 'searchPartner', query.term, 20)
+ self.call('mail_service', 'searchPartner', query.term, 20)
.then(function (partners) {
query.callback({
results: _.map(partners, function (partner) {
@@ -91,16 +92,20 @@ var PartnerInviteDialog = Dialog.extend({
return this._rpc({
model: 'mail.channel',
method: 'channel_invite',
- args: [this.channelID],
+ args: [this._channelID],
kwargs: {partner_ids: _.pluck(data, 'id')},
}).then(function () {
var names = _.escape(_.pluck(data, 'text').join(', '));
- var notification = _.str.sprintf(_t('You added <b>%s</b> to the conversation.'), names);
- self.do_notify(_t('New people'), notification);
- // Clear the membersDeferred to fetch again the partner
- // when getMentionPartnerSuggestions from the chatManager is triggered
- var channel = self.call('chat_manager', 'getChannel', self.channelID);
- delete channel.membersDeferred;
+ var notification =
+ _.str.sprintf(
+ _t("You added <b>%s</b> to the conversation."),
+ names
+ );
+ self.do_notify(_t("New people"), notification);
+ // Update list of members with the invited user, so that
+ // we can mention this user in this channel right away.
+ var channel = self.call('mail_service', 'getChannel', self._channelID);
+ channel.forceFetchMembers();
});
}
},
@@ -113,7 +118,7 @@ var PartnerInviteDialog = Dialog.extend({
* This let the moderator provide a reason for rejecting the messages.
*/
var ModeratorRejectMessageDialog = Dialog.extend({
- template: "mail.ModeratorRejectMessageDialog",
+ template: 'mail.ModeratorRejectMessageDialog',
/**
* @override
@@ -135,12 +140,12 @@ var ModeratorRejectMessageDialog = Dialog.extend({
this._messageIDs = params.messageIDs;
this._proceedReject = params.proceedReject;
this._super(parent, {
- title: _t('Send explanation to author'),
- size: "medium",
+ title: _t("Send explanation to author"),
+ size: 'medium',
buttons: [{
text: _t("Send"),
close: true,
- classes: "btn-primary",
+ classes: 'btn-primary',
click: _.bind(this._onSendClicked, this),
}],
});
@@ -175,14 +180,14 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
update_moderation_buttons: '_onUpdateModerationButtons',
},
events: {
- 'blur .o_mail_add_channel input': '_onAddChannelBlur',
+ 'blur .o_mail_add_thread input': '_onAddThreadBlur',
'click .o_mail_annoying_notification_bar .fa-close': '_onCloseNotificationBar',
- 'click .o_mail_chat_channel_item': '_onChannelClicked',
+ 'click .o_mail_discuss_item': '_onDiscussItemClicked',
'click .o_mail_open_channels': '_onPublicChannelsClick',
'click .o_mail_partner_unpin': '_onUnpinChannel',
'click .o_mail_channel_settings': '_onChannelSettingsClicked',
'click .o_mail_request_permission': '_onRequestNotificationPermission',
- 'click .o_mail_sidebar_title .o_add': '_onAddChannel',
+ 'click .o_mail_sidebar_title .o_add': '_onAddThread',
'keydown': '_onKeydown',
},
@@ -191,71 +196,92 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
*/
init: function (parent, action, options) {
this._super.apply(this, arguments);
+
+ this.action = action;
this.action_manager = parent;
this.dataset = new data.DataSetSearch(this, 'mail.message');
+ this.displayNotificationBar = (
+ window.Notification &&
+ window.Notification.permission === 'default'
+ );
this.domain = [];
- this.action = action;
this.options = options || {};
- this.channelsScrolltop = {};
- this.throttledUpdateChannels = _.throttle(this._updateChannels.bind(this), 100, { leading: false });
- this.notification_bar = (window.Notification && window.Notification.permission === "default");
- this.selected_message = null;
- this.composerStates = {};
- this.defaultChannelID = this.options.active_id ||
- this.action.context.active_id ||
- this.action.params.default_active_id ||
- 'channel_inbox';
+
+ this._threadsScrolltop = {};
+ this._composerStates = {};
+ this._defaultThreadID = this.options.active_id ||
+ this.action.context.active_id ||
+ this.action.params.default_active_id ||
+ 'mailbox_inbox';
+ this._selectedMessage = null;
+ this._throttledUpdateThreads = _.throttle(
+ this._updateThreads.bind(this), 100, { leading: false });
},
/**
* @override
*/
willStart: function () {
var self = this;
- var viewID = this.action && this.action.search_view_id && this.action.search_view_id[0];
+ var viewID = this.action &&
+ this.action.search_view_id &&
+ this.action.search_view_id[0];
var def = this
.loadFieldView(this.dataset, viewID, 'search')
- .then(function (fields_view) {
- self.fields_view = fields_view;
+ .then(function (fieldsView) {
+ self.fields_view = fieldsView;
});
- return $.when(this._super(), this.call('chat_manager', 'isReady'), def);
+ return $.when(this._super(), this.call('mail_service', 'isReady'), def);
},
/**
* @override
*/
start: function () {
var self = this;
- var defaultChannel = this.call('chat_manager', 'getChannel', this.defaultChannelID) ||
- this.call('chat_manager', 'getChannel', 'channel_inbox');
- this.basicComposer = new composer.BasicComposer(this, {mention_partners_restricted: true});
- this.extendedComposer = new composer.ExtendedComposer(this, {mention_partners_restricted: true});
- this.basicComposer.on('post_message', this, this._onPostMessage);
- this.basicComposer.on('input_focused', this, this._onComposerFocused);
- this.extendedComposer.on('post_message', this, this._onPostMessage);
- this.extendedComposer.on('input_focused', this, this._onComposerFocused);
+ this._basicComposer = new BasicComposer(this,
+ { mentionPartnersRestricted: true }
+ );
+ this._extendedComposer = new ExtendedComposer(this,
+ { mentionPartnersRestricted: true }
+ );
+ this._basicComposer
+ .on('post_message', this, this._onPostMessage)
+ .on('input_focused', this, this._onComposerFocused);
+ this._extendedComposer
+ .on('post_message', this, this._onPostMessage)
+ .on('input_focused', this, this._onComposerFocused);
this._renderButtons();
var defs = [];
- defs.push(this._renderThread());
- defs.push(this.basicComposer.appendTo(this.$('.o_mail_chat_content')));
- defs.push(this.extendedComposer.appendTo(this.$('.o_mail_chat_content')));
+ defs.push(
+ this._renderThread()
+ );
+ defs.push(
+ this._basicComposer.appendTo(this.$('.o_mail_discuss_content')));
+ defs.push(
+ this._extendedComposer.appendTo(this.$('.o_mail_discuss_content')));
defs.push(this._renderSearchView());
return this.alive($.when.apply($, defs))
.then(function () {
- return self.alive(self._setChannel(defaultChannel));
+ return self.alive(self._setThread(self._defaultThreadID));
})
.then(function () {
- self._updateChannels();
+ self._updateThreads();
self._startListening();
- self.thread.$el.on("scroll", null, _.debounce(function () {
- if (self.thread.get_scrolltop() < 20 &&
- !self.thread.$('.o_mail_no_content').length &&
- !self.call('chat_manager', 'isAllHistoryLoaded', self.channel, self.domain)) {
+ self._threadWidget.$el.on('scroll', null, _.debounce(function () {
+ if (
+ self._threadWidget.getScrolltop() < 20 &&
+ !self._threadWidget.$('.o_mail_no_content').length &&
+ !self._thread.isAllHistoryLoaded(self.domain)
+ ) {
self._loadMoreMessages();
}
- if (self.thread.is_at_bottom()) {
- self.call('chat_manager', 'markChannelAsSeen', self.channel);
+ if (
+ self._threadWidget.isAtBottom() &&
+ self._thread.getType() !== 'mailbox'
+ ) {
+ self._thread.markAsRead();
}
}, 100));
});
@@ -268,7 +294,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
this._updateControlPanel();
this.action_manager.do_push_state({
action: this.action.id,
- active_id: this.channel.id,
+ active_id: this._thread.getID(),
});
},
/**
@@ -284,9 +310,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @override
*/
on_attach_callback: function () {
- this.call('chat_manager', 'getChatBus').trigger('discuss_open', true);
- if (this.channel) {
- this.thread.scroll_to({offset: this.channelsScrolltop[this.channel.id]});
+ this.call('mail_service', 'getMailBus').trigger('discuss_open', true);
+ if (this._thread) {
+ this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]);
}
this._loadEnoughMessages();
},
@@ -294,8 +320,8 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @override
*/
on_detach_callback: function () {
- this.call('chat_manager', 'getChatBus').trigger('discuss_open', false);
- this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop();
+ this.call('mail_service', 'getMailBus').trigger('discuss_open', false);
+ this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop();
},
//--------------------------------------------------------------------------
@@ -312,9 +338,12 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
_banAuthorsFromMessageIDs: function (messageIDs) {
var self = this;
var emailList = _.map(messageIDs, function (messageID) {
- return self.call('chat_manager', 'getMessage', messageID).email_from;
+ return self.call('mail_service', 'getMessage', messageID).getEmailFrom();
}).join(", ");
- var text = _.str.sprintf(_t("You are going to ban: %s. Do you confirm the action?"), emailList);
+ var text = _.str.sprintf(
+ _t("You are going to ban: %s. Do you confirm the action?"),
+ emailList
+ );
var options = {
confirm_callback: function () {
self._moderateMessages(messageIDs, 'ban');
@@ -334,7 +363,10 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
var num = messageIDs.length;
var text;
if (num > 1) {
- text = _.str.sprintf(_t("You are going to discard %s messages. Do you confirm the action?"), num);
+ text = _.str.sprintf(
+ _t("You are going to discard %s messages. Do you confirm the action?"),
+ num
+ );
} else if (num === 1) {
text = _t("You are going to discard 1 message. Do you confirm the action?");
}
@@ -351,42 +383,51 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
*/
_fetchAndRenderThread: function () {
var self = this;
- return this.call('chat_manager', 'getMessages', {
- channelID: this.channel.id,
- domain: this.domain
- }).then(function (messages) {
- self.thread.render(messages, self._getThreadRenderingOptions(messages));
+ return this._thread.getMessages(this.domain)
+ .then(function (messages) {
+ self._threadWidget.render(
+ messages,
+ self._getThreadRenderingOptions(messages)
+ );
self._updateButtonStatus(messages.length === 0);
return self._loadEnoughMessages();
});
},
/**
* @private
- * @param {Object} messages
+ * @param {mail.model.Message} messages
* @returns {Object}
*/
_getThreadRenderingOptions: function (messages) {
- // Compute position of the 'New messages' separator, only once when joining
- // a channel to keep it in the thread when new messages arrive
- if (_.isUndefined(this.messages_separator_position)) {
- if (!this.unread_counter) {
- this.messages_separator_position = false; // no unread message -> don't display separator
+ // Compute position of the 'New messages' separator, only once when
+ // joining a channel to keep it in the thread when new messages arrive
+ if (_.isUndefined(this.messagesSeparatorPosition)) {
+ if (!this._unreadCounter) {
+ // no unread message -> don't display separator
+ this.messagesSeparatorPosition = false;
} else {
- var msg = this.call('chat_manager', 'getLastSeenMessage', this.channel);
- this.messages_separator_position = msg ? msg.id : 'top';
+ var messageID = this._thread.getLastSeenMessageID();
+ this.messagesSeparatorPosition = messageID || 'top';
}
}
return {
- channel_id: this.channel.id,
- display_load_more: !this.call('chat_manager', 'isAllHistoryLoaded', this.channel, this.domain),
- display_needactions: this.channel.display_needactions,
- messages_separator_position: this.messages_separator_position,
- squash_close_messages: this.channel.type !== 'static' && !this.channel.mass_mailing,
- display_empty_channel: !messages.length && !this.domain.length,
- display_no_match: !messages.length && this.domain.length,
- display_subject: this.channel.mass_mailing || this.channel.id === "channel_inbox" || this.channel.id === "channel_moderation",
- display_email_icon: false,
- display_reply_icon: true,
+ threadID: this._thread.getID(),
+ displayLoadMore: !this._thread.isAllHistoryLoaded(this.domain),
+ displayMarkAsRead: this._thread.getID() === 'mailbox_inbox',
+ messagesSeparatorPosition: this.messagesSeparatorPosition,
+ squashCloseMessages: this._thread.getType() !== 'mailbox' &&
+ !this._thread.isMassMailing(),
+ displayEmptyChannel: !messages.length && !this.domain.length,
+ displayNoMatchFound: !messages.length && this.domain.length,
+ displaySubjectOnMessages:
+ (
+ this._thread.getType() !== 'mailbox' &&
+ this._thread.isMassMailing()
+ ) ||
+ this._thread.getID() === 'mailbox_inbox' ||
+ this._thread.getID() === 'mailbox_moderation',
+ displayEmailIcons: false,
+ displayReplyIcons: true,
};
},
/**
@@ -423,11 +464,13 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* screen, or when there is no more message to fetch
*/
_loadEnoughMessages: function () {
- var loadMoreMessages = this.thread.el.clientHeight &&
- this.thread.el.clientHeight === this.thread.el.scrollHeight &&
- !this.call('chat_manager', 'isAllHistoryLoaded', this.channel, this.domain);
+ var loadMoreMessages =
+ this._threadWidget.el.clientHeight &&
+ (this._threadWidget.el.clientHeight === this._threadWidget.el.scrollHeight) &&
+ !this._thread.isAllHistoryLoaded(this.domain);
if (loadMoreMessages) {
- return this._loadMoreMessages().then(this._loadEnoughMessages.bind(this));
+ return this._loadMoreMessages()
+ .then(this._loadEnoughMessages.bind(this));
}
},
/**
@@ -438,21 +481,22 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
*/
_loadMoreMessages: function () {
var self = this;
- var oldestMsgID = this.$('.o_thread_message').first().data('messageId');
- var oldestMsgSelector = '.o_thread_message[data-message-id="' + oldestMsgID + '"]';
- var offset = -dom.getPosition(document.querySelector(oldestMsgSelector)).top;
- return this.call('chat_manager', 'getMessages', {
- channelID: this.channel.id,
- domain: this.domain,
- loadMore: true
- })
+ var oldestMessageID = this.$('.o_thread_message').first().data('messageId');
+ var oldestMessageSelector = '.o_thread_message[data-message-id="' + oldestMessageID + '"]';
+ var offset = -dom.getPosition(document.querySelector(oldestMessageSelector)).top;
+ return this._thread.getMessages(this.domain, true)
.then(function (messages) {
- if (self.messages_separator_position === 'top') {
- self.messages_separator_position = undefined; // reset value to re-compute separator position
+ if (self.messagesSeparatorPosition === 'top') {
+ // reset value to re-compute separator position
+ self.messagesSeparatorPosition = undefined;
}
- self.thread.render(messages, self._getThreadRenderingOptions(messages));
- offset += dom.getPosition(document.querySelector(oldestMsgSelector)).top;
- self.thread.scroll_to({offset: offset});
+ self._threadWidget.render(
+ messages,
+ self._getThreadRenderingOptions(messages)
+ );
+ offset += dom.getPosition(document.querySelector(oldestMessageSelector)
+ ).top;
+ self._threadWidget.scrollToPosition(offset);
});
},
/**
@@ -484,68 +528,71 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
},
/**
* Binds handlers on the given $input to make them autocomplete and/or
- * create channels.
+ * create threads.
*
* @private
* @param {JQuery} $input the input to prepare
- * @param {string} type the type of channel to create ('dm', 'public' or
- * 'private')
+ * @param {string} type the type of thread to create ('dm', 'public' or
+ * 'private' channel)
*/
- _prepareAddChannelInput: function ($input, type) {
+ _prepareAddThreadInput: function ($input, type) {
var self = this;
if (type === 'public') {
$input.autocomplete({
source: function (request, response) {
- self.last_search_val = _.escape(request.term);
- self._searchChannel(self.last_search_val).done(function (result){
+ self._lastSearchVal = _.escape(request.term);
+ self._searchChannel(self._lastSearchVal).done(function (result){
result.push({
- 'label': _.str.sprintf('<strong>'+_t("Create %s")+'</strong>', '<em>"#'+self.last_search_val+'"</em>'),
+ 'label': _.str.sprintf(
+ '<strong>' + _t("Create %s") + '</strong>',
+ '<em>"#' + self._lastSearchVal + '"</em>'
+ ),
'value': '_create',
});
response(result);
});
},
- select: function (event, ui) {
- if (self.last_search_val) {
+ select: function (ev, ui) {
+ if (self._lastSearchVal) {
if (ui.item.value === '_create') {
- self.call('chat_manager', 'createChannel', self.last_search_val, "public");
+ self.call('mail_service', 'createChannel', self._lastSearchVal, 'public');
} else {
- self.call('chat_manager', 'joinChannel', ui.item.id);
+ self.call( 'mail_service', 'joinChannel', ui.item.id);
}
}
},
- focus: function (event) {
- event.preventDefault();
+ focus: function (ev) {
+ ev.preventDefault();
},
html: true,
});
} else if (type === 'private') {
- $input.on('keyup', this, function (event) {
- var name = _.escape($(event.target).val());
- if (event.which === $.ui.keyCode.ENTER && name) {
- self.call('chat_manager', 'createChannel', name, "private");
+ $input.on('keyup', this, function (ev) {
+ var name = _.escape($(ev.target).val());
+ if (ev.which === $.ui.keyCode.ENTER && name) {
+ self.call('mail_service', 'createChannel', name, 'private');
}
});
} else if (type === 'dm') {
$input.autocomplete({
source: function (request, response) {
- self.last_search_val = _.escape(request.term);
- self.call('chat_manager', 'searchPartner', self.last_search_val, 10).done(response);
+ self._lastSearchVal = _.escape(request.term);
+ self.call('mail_service', 'searchPartner', self._lastSearchVal, 10).done(response);
},
- select: function (event, ui) {
- var partner_id = ui.item.id;
- var dm = self.call('chat_manager', 'getDmFromPartnerID', partner_id);
+ select: function (ev, ui) {
+ var partnerID = ui.item.id;
+ var dm = self.call('mail_service', 'getDmFromPartnerID', partnerID);
if (dm) {
- self._setChannel(dm);
+ self._setThread(dm.getID());
} else {
- self.call('chat_manager', 'createChannel', partner_id, "dm");
+ self.call('mail_service', 'createChannel', partnerID, 'dm');
}
// clear the input
$(this).val('');
return false;
},
- focus: function (event) {
- event.preventDefault();
+ focus: function (ev) {
+ ev.preventDefault();
},
});
}
@@ -572,14 +619,15 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @private
*/
_renderButtons: function () {
- this.$buttons = $(QWeb.render("mail.chat.ControlButtons", {debug: session.debug}));
- this.$buttons.find('button').css({display:"inline-block"});
- this.$buttons.on('click', '.o_mail_chat_button_invite', this._onInviteButtonClicked.bind(this));
- this.$buttons.on('click', '.o_mail_chat_button_mark_read', this._onMarkAllReadClicked.bind(this));
- this.$buttons.on('click', '.o_mail_chat_button_unstar_all', this._onUnstarAllClicked.bind(this));
- this.$buttons.on('click', '.o_mail_chat_button_moderate_all', this._onModerateAllClicked.bind(this));
- this.$buttons.on('click', '.o_mail_chat_button_select_all', this._onSelectAllClicked.bind(this));
- this.$buttons.on('click', '.o_mail_chat_button_unselect_all', this._onUnselectAllClicked.bind(this));
+ this.$buttons = $(QWeb.render('mail.discuss.ControlButtons', { debug: session.debug }));
+ this.$buttons.find('button').css({display:'inline-block'});
+ this.$buttons
+ .on('click', '.o_mail_discuss_button_invite', this._onInviteButtonClicked.bind(this))
+ .on('click', '.o_mail_discuss_button_mark_all_read', this._onMarkAllAsReadClicked.bind(this))
+ .on('click', '.o_mail_discuss_button_unstar_all', this._onUnstarAllClicked.bind(this))
+ .on('click', '.o_mail_discuss_button_moderate_all', this._onModerateAllClicked.bind(this))
+ .on('click', '.o_mail_discuss_button_select_all', this._onSelectAllClicked.bind(this))
+ .on('click', '.o_mail_discuss_button_unselect_all', this._onUnselectAllClicked.bind(this));
},
/**
* @private
@@ -588,25 +636,40 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
_renderSearchView: function () {
var self = this;
var options = {
- $buttons: $("<div>"),
+ $buttons: $('<div>'),
action: this.action,
disable_groupby: true,
};
this.searchview = new SearchView(this, this.dataset, this.fields_view, options);
- return this.alive(this.searchview.appendTo($("<div>"))).then(function () {
- self.$searchview_buttons = self.searchview.$buttons.contents();
- // manually call do_search to generate the initial domain and filter
- // the messages in the default channel
- self.searchview.do_search();
- });
+ return this.alive(this.searchview.appendTo($('<div>')))
+ .then(function () {
+ self.$searchview_buttons = self.searchview.$buttons.contents();
+ // manually call do_search to generate the initial domain and filter
+ // the messages in the default thread
+ self.searchview.do_search();
+ });
},
/**
+ * Render the sidebar of discuss app
+ *
* @private
* @param {Object} options
- * @returns {JQuery}
+ * @param {mail.model.Thread[]} [options.threads=[]]
+ * @returns {jQueryElement}
*/
_renderSidebar: function (options) {
- return $(QWeb.render("mail.chat.Sidebar", options));
+ var inbox = this.call('mail_service', 'getMailbox', 'inbox');
+ var starred = this.call('mail_service', 'getMailbox', 'starred');
+ var moderation = this.call('mail_service', 'getMailbox', 'moderation');
+ var $sidebar = $(QWeb.render('mail.discuss.Sidebar', {
+ activeThreadID: this._thread ? this._thread.getID() : undefined,
+ threads: options.threads,
+ needactionCounter: inbox.getMailboxCounter(),
+ starredCounter: starred.getMailboxCounter(),
+ moderationCounter: moderation ? moderation.getMailboxCounter() : 0,
+ isModerator: this.call('mail_service', 'isModerator'),
+ }));
+ return $sidebar;
},
/**
* @private
@@ -620,7 +683,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
}
timeout = timeout || 20000;
this.$snackbar = $(QWeb.render(template, context));
- this.$('.o_mail_chat_content').append(this.$snackbar);
+ this.$('.o_mail_discuss_content').append(this.$snackbar);
// Hide snackbar after [timeout] milliseconds (by default, 20s)
var $snackbar = this.$snackbar;
setTimeout(function () { $snackbar.fadeOut(); }, timeout);
@@ -632,52 +695,57 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @returns {Deferred}
*/
_renderThread: function () {
- this.thread = new ChatThread(this, {display_help: true, loadMoreOnScroll: true});
-
- this.thread.on('redirect', this, function (resModel, resID) {
- this.call('chat_manager', 'redirect', resModel, resID, this._setChannel.bind(this));
+ this._threadWidget = new ThreadWidget(this, {
+ displayHelp: true,
+ loadMoreOnScroll: true
});
- this.thread.on('redirect_to_channel', this, function (channelID) {
- this.call('chat_manager', 'joinChannel', channelID).then(this._setChannel.bind(this));
- });
- this.thread.on('load_more_messages', this, this._loadMoreMessages);
- this.thread.on('mark_as_read', this, function (messageID) {
- this.call('chat_manager', 'markAsRead', [messageID]);
- });
- this.thread.on('toggle_star_status', this, function (messageID) {
- this.call('chat_manager', 'toggleStarStatus', messageID);
- });
- this.thread.on('select_message', this, this._selectMessage);
- this.thread.on('unselect_message', this, this._unselectMessage);
- return this.thread.appendTo(this.$('.o_mail_chat_content'));
+
+ this._threadWidget
+ .on('redirect', this, function (resModel, resID) {
+ this.call('mail_service', 'redirect', resModel, resID, this._setThread.bind(this));
+ })
+ .on('redirect_to_channel', this, function (channelID) {
+ this.call('mail_service', 'joinChannel', channelID).then(this._setThread.bind(this));
+ })
+ .on('load_more_messages', this, this._loadMoreMessages)
+ .on('mark_as_read', this, function (messageID) {
+ this.call('mail_service', 'markMessagesAsRead', [messageID]);
+ })
+ .on('toggle_star_status', this, function (messageID) {
+ var message = this.call('mail_service', 'getMessage', messageID);
+ message.toggleStarStatus();
+ })
+ .on('select_message', this, this._selectMessage)
+ .on('unselect_message', this, this._unselectMessage);
+
+ return this._threadWidget.appendTo(this.$('.o_mail_discuss_content'));
},
/**
- * Restores the scroll position and composer state of the current channel
- *
* @private
+ * @param {mail.model.Channel} channel
*/
- _restoreChannelState: function () {
- var $newMessagesSeparator = this.$('.o_thread_new_messages_separator');
- if ($newMessagesSeparator.length) {
- this.thread.$el.scrollTo($newMessagesSeparator);
- } else {
- var newChannelScrolltop = this.channelsScrolltop[this.channel.id];
- this.thread.scroll_to({offset: newChannelScrolltop});
+ _restoreComposerState: function (channel) {
+ var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer;
+ var composerState = this._composerStates[channel.getUUID()];
+ if (composerState) {
+ composer.setState(composerState);
}
- this._restoreComposerState(this.channel);
},
/**
+ * Restores the scroll position and composer state of the current thread
+ *
* @private
- * @param {Object} channel
*/
- _restoreComposerState: function (channel) {
- if (channel.type === 'static') {
- return; // no composer in static channels
+ _restoreThreadState: function () {
+ var $newMessagesSeparator = this.$('.o_thread_new_messages_separator');
+ if ($newMessagesSeparator.length) {
+ this._threadWidget.$el.scrollTo($newMessagesSeparator);
+ } else {
+ var newThreadScrolltop = this._threadsScrolltop[this._thread.getID()];
+ this._threadWidget.scrollToPosition(newThreadScrolltop);
}
- var composer = channel.mass_mailing ? this.extendedComposer : this.basicComposer;
- var composerState = this.composerStates[channel.uuid];
- if (composerState) {
- composer.setState(composerState);
+ if (this._thread.getType() !== 'mailbox') {
+ this._restoreComposerState(this._thread);
}
},
/**
@@ -694,10 +762,10 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
.then(function (result){
var values = [];
_.each(result, function (channel){
- var escaped_name = _.escape(channel.name);
+ var escapedName = _.escape(channel.name);
values.push(_.extend(channel, {
- 'value': escaped_name,
- 'label': escaped_name,
+ 'value': escapedName,
+ 'label': escapedName,
}));
});
return values;
@@ -709,145 +777,134 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
*/
_selectMessage: function (messageID) {
this.$el.addClass('o_mail_selection_mode');
- var message = this.call('chat_manager', 'getMessage', messageID);;
- this.selected_message = message;
- var subject = "Re: " + message.record_name;
- this.extendedComposer.set_subject(subject);
+ var message = this.call('mail_service', 'getMessage', messageID);
+ this._selectedMessage = message;
+ var subject = "Re: " + message.getDocumentName();
+ this._extendedComposer.setSubject(subject);
- if (this.channel.type !== 'static') {
- this.basicComposer.do_hide();
+ if (this._thread.getType() !== 'mailbox') {
+ this._basicComposer.do_hide();
}
- this.extendedComposer.do_show();
+ this._extendedComposer.do_show();
- this.thread.scroll_to({id: messageID, duration: 200, only_if_necessary: true});
- this.extendedComposer.focus('body');
+ this._threadWidget.scrollToMessage({
+ msgID: messageID,
+ duration: 200,
+ onlyIfNecessary: true
+ });
+ this._extendedComposer.focus('body');
},
/**
+ * Set the selected thread with ID `threadID`.
+ * If there is no thread with such ID, fallback on mailbox 'inbox'.
+ *
* @private
- * @param {Object} channel
+ * @param {integer} threadID a thread with such ID
* @returns {$.Promise}
*/
- _setChannel: function (channel) {
+ _setThread: function (threadID) {
var self = this;
- // Store scroll position and composer state of the previous channel
- this._storeChannelState();
+ // Store scroll position and composer state of the previous thread
+ this._storeThreadState();
+
+ this._thread = this.call('mail_service', 'getThread', threadID) ||
+ this.call('mail_service', 'getMailbox', 'inbox');
- this.channel = channel;
- this.messages_separator_position = undefined; // reset value on channel change
- this.unread_counter = this.channel.unread_counter;
- this.last_seen_message_id = this.channel.last_seen_message_id;
+ // reset value on channel change
+ this.messagesSeparatorPosition = undefined;
+ this._unreadCounter = this._thread.getUnreadCounter();
if (this.$snackbar) {
this.$snackbar.remove();
}
- this.action.context.active_id = channel.id;
- this.action.context.active_ids = [channel.id];
+ this.action.context.active_id = this._thread.getID();
+ this.action.context.active_ids = [this._thread.getID()];
return this._fetchAndRenderThread().then(function () {
- // Mark channel's messages as read and clear needactions
- if (channel.type !== 'static') {
- self.call('chat_manager', 'markChannelAsSeen', channel);
+ // Mark thread's messages as read and clear needactions
+ if (self._thread.getType() !== 'mailbox') {
+ self._thread.markAsRead();
}
- // Restore scroll position and composer of the new current channel
- self._restoreChannelState();
+ // Restore scroll position and composer of the new current thread
+ self._restoreThreadState();
- // Update control panel before focusing the composer, otherwise focus is on the searchview
- self.set("title", '#' + channel.name);
+ // Update control panel before focusing the composer, otherwise
+ // focus is on the searchview
+ self.set("title", '#' + self._thread.getName());
self._updateControlPanel();
- self._updateControlPanelButtons(channel);
+ self._updateControlPanelButtons(self._thread);
- // Display and focus the adequate composer, and unselect possibly selected message
- // to prevent sending messages as reply to that message
+ // Display and focus the adequate composer, and unselect possibly
+ // selected message to prevent sending messages as reply to that
+ // message
self._unselectMessage();
self.action_manager.do_push_state({
action: self.action.id,
- active_id: self.channel.id,
+ active_id: self._thread.getID(),
});
});
},
/**
- * Binds handlers on chatManager events
+ * Binds handlers on mail bus events
*
* @private
*/
_startListening: function () {
- var chatBus = this.call('chat_manager', 'getChatBus');
- chatBus.on('open_channel', this, this._setChannel);
- chatBus.on('new_message', this, this._onNewMessage);
- chatBus.on('update_message', this, this._onMessageUpdated);
- chatBus.on('new_channel', this, this._onNewChannel);
- chatBus.on('anyone_listening', this, function (channel, query) {
- query.is_displayed = query.is_displayed ||
- (channel.id === this.channel.id && this.thread.is_at_bottom());
- });
- chatBus.on('unsubscribe_from_channel', this, this._onChannelLeft);
- chatBus.on('update_needaction', this, this.throttledUpdateChannels);
- chatBus.on('update_starred', this, this.throttledUpdateChannels);
- chatBus.on('update_channel_unread_counter', this, this.throttledUpdateChannels);
- chatBus.on('update_dm_presence', this, this.throttledUpdateChannels);
- chatBus.on('activity_updated', this, this.throttledUpdateChannels);
- chatBus.on('update_moderation_counter', this, this.throttledUpdateChannels);
+ this.call('mail_service', 'getMailBus')
+ .on('open_thread_in_discuss', this, this._onOpenThreadInDiscuss)
+ .on('new_message', this, this._onNewMessage)
+ .on('update_message', this, this._onMessageUpdated)
+ .on('new_channel', this, this._onNewChannel)
+ .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible)
+ .on('unsubscribe_from_channel', this, this._onChannelLeft)
+ .on('update_needaction', this, this._throttledUpdateThreads)
+ .on('update_starred', this, this._throttledUpdateThreads)
+ .on('update_thread_unread_counter', this, this._throttledUpdateThreads)
+ .on('update_dm_presence', this, this._throttledUpdateThreads)
+ .on('activity_updated', this, this._throttledUpdateThreads)
+ .on('update_moderation_counter', this, this._throttledUpdateThreads);
},
/**
- * Stores the scroll position and composer state of the current channel
- *
* @private
+ * @param {mail.model.Channel} channel
*/
- _storeChannelState: function () {
- if (this.channel) {
- this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop();
- this._storeComposerState(this.channel);
- }
+ _storeComposerState: function (channel) {
+ var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer;
+ this._composerStates[channel.getUUID()] = composer.getState();
+ composer.clearComposer();
},
/**
+ * Stores the scroll position of the current thread.
+ * For channels, also stores composer state.
+ *
* @private
- * @param {Object} channel
*/
- _storeComposerState: function (channel) {
- if (channel.type === 'static') {
- return; // no composer in static channels
+ _storeThreadState: function () {
+ if (this._thread) {
+ this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop();
+ if (this._thread.getType() !== 'mailbox') {
+ this._storeComposerState(this._thread);
+ }
}
- var composer = channel.mass_mailing ? this.extendedComposer : this.basicComposer;
- this.composerStates[channel.uuid] = composer.getState();
- composer.clear_composer();
},
/**
* @private
*/
_unselectMessage: function () {
- this.basicComposer.do_toggle(this.channel.type !== 'static' && !this.channel.mass_mailing);
- this.extendedComposer.do_toggle(this.channel.type !== 'static' && !!this.channel.mass_mailing);
+ this._basicComposer.do_toggle(this._thread.getType() !== 'mailbox' && !this._thread.isMassMailing());
+ this._extendedComposer.do_toggle(this._thread.getType() !== 'mailbox' && !!this._thread.isMassMailing());
if (!config.device.touch) {
- var composer = this.channel.mass_mailing ? this.extendedComposer : this.basicComposer;
+ var composer = this._thread.getType() !== 'mailbox' && this._thread.isMassMailing() ?
+ this._extendedComposer :
+ this._basicComposer;
composer.focus();
}
this.$el.removeClass('o_mail_selection_mode');
- this.thread.unselect();
- this.selected_message = null;
- },
- /**
- * Renders the mainside bar with current channels
- *
- * @private
- */
- _updateChannels: function () {
- var self = this;
- var $sidebar = this._renderSidebar({
- active_channel_id: this.channel ? this.channel.id: undefined,
- channels: this.call('chat_manager', 'getChannels'),
- needaction_counter: this.call('chat_manager', 'getNeedactionCounter'),
- starred_counter: this.call('chat_manager', 'getStarredCounter'),
- moderationCounter: this.call('chat_manager', 'getModerationCounter'),
- isModerator: this.call('chat_manager', 'isModerator'),
- });
- this.$(".o_mail_chat_sidebar").html($sidebar.contents());
- _.each(['dm', 'public', 'private'], function (type) {
- var $input = self.$('.o_mail_add_channel[data-type=' + type + '] input');
- self._prepareAddChannelInput($input, type);
- });
+ this._threadWidget.unselectMessage();
+ this._selectedMessage = null;
},
/**
* @private
@@ -855,25 +912,31 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @param {string} type
*/
_updateButtonStatus: function (disabled, type) {
- if (this.channel.id === "channel_inbox") {
+ if (this._thread.getID() === 'mailbox_inbox') {
this.$buttons
- .find('.o_mail_chat_button_mark_read')
+ .find('.o_mail_discuss_button_mark_all_read')
.toggleClass('disabled', disabled);
// Display Rainbowman when all inbox messages are read through
// 'MARK ALL READ' or marking last inbox message as read
if (disabled && type === 'mark_as_read') {
this.trigger_up('show_effect', {
- message: _t('Congratulations, your inbox is empty!'),
+ message: _t("Congratulations, your inbox is empty!"),
type: 'rainbow_man',
});
}
}
- if (this.channel.id === "channel_starred") {
+ if (this._thread.getID() === 'mailbox_starred') {
this.$buttons
- .find('.o_mail_chat_button_unstar_all')
+ .find('.o_mail_discuss_button_unstar_all')
.toggleClass('disabled', disabled);
}
- if ((this.channel.isModerated && this.channel.isModerator) || this.channel.id === "channel_moderation") {
+ if (
+ this._thread.getID() === 'mailbox_moderation' ||
+ (
+ this._thread.isModerated() &&
+ this._thread.isModerator()
+ )
+ ) {
this._updateModerationButtons();
}
},
@@ -891,36 +954,37 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
});
},
/**
- * Updates the control panel buttons visibility based on channel type
+ * Updates the control panel buttons visibility based on thread type
*
* @private
- * @param {Object} channel
+ * @param {mail.model.Thread} thread
*/
- _updateControlPanelButtons: function (channel) {
+ _updateControlPanelButtons: function (thread) {
// Hide 'unsubscribe' button in state channels and DM and channels with group-based subscription
this.$buttons
- .find('.o_mail_chat_button_invite, .o_mail_chat_button_settings')
- .toggle(channel.type !== "dm" && channel.type !== 'static');
+ .find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings')
+ .toggle(thread.getType() !== 'dm' && thread.getType() !== 'mailbox');
this.$buttons
- .find('.o_mail_chat_button_mark_read')
- .toggle(channel.id === "channel_inbox")
- .removeClass("o_hidden");
+ .find('.o_mail_discuss_button_mark_all_read')
+ .toggle(thread.getID() === 'mailbox_inbox')
+ .removeClass('o_hidden');
this.$buttons
- .find('.o_mail_chat_button_unstar_all')
- .toggle(channel.id === "channel_starred")
- .removeClass("o_hidden");
+ .find('.o_mail_discuss_button_unstar_all')
+ .toggle(thread.getID() === 'mailbox_starred')
+ .removeClass('o_hidden');
this.$buttons
- .find('.o_mail_chat_button_select_all')
- .toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation")
- .removeClass("o_hidden");
+ .find('.o_mail_discuss_button_select_all')
+ .toggle((thread.isModerated() && thread.isModerator()) || thread.getID() === 'mailbox_moderation')
+ .removeClass('o_hidden');
this.$buttons
- .find('.o_mail_chat_button_unselect_all')
- .toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation")
- .removeClass("o_hidden");
- this.$buttons.find('.o_mail_chat_button_moderate_all').hide();
- this.$('.o_mail_chat_channel_item')
+ .find('.o_mail_discuss_button_unselect_all')
+ .toggle((thread.isModerated() && thread.isModerator()) || thread.getID() === 'mailbox_moderation')
+ .removeClass('o_hidden');
+ this.$buttons.find('.o_mail_discuss_button_moderate_all').hide();
+
+ this.$('.o_mail_discuss_item')
.removeClass('o_active')
- .filter('[data-channel-id=' + channel.id + ']')
+ .filter('[data-thread-id=' + thread.getID() + ']')
.removeClass('o_unread_message')
.addClass('o_active');
},
@@ -942,20 +1006,20 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @private
*/
_updateModerationDecisionButton: function () {
- if (this.thread.$('.moderation_checkbox:checked').length) {
- this.$buttons.find('.o_mail_chat_button_moderate_all').show();
+ if (this._threadWidget.$('.moderation_checkbox:checked').length) {
+ this.$buttons.find('.o_mail_discuss_button_moderate_all').show();
} else {
- this.$buttons.find('.o_mail_chat_button_moderate_all').hide();
+ this.$buttons.find('.o_mail_discuss_button_moderate_all').hide();
}
},
/**
* @private
*/
_updateSelectUnselectAllButtons: function () {
- var buttonSelect = this.$buttons.find('.o_mail_chat_button_select_all');
- var buttonUnselect = this.$buttons.find('.o_mail_chat_button_unselect_all');
- var numCheckboxes = this.thread.$('.moderation_checkbox').length;
- var numCheckboxesChecked = this.thread.$('.moderation_checkbox:checked').length;
+ var buttonSelect = this.$buttons.find('.o_mail_discuss_button_select_all');
+ var buttonUnselect = this.$buttons.find('.o_mail_discuss_button_unselect_all');
+ var numCheckboxes = this._threadWidget.$('.moderation_checkbox').length;
+ var numCheckboxesChecked = this._threadWidget.$('.moderation_checkbox:checked').length;
if (numCheckboxes) {
if (numCheckboxesChecked === numCheckboxes) {
buttonSelect.toggleClass('disabled', true);
@@ -972,6 +1036,26 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
buttonUnselect.toggleClass('disabled', true);
}
},
+ /**
+ * Renders the mainside bar with current threads
+ *
+ * @private
+ */
+ _updateThreads: function () {
+ var self = this;
+
+ var $sidebar = this._renderSidebar({
+ threads: _.filter(this.call('mail_service', 'getThreads'), function (thread) {
+ return thread.getType() !== 'document_thread';
+ }),
+ });
+
+ this.$('.o_mail_discuss_sidebar').html($sidebar.contents());
+ _.each(['dm', 'public', 'private'], function (type) {
+ var $input = self.$('.o_mail_add_thread[data-type=' + type + '] input');
+ self._prepareAddThreadInput($input, type);
+ });
+ },
//--------------------------------------------------------------------------
// Handlers
@@ -979,103 +1063,123 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onAddChannel: function (event) {
- event.preventDefault();
- var type = $(event.target).data("type");
- this.$('.o_mail_add_channel[data-type=' + type + ']')
+ _onAddThread: function (ev) {
+ ev.preventDefault();
+ var type = $(ev.target).data('type');
+ this.$('.o_mail_add_thread[data-type=' + type + ']')
.show()
- .find("input").focus();
+ .find('input').focus();
},
/**
* @private
*/
- _onAddChannelBlur: function () {
- this.$('.o_mail_add_channel').hide();
- },
- /**
- * @private
- * @param {MouseEvent} event
- */
- _onChannelClicked: function (event) {
- event.preventDefault();
- var channelID = $(event.currentTarget).data('channel-id');
- var channel = this.call('chat_manager', 'getChannel', channelID);
- this._setChannel(channel);
+ _onAddThreadBlur: function () {
+ this.$('.o_mail_add_thread').hide();
},
/**
* @private
* @param {integer|string} channelID
*/
_onChannelLeft: function (channelID) {
- if (this.channel.id === channelID) {
- var channel = this.call('chat_manager', 'getChannel', 'channel_inbox');
- this._setChannel(channel);
+ if (this._thread.getID() === channelID) {
+ this._setThread('mailbox_inbox');
}
- this._updateChannels();
- delete this.channelsScrolltop[channelID];
+ this._updateThreads();
+ delete this._threadsScrolltop[channelID];
},
/**
* @private
*/
_onCloseNotificationBar: function () {
- this.$(".o_mail_annoying_notification_bar").slideUp();
+ this.$('.o_mail_annoying_notification_bar').slideUp();
},
/**
* @private
*/
_onComposerFocused: function () {
- var composer = this.channel.mass_mailing ? this.extendedComposer : this.basicComposer;
- var commands = this.call('chat_manager', 'getCommands', this.channel);
- var partners = this.call('chat_manager', 'getMentionPartnerSuggestions', this.channel);
- composer.mention_set_enabled_commands(commands);
- composer.mention_set_prefetched_partners(partners);
+ var composer = this._thread.isMassMailing() ? this._extendedComposer : this._basicComposer;
+ var commands = this._thread.getCommands();
+ var partners = this._thread.getMentionPartnerSuggestions();
+ composer.mentionSetCommands(commands);
+ composer.mentionSetPrefetchedPartners(partners);
+ },
+ /**
+ * When clicking on an item in the sidebar
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDiscussItemClicked: function (ev) {
+ ev.preventDefault();
+ var threadID = $(ev.currentTarget).data('thread-id');
+ this._setThread(threadID);
},
/**
+ * Invite button is only for channels (not mailboxes)
+ *
* @private
*/
_onInviteButtonClicked: function () {
- var title = _.str.sprintf(_t('Invite people to #%s'), this.channel.name);
- new PartnerInviteDialog(this, title, this.channel.id).open();
+ var title = _.str.sprintf(_t("Invite people to #%s"), this._thread.getName());
+ new PartnerInviteDialog(this, title, this._thread.getID()).open();
},
/**
+ * Called when someone asks discuss whether the bottom of `thread` is
+ * visible or not. An object `query` is provided in order to reponse on the
+ * key `isDisplayed`.
+ *
* @private
- * @param {KeyEvent} event
+ * @param {mail.model.Thread} thread
+ * @param {Object} query
+ * @param {boolean} query.isBottomVisible the response to provide on whether
+ * the thread is visible in discuss.
*/
- _onKeydown: function (event) {
- if (event.which === $.ui.keyCode.ESCAPE && this.selected_message) {
+ _onIsThreadBottomVisible: function (thread, query) {
+ query.isBottomVisible = query.isBottomVisible ||
+ (
+ thread.getID() === this._thread.getID() &&
+ this._threadWidget.isAtBottom()
+ );
+ },
+ /**
+ * @private
+ * @param {KeyEvent} ev
+ */
+ _onKeydown: function (ev) {
+ if (ev.which === $.ui.keyCode.ESCAPE && this._selectedMessage) {
this._unselectMessage();
}
},
/**
* @private
*/
- _onMarkAllReadClicked: function () {
- this.call('chat_manager', 'markAllAsRead', this.channel, this.domain);
+ _onMarkAllAsReadClicked: function () {
+ this._thread.markAllMessagesAsRead(this.domain);
},
/**
* @private
- * @param {Object} message
+ * @param {mail.model.Message} message
* @param {string} type the channel type
*/
_onMessageUpdated: function (message, type) {
var self = this;
- var currentChannelID = this.channel.id;
- if ((currentChannelID === "channel_starred" && !message.is_starred) ||
- (currentChannelID === "channel_moderation" && !message.needsModeration) ||
- (currentChannelID === "channel_inbox" && !message.is_needaction)) {
- this.call('chat_manager', 'getMessages', {
- channelID: this.channel.id,
- domain: this.domain
- }).then(function (messages) {
+ var currentThreadID = this._thread.getID();
+ if (
+ (currentThreadID === 'mailbox_starred' && !message.isStarred()) ||
+ (currentThreadID === 'mailbox_inbox' && !message.isNeedaction()) ||
+ (currentThreadID === 'mailbox_moderation' && !message.needsModeration())
+ ) {
+ this._thread.getMessages(this.domain)
+ .then(function (messages) {
var options = self._getThreadRenderingOptions(messages);
- self.thread.remove_message_and_render(message.id, messages, options)
+ self._threadWidget.removeMessageAndRender(message.getID(), messages, options)
.then(function () {
self._updateButtonStatus(messages.length === 0, type);
});
});
- } else if (_.contains(message.channel_ids, currentChannelID) || (message.res_id === currentChannelID)) {
+ } else if (_.contains(message.getThreadIDs(), currentThreadID)) {
this._fetchAndRenderThread();
}
},
@@ -1085,7 +1189,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
*/
_onModerateAllClicked: function (ev) {
var decision = $(ev.target).data('decision');
- var messageIDs = this.thread.$('.moderation_checkbox:checked')
+ var messageIDs = this._threadWidget.$('.moderation_checkbox:checked')
.map(function () {
return $(this).data('message-id');
})
@@ -1105,55 +1209,69 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
},
/**
* @private
- * @param {Object} channel
+ * @param {mail.model.Channel} channel
*/
_onNewChannel: function (channel) {
- this._updateChannels();
- if (channel.autoswitch) {
- this._setChannel(channel);
+ this._updateThreads();
+ if (channel.isAutoswitch()) {
+ this._setThread(channel.getID());
}
},
/**
* @private
- * @param {Object} message
+ * @param {mail.model.Message} message
*/
_onNewMessage: function (message) {
var self = this;
- if (_.contains(message.channel_ids, this.channel.id)) {
- if (this.channel.type !== 'static' && this.thread.is_at_bottom()) {
- this.call('chat_manager', 'markChannelAsSeen', this.channel);
+ if (_.contains(message.getThreadIDs(), this._thread.getID())) {
+ if (this._thread.getType() !== 'mailbox' && this._threadWidget.isAtBottom()) {
+ this._thread.markAsRead();
}
- var should_scroll = this.thread.is_at_bottom();
+ var shouldScroll = this._threadWidget.isAtBottom();
this._fetchAndRenderThread().then(function () {
- if (should_scroll) {
- self.thread.scroll_to({id: message.id});
+ if (shouldScroll) {
+ self._threadWidget.scrollToMessage({ msgID: message.getID() });
}
});
}
- // Re-render sidebar to indicate that there is a new message in the corresponding channels
- this._updateChannels();
- // Dump scroll position of channels in which the new message arrived
- this.channelsScrolltop = _.omit(this.channelsScrolltop, message.channel_ids);
+ // Re-render sidebar to indicate that there is a new message in the corresponding threads
+ this._updateThreads();
+ // Dump scroll position of threads in which the new message arrived
+ this._threadsScrolltop = _.omit(this._threadsScrolltop, message.getThreadIDs());
},
/**
+ * Called when opening a thread in discuss, due to discuss being open.
+ * All threads except document threads are valid for discuss app.
+ *
* @private
- * @param {Object} message
+ * @param {mail.model.Thread}
*/
- _onPostMessage: function (message) {
+ _onOpenThreadInDiscuss: function (thread) {
+ if (thread.getType() !== 'document_thread') {
+ this._setThread(thread.getID());
+ }
+ },
+ /**
+ * @private
+ * @param {Object} messageData
+ */
+ _onPostMessage: function (messageData) {
var self = this;
- var options = this.selected_message ? {} : {channelID: this.channel.id};
- if (this.selected_message) {
- message.subtype = this.selected_message.is_note ? 'mail.mt_note': 'mail.mt_comment';
- message.subtype_id = false;
- message.message_type = 'comment';
+ if (this._selectedMessage) {
+ messageData.subtype = this._selectedMessage.isNote() ? 'mail.mt_note': 'mail.mt_comment';
+ messageData.subtype_id = false;
+ messageData.message_type = 'comment';
+ messageData.content_subtype = 'html';
}
- this.call('chat_manager', 'postMessage', message, options)
+ this._thread.postMessage(messageData)
.then(function () {
- if (self.selected_message) {
- self._renderSnackbar('mail.chat.MessageSentSnackbar', {record_name: self.selected_message.record_name}, 5000);
+ if (self._selectedMessage) {
+ self._renderSnackbar('mail.discuss.MessageSentSnackbar', {
+ documentName: self._selectedMessage.getDocumentName()
+ }, 5000);
self._unselectMessage();
} else {
- self.thread.scroll_to();
+ self._threadWidget.scrollToBottom();
}
})
.fail(function () {
@@ -1165,9 +1283,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
*/
_onPublicChannelsClick: function () {
this.do_action({
- name: _t('Public Channels'),
+ name: _t("Public Channels"),
type: 'ir.actions.act_window',
- res_model: "mail.channel",
+ res_model: 'mail.channel',
views: [[false, 'kanban'], [false, 'form']],
domain: [['public', '!=', 'private']],
}, {
@@ -1176,41 +1294,41 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
},
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onRequestNotificationPermission: function (event) {
+ _onRequestNotificationPermission: function (ev) {
var self = this;
- event.preventDefault();
- this.$(".o_mail_annoying_notification_bar").slideUp();
+ ev.preventDefault();
+ this.$('.o_mail_annoying_notification_bar').slideUp();
var def = window.Notification && window.Notification.requestPermission();
if (def) {
def.then(function (value) {
if (value !== 'granted') {
- self.call('bus_service', 'sendNotification', self, _t('Permission denied'),
- _t('Odoo will not have the permission to send native notifications on this device.'));
+ self.call('bus_service', 'sendNotification', _t("Permission denied"),
+ _t("Odoo will not have the permission to send native notifications on this device."));
} else {
- self.call('bus_service', 'sendNotification', self, _t('Permission granted'),
- _t('Odoo has now the permission to send you native notifications on this device.'));
+ self.call('bus_service', 'sendNotification', _t("Permission granted"),
+ _t("Odoo has now the permission to send you native notifications on this device."));
}
});
}
},
/**
* @private
- * @param {OdooEvent}
+ * @param {OdooEvent} ev
*/
- _onSearch: function (event) {
- event.stopPropagation();
+ _onSearch: function (ev) {
+ ev.stopPropagation();
var session = this.getSession();
var result = pyeval.eval_domains_and_contexts({
- domains: event.data.domains,
+ domains: ev.data.domains,
contexts: [session.user_context],
});
this.domain = result.domain;
- if (this.channel) {
+ if (this._thread) {
// initially (when _onSearch is called manually), there is no
- // channel set yet, so don't try to fetch and render the thread as
- // this will be done as soon as the default channel is set
+ // thread set yet, so don't try to fetch and render the thread as
+ // this will be done as soon as the default thread is set
this._fetchAndRenderThread();
}
},
@@ -1221,33 +1339,33 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
_onSelectAllClicked: function (ev) {
var $button = $(ev.target);
if (!$button.hasClass('disabled')) {
- this.thread.toggleModerationCheckboxes(true);
+ this._threadWidget.toggleModerationCheckboxes(true);
this._updateModerationButtons();
}
},
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onChannelSettingsClicked: function (event) {
- var channelID = $(event.target).data("channel-id");
+ _onChannelSettingsClicked: function (ev) {
+ var threadID = $(ev.target).data('thread-id');
this.do_action({
type: 'ir.actions.act_window',
- res_model: "mail.channel",
- res_id: channelID,
+ res_model: 'mail.channel',
+ res_id: threadID,
views: [[false, 'form']],
target: 'current'
});
},
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onUnpinChannel: function (event) {
- event.stopPropagation();
- var channelID = $(event.target).data("channel-id");
- var channel = this.call('chat_manager', 'getChannel', channelID);
- this.call('chat_manager', 'unsubscribe', channel);
+ _onUnpinChannel: function (ev) {
+ ev.stopPropagation();
+ var channelID = $(ev.target).data('thread-id');
+ var channel = this.call('mail_service', 'getChannel', channelID);
+ channel.unsubscribe();
},
/**
* @private
@@ -1256,7 +1374,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
_onUnselectAllClicked: function (ev) {
var $button = $(ev.target);
if (!$button.hasClass('disabled')) {
- this.thread.toggleModerationCheckboxes(false);
+ this._threadWidget.toggleModerationCheckboxes(false);
this._updateModerationButtons();
}
},
@@ -1264,7 +1382,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, {
* @private
*/
_onUnstarAllClicked: function () {
- this.call('chat_manager', 'unstarAll');
+ this.call('mail_service', 'unstarAll');
},
/**
* Update the moderation buttons.
diff --git a/addons/mail/static/src/js/discuss_mobile.js b/addons/mail/static/src/js/discuss_mobile.js
index 531553e6804..867af05152c 100644
--- a/addons/mail/static/src/js/discuss_mobile.js
+++ b/addons/mail/static/src/js/discuss_mobile.js
@@ -1,7 +1,7 @@
-odoo.define('mail.chat_discuss_mobile', function (require) {
+odoo.define('mail.DiscussMobile', function (require) {
"use strict";
-var Discuss = require('mail.chat_discuss');
+var Discuss = require('mail.Discuss');
var config = require('web.config');
var core = require('web.core');
@@ -12,12 +12,15 @@ if (!config.device.isMobile) {
return;
}
+/**
+ * Overrides Discuss module in mobile
+ */
Discuss.include({
template: 'mail.discuss_mobile',
events: _.extend(Discuss.prototype.events, {
'click .o_mail_mobile_tab': '_onMobileTabClicked',
- 'click .o_channel_inbox_item': '_onMobileInboxButtonClicked',
- 'click .o_mail_channel_preview': '_onMobileChannelClicked',
+ 'click .o_mailbox_inbox_item': '_onMobileInboxButtonClicked',
+ 'click .o_mail_preview': '_onMobileThreadClicked',
}),
/**
@@ -25,13 +28,13 @@ Discuss.include({
*/
init: function () {
this._super.apply(this, arguments);
- this.currentState = this.defaultChannelID;
+ this._currentState = this._defaultThreadID;
},
/**
* @override
*/
start: function () {
- this.$mainContent = this.$('.o_mail_chat_content');
+ this._$mainContent = this.$('.o_mail_discuss_content');
return this._super.apply(this, arguments)
.then(this._updateControlPanel.bind(this));
},
@@ -39,8 +42,8 @@ Discuss.include({
* @override
*/
on_attach_callback: function () {
- if (this.channel && this._isInInboxTab()) {
- this.thread.scroll_to({offset: this.channelsScrolltop[this.channel.id]});
+ if (this._thread && this._isInInboxTab()) {
+ this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]);
}
},
/**
@@ -48,7 +51,7 @@ Discuss.include({
*/
on_detach_callback: function () {
if (this._isInInboxTab()) {
- this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop();
+ this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop();
}
},
@@ -61,7 +64,7 @@ Discuss.include({
* @returns {Boolean} true iff we currently are in the Inbox tab
*/
_isInInboxTab: function () {
- return _.contains(['channel_inbox', 'channel_starred'], this.currentState);
+ return _.contains(['mailbox_inbox', 'mailbox_starred'], this._currentState);
},
/**
* @override
@@ -71,18 +74,18 @@ Discuss.include({
var self = this;
this._super.apply(this, arguments);
_.each(['dm', 'public', 'private'], function (type) {
- var selector = '.o_mail_chat_button_' + type;
- self.$buttons.on('click', selector, self._onAddChannel.bind(self));
+ var selector = '.o_mail_discuss_button_' + type;
+ self.$buttons.on('click', selector, self._onAddThread.bind(self));
});
},
/**
- * Overrides to only store the channel state if we are in the Inbox tab, as
- * this is the only tab in which we actually have a displayed channel
+ * Overrides to only store the thread state if we are in the Inbox tab, as
+ * this is the only tab in which we actually have a displayed thread
*
* @override
* @private
*/
- _restoreChannelState: function () {
+ _restoreThreadState: function () {
if (this._isInInboxTab()) {
this._super.apply(this, arguments);
}
@@ -100,29 +103,31 @@ Discuss.include({
/**
* @override
* @private
- * @param {Object} channel
*/
- _setChannel: function (channel) {
- if (channel.type !== 'static') {
- return this.call('chat_manager', 'detachChannel', channel.id);
+ _setThread: function (threadID) {
+ var thread = this.call('mail_service', 'getThread', threadID);
+ if (thread.getType() !== 'mailbox') {
+ this.call('mail_service', 'openThreadWindow', threadID);
+ return $.when();
} else {
return this._super.apply(this, arguments);
}
},
/**
- * Overrides to only store the channel state if we are in the Inbox tab, as
- * this is the only tab in which we actually have a displayed channel
+ * Overrides to only store the thread state if we are in the Inbox tab, as
+ * this is the only tab in which we actually have a displayed thread
*
* @override
* @private
*/
- _storeChannelState: function () {
- if (this.channel && this._isInInboxTab()) {
+ _storeThreadState: function () {
+ if (this._thread && this._isInInboxTab()) {
this._super.apply(this, arguments);
}
},
/**
- * Overrides to toggle the visibility of the tabs when a message is unselected
+ * Overrides to toggle the visibility of the tabs when a message is
+ * unselected
*
* @override
* @private
@@ -135,74 +140,86 @@ Discuss.include({
* @override
* @private
*/
- _updateChannels: function () {
- return this._updateContent(this.currentState);
+ _updateThreads: function () {
+ return this._updateContent(this._currentState);
},
/**
* Redraws the content of the client action according to its current state.
*
* @private
- * @param {string} type the channel's type to display (e.g. 'channel_inbox',
- * 'channel_starred', 'dm'...).
+ * @param {string} type the thread's type to display (e.g. 'mailbox_inbox',
+ * 'mailbox_starred', 'dm'...).
*/
_updateContent: function (type) {
var self = this;
- var inInbox = type === 'channel_inbox' || type === 'channel_starred';
- if (!inInbox && this._isInInboxTab()) {
+ var inMailbox = type === 'mailbox_inbox' || type === 'mailbox_starred';
+ if (!inMailbox && this._isInInboxTab()) {
// we're leaving the inbox, so store the thread scrolltop
- this._storeChannelState();
+ this._storeThreadState();
}
var previouslyInInbox = this._isInInboxTab();
- this.currentState = type;
+ this._currentState = type;
// fetch content to display
var def;
- if (inInbox) {
+ if (inMailbox) {
def = this._fetchAndRenderThread();
} else {
- var allChannels = this.call('chat_manager', 'getChannels');
- var channels = _.where(allChannels, {type: type});
- def = this.call('chat_manager', 'getChannelsPreview', channels);
+ var allChannels = this.call('mail_service', 'getChannels');
+ var channels = _.filter(allChannels, function (channel) {
+ return channel.getType() === type;
+ });
+ def = this.call('mail_service', 'getChannelPreviews', channels);
}
- return $.when(def).then(function (channelsPreview) {
+ return $.when(def).then(function (previews) {
// update content
- if (inInbox) {
+ if (inMailbox) {
if (!previouslyInInbox) {
- self.$('.o_mail_chat_tab_pane').remove();
- self.$mainContent.append(self.thread.$el);
- self.$mainContent.append(self.extendedComposer.$el);
+ self.$('.o_mail_discuss_tab_pane').remove();
+ self._$mainContent.append(self._threadWidget.$el);
+ self._$mainContent.append(self._extendedComposer.$el);
}
- self._restoreChannelState();
+ self._restoreThreadState();
} else {
- self.thread.$el.detach();
- self.extendedComposer.$el.detach();
- var $content = $(QWeb.render("mail.chat.MobileTabPane", {
- channels: channelsPreview,
+ self._threadWidget.$el.detach();
+ self._extendedComposer.$el.detach();
+ var $content = $(QWeb.render('mail.discuss.MobileTabPane', {
+ previews: previews,
type: type,
}));
- self._prepareAddChannelInput($content.find('.o_mail_add_channel input'), type);
- self.$mainContent.html($content);
+ self._prepareAddThreadInput($content.find('.o_mail_add_thread input'), type);
+ self._$mainContent.html($content);
}
// update control panel
- self.$buttons.find('button').addClass('o_hidden');
- self.$buttons.find('.o_mail_chat_button_' + type).removeClass('o_hidden');
- self.$buttons.find('.o_mail_chat_button_mark_read').toggleClass('o_hidden', type !== 'channel_inbox');
- self.$buttons.find('.o_mail_chat_button_unstar_all').toggleClass('o_hidden', type !== 'channel_starred');
+ self.$buttons.find('button')
+ .addClass('o_hidden');
+ self.$buttons.find('.o_mail_discuss_button_' + type)
+ .removeClass('o_hidden');
+ self.$buttons.find('.o_mail_discuss_button_mark_all_read')
+ .toggleClass('o_hidden', type !== 'mailbox_inbox');
+ self.$buttons.find('.o_mail_discuss_button_unstar_all')
+ .toggleClass('o_hidden', type !== 'mailbox_starred');
- // update Inbox page buttons
- if (inInbox) {
- self.$('.o_mail_chat_mobile_inbox_buttons').removeClass('o_hidden');
- self.$('.o_channel_inbox_item').removeClass('btn-primary').addClass('btn-default');
- self.$('.o_channel_inbox_item[data-type=' + type + ']').removeClass('btn-default').addClass('btn-primary');
+ // update Mailbox page buttons
+ if (inMailbox) {
+ self.$('.o_mail_discuss_mobile_mailboxes_buttons')
+ .removeClass('o_hidden');
+ self.$('.o_mailbox_inbox_item')
+ .removeClass('btn-primary')
+ .addClass('btn-default');
+ self.$('.o_mailbox_inbox_item[data-type=' + type + ']')
+ .removeClass('btn-default')
+ .addClass('btn-primary');
} else {
- self.$('.o_mail_chat_mobile_inbox_buttons').addClass('o_hidden');
+ self.$('.o_mail_discuss_mobile_mailboxes_buttons')
+ .addClass('o_hidden');
}
// update bottom buttons
self.$('.o_mail_mobile_tab').removeClass('active');
- // channel_inbox and channel_starred share the same tab
- type = type === 'channel_starred' ? 'channel_inbox' : type;
+ // mailbox_inbox and mailbox_starred share the same tab
+ type = type === 'mailbox_starred' ? 'mailbox_inbox' : type;
self.$('.o_mail_mobile_tab[data-type=' + type + ']').addClass('active');
});
},
@@ -212,45 +229,46 @@ Discuss.include({
//--------------------------------------------------------------------------
/**
+ * @override
* @private
*/
- _onAddChannel: function () {
- this.$('.o_mail_add_channel').show().find('input').focus();
+ _onAddThread: function () {
+ this.$('.o_mail_add_thread').show().find('input').focus();
},
/**
- * Switches to the clicked channel in the Inbox page (Inbox or Starred).
+ * Switches to the clicked thread in the Inbox page (Inbox or Starred).
*
* @private
- * @param {MouseEvent}
+ * @param {MouseEvent} ev
*/
- _onMobileInboxButtonClicked: function (event) {
- var channel = this.call('chat_manager', 'getChannel', $(event.currentTarget).data('type'));
- this._setChannel(channel);
- this._updateContent(this.channel.id);
+ _onMobileInboxButtonClicked: function (ev) {
+ var mailboxID = $(ev.currentTarget).data('type');
+ this._setThread(mailboxID);
+ this._updateContent(this._thread.getID());
},
/**
* Switches to another tab.
*
* @private
- * @param {MouseEvent}
+ * @param {MouseEvent} ev
*/
- _onMobileTabClicked: function (event) {
- var type = $(event.currentTarget).data('type');
- if (type === 'channel_inbox') {
- var channel = this.call('chat_manager', 'getChannel', 'channel_inbox');
- this._setChannel(channel);
+ _onMobileTabClicked: function (ev) {
+ var type = $(ev.currentTarget).data('type');
+ if (type === 'mailbox') {
+ var inbox = this.call('mail_service', 'getMailbox', 'inbox');
+ this._setThread(inbox);
}
this._updateContent(type);
},
/**
- * Opens a channel in a chat windown (full screen in mobile).
+ * Opens a thread in a chat window (full screen in mobile).
*
* @private
- * @param {MouseEvent}
+ * @param {MouseEvent} ev
*/
- _onMobileChannelClicked: function (event) {
- var channelID = $(event.currentTarget).data("channel_id");
- this.call('chat_manager', 'detachChannel', channelID);
+ _onMobileThreadClicked: function (ev) {
+ var threadID = $(ev.currentTarget).data('preview-id');
+ this.call('mail_service', 'openThreadWindow', threadID);
},
});
diff --git a/addons/mail/static/src/js/emojis.js b/addons/mail/static/src/js/emojis.js
new file mode 100644
index 00000000000..4fedb5647a6
--- /dev/null
+++ b/addons/mail/static/src/js/emojis.js
@@ -0,0 +1,149 @@
+odoo.define('mail.emojis', function (require) {
+"use strict";
+
+/**
+ * This module exports the list of all available emojis on the client side.
+ * An emoji object has the following properties:
+ *
+ * - {string[]} sources: the character representations of the emoji
+ * - {string} unicode: the unicode representation of the emoji
+ * - {string} description: the description of the emoji
+ */
+
+/**
+ * This data represent all the available emojis that are supported on the web
+ * client:
+ *
+ * - key: this is the source representation of an emoji, i.e. its "character"
+ * representation. This is a string that can be easily typed by the
+ * user and then translated to its unicode representation (see value)
+ * - value: this is the unicode representation of an emoji, i.e. its "true"
+ * representation in the system.
+ */
+var data = {
+ ":)": "😊",
+ ":-)": "😊", // alternative (alt.)
+ "=)": "😊", // alt.
+ ":]": "😊", // alt.
+ ":D": "😃",
+ ":-D": "😃", // alt.
+ "=D": "😃", // alt.
+ "xD": "😆",
+ "XD": "😆", // alt.
+ "x'D": "😂",
+ ";)": "😉",
+ ";-)": "😉", // alt.
+ "B)": "😎",
+ "8)": "😎", // alt.
+ "B-)": "😎", // alt.
+ "8-)": "😎", // alt.
+ ";p": "😜",
+ ";P": "😜", // alt.
+ ":p": "😋",
+ ":P": "😋", // alt.
+ ":-p": "😋", // alt.
+ ":-P": "😋", // alt.
+ "=P": "😋", // alt.
+ "xp": "😝",
+ "xP": "😝", // alt.
+ "o_o": "😳",
+ ":|": "😐",
+ ":-|": "😐", // alt.
+ ":/": "😕", // alt.
+ ":-/": "😕", // alt.
+ ":(": "😞",
+ ":@": "😱",
+ ":O": "😲",
+ ":-O": "😲", // alt.
+ ":o": "😲", // alt.
+ ":-o": "😲", // alt.
+ ":'o": "😨",
+ "3:(": "😠",
+ ">:(": "😠", // alt.
+ "3:": "😠", // alt.
+ "3:)": "😈",
+ ">:)": "😈", // alt.
+ ":*": "😘",
+ ":-*": "😘", // alt.
+ "o:)": "😇",
+ ":'(": "😢",
+ ":'-(": "😭",
+ ":\"(": "😭", // alt.
+ "<3": "❤️",
+ ":heart": "❤️", // alt.
+ ":heart_eyes": "😍",
+ ":turban": "👳",
+ ":+1": "👍",
+ ":-1": "👎",
+ ":ok": "👌",
+ ":poop": "💩",
+ ":no_see": "🙈",
+ ":no_hear": "🙉",
+ ":no_speak": "🙊",
+ ":bug": "🐞",
+ ":kitten": "😺",
+ ":bear": "🐻",
+ ":snail": "🐌",
+ ":boar": "🐗",
+ ":clover": "🍀",
+ ":sunflower": "🌹",
+ ":fire": "🔥",
+ ":sun": "☀️",
+ ":partly_sunny:": "⛅️",
+ ":rainbow": "🌈",
+ ":cloud": "☁️",
+ ":zap": "⚡️",
+ ":star": "⭐️",
+ ":cookie": "🍪",
+ ":pizza": "🍕",
+ ":hamburger": "🍔",
+ ":fries": "🍟",
+ ":cake": "🎂",
+ ":cake_part": "🍰",
+ ":coffee": "☕️",
+ ":banana": "🍌",
+ ":sushi": "🍣",
+ ":rice_ball": "🍙",
+ ":beer": "🍺",
+ ":wine": "🍷",
+ ":cocktail": "🍸",
+ ":tropical": "🍹",
+ ":beers": "🍻",
+ ":ghost": "👻",
+ ":skull": "💀",
+ ":et": "👽",
+ ":alien": "👽", // alt.
+ ":party": "🎉",
+ ":trophy": "🏆",
+ ":key": "🔑",
+ ":pin": "📌",
+ ":postal_horn": "📯",
+ ":music": "🎵",
+ ":trumpet": "🎺",
+ ":guitar": "🎸",
+ ":soccer": "⚽️",
+ ":football": "🏈",
+ ":8ball": "🎱",
+ ":clapper": "🎬",
+ ":microphone": "🎤"
+};
+
+// list of emojis in a dictionary, indexed by emoji unicode
+var emojiDict = {};
+_.each(data, function (unicode, source) {
+ if (!emojiDict[unicode]) {
+ emojiDict[unicode] = {
+ sources: [source],
+ unicode: unicode,
+ description: source,
+ };
+ } else {
+ emojiDict[unicode].sources.push(source);
+ }
+});
+
+var emojis = _.values(emojiDict);
+
+return emojis;
+
+});
diff --git a/addons/mail/static/src/js/extended_chat_window.js b/addons/mail/static/src/js/extended_chat_window.js
deleted file mode 100644
index a85740ac5e1..00000000000
--- a/addons/mail/static/src/js/extended_chat_window.js
+++ /dev/null
@@ -1,63 +0,0 @@
-odoo.define('mail.ExtendedChatWindow', function (require) {
-"use strict";
-
-var ChatWindow = require('mail.ChatWindow');
-var composer = require('mail.composer');
-
-return ChatWindow.extend({
- template: "mail.ExtendedChatWindow",
- events: _.extend({}, ChatWindow.prototype.events, {
- "click .o_chat_window_expand": "on_click_expand",
- }),
- start: function () {
- var self = this;
- var def;
- if (self.options.thread_less) {
- this.$el.addClass('o_thread_less');
- this.$('.o_chat_search_input input')
- .autocomplete({
- source: function (request, response) {
- self.call('chat_manager', 'searchPartner', request.term, 10).done(response);
- },
- select: function (event, ui) {
- self.trigger('open_dm_session', ui.item.id);
- },
- })
- .focus();
- } else if (!self.options.input_less) {
- var basicComposer = new composer.BasicComposer(self, {mention_partners_restricted: true, isMini: true});
- basicComposer.on('post_message', self, function (message) {
- self.call('chat_manager', 'postMessage', message, {
- channelID: self.channel_id,
- });
- });
- basicComposer.once('input_focused', self, function () {
- var channel = self.call('chat_manager', 'getChannel', self.channel_id);
- var commands = self.call('chat_manager', 'getCommands', channel);
- var partners = self.call('chat_manager', 'getMentionPartnerSuggestions', channel);
- basicComposer.mention_set_enabled_commands(commands);
- basicComposer.mention_set_prefetched_partners(partners);
- });
- def = basicComposer.replace(self.$('.o_chat_composer'));
- }
- return $.when(this._super(), def);
- },
- // Override on_keydown to only prevent jquery's blockUI to cancel event, but without sending
- // the message on ENTER keydown as this is handled by the BasicComposer
- on_keydown: function (event) {
- event.stopPropagation();
- },
- on_reverse_breadcrumb: function () {
- this.call('chat_manager', 'getChatBus').trigger('discuss_open', false);
- },
- on_click_expand: _.debounce(function (event) {
- event.preventDefault();
- this.do_action('mail.mail_channel_action_client_chat', {
- clear_breadcrumbs: false,
- active_id: this.channel_id,
- on_reverse_breadcrumb: this.on_reverse_breadcrumb
- });
- }, 1000, true),
-});
-
-});
diff --git a/addons/mail/static/src/js/followers.js b/addons/mail/static/src/js/followers.js
index 651165dde30..a28f72edb1c 100644
--- a/addons/mail/static/src/js/followers.js
+++ b/addons/mail/static/src/js/followers.js
@@ -31,7 +31,7 @@ var Followers = AbstractField.extend({
supportedFieldTypes: ['one2many'],
// inherited
- init: function(parent, name, record, options) {
+ init: function (parent, name, record, options) {
this._super.apply(this, arguments);
this.image = this.attrs.image || 'image_small';
@@ -40,7 +40,7 @@ var Followers = AbstractField.extend({
this.followers = [];
this.subtypes = [];
this.data_subtype = {};
- this.is_follower = undefined;
+ this._isFollower = undefined;
var session = this.getSession();
this.partnerID = session.partner_id;
@@ -84,7 +84,7 @@ var Followers = AbstractField.extend({
// private
_displayButtons: function () {
- if (this.is_follower) {
+ if (this._isFollower) {
this.$('button.o_followers_follow_button').removeClass('o_followers_notfollow').addClass('o_followers_following');
this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', false);
this.$('.o_followers_actions .dropdown-toggle').addClass('o_followers_following');
@@ -100,7 +100,7 @@ var Followers = AbstractField.extend({
this.$('.o_followers_title_box > button').prop('disabled', true);
this.$('.o_followers_count')
.html(this.value.res_ids.length)
- .parent().attr("title", this._formatFollowers(this.value.res_ids.length));
+ .parent().attr('title', this._formatFollowers(this.value.res_ids.length));
},
_displayFollowers: function () {
var self = this;
@@ -118,7 +118,7 @@ var Followers = AbstractField.extend({
// On mouse-enter it will show the edit_subtype pencil.
if (record.is_editable) {
- $follower_li.on('mouseenter mouseleave', function(e) {
+ $follower_li.on('mouseenter mouseleave', function (e) {
$(e.currentTarget).find('.o_edit_subtype').toggleClass('hide', e.type === 'mouseleave');
});
}
@@ -129,7 +129,7 @@ var Followers = AbstractField.extend({
this.$('.o_followers_title_box > button').prop('disabled', !$followers_list.children().length);
this.$('.o_followers_count')
.html(this.value.res_ids.length)
- .parent().attr("title", this._formatFollowers(this.value.res_ids.length));
+ .parent().attr('title', this._formatFollowers(this.value.res_ids.length));
},
_displaySubtypes:function (data, dialog, display_warning) {
var old_parent_model;
@@ -167,7 +167,7 @@ var Followers = AbstractField.extend({
view_mode: 'form',
view_type: 'form',
views: [[false, 'form']],
- name: _t('Invite Follower'),
+ name: _t("Invite Follower"),
target: 'new',
context: {
'default_res_model': this.model,
@@ -182,11 +182,11 @@ var Followers = AbstractField.extend({
_formatFollowers: function (count){
var str = '';
if (count <= 0) {
- str = _t('No follower');
+ str = _t("No follower");
} else if (count === 1){
- str = _t('One follower');
+ str = _t("One follower");
} else {
- str = ''+count+' '+_t('followers');
+ str = ''+count+' '+_t("followers");
}
return str;
},
@@ -210,7 +210,7 @@ var Followers = AbstractField.extend({
return _.contains(self.value.res_ids, follower.id);
});
var user_follower = _.filter(self.followers, function (rec) { return rec.is_uid; });
- self.is_follower = user_follower.length >= 1;
+ self._isFollower = user_follower.length >= 1;
});
},
_reload: function () {
@@ -258,17 +258,17 @@ var Followers = AbstractField.extend({
});
return def;
},
- _updateSubscription: function (event, follower_id, is_channel) {
+ _updateSubscription: function (event, followerID, isChannel) {
var ids = {};
var subtypes;
- if (follower_id !== undefined) {
+ if (followerID !== undefined) {
// Subtypes edited from the modal
subtypes = this.dialog.$('input[type="checkbox"]');
- if (is_channel) {
- ids.channel_ids = [follower_id];
+ if (isChannel) {
+ ids.channel_ids = [followerID];
} else {
- ids.partner_ids = [follower_id];
+ ids.partner_ids = [followerID];
}
} else {
subtypes = this.$('.o_followers_actions input[type="checkbox"]');
@@ -286,7 +286,7 @@ var Followers = AbstractField.extend({
// If no more subtype followed, unsubscribe the follower
if (!checklist.length) {
this._unfollow(ids).fail(function () {
- $(event.target).prop("checked", true);
+ $(event.target).prop('checked', true);
});
} else {
var kwargs = _.extend({}, ids);
@@ -302,21 +302,33 @@ var Followers = AbstractField.extend({
}
},
- // handlers
- _onAddFollower: function (event) {
- event.preventDefault();
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddFollower: function (ev) {
+ ev.preventDefault();
this._inviteFollower(false);
},
- _onAddChannel: function (event) {
- event.preventDefault();
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddChannel: function (ev) {
+ ev.preventDefault();
this._inviteFollower(true);
},
/**
- * @param {Event} event
+ * @private
+ * @param {MouseEvent} ev
*/
- _onEditSubtype: function (event) {
+ _onEditSubtype: function (ev) {
var self = this;
- var $currentTarget = $(event.currentTarget);
+ var $currentTarget = $(ev.currentTarget);
var follower_id = $currentTarget.data('follower-id'); // id of model mail_follower
this._rpc({
route: '/mail/read_subscription_data',
@@ -327,13 +339,13 @@ var Followers = AbstractField.extend({
var is_channel = $currentTarget.data('oe-model') === 'mail.channel';
self.dialog = new Dialog(this, {
size: 'medium',
- title: _t('Edit Subscription of ') + $currentTarget.siblings('a').text(),
+ title: _t("Edit Subscription of ") + $currentTarget.siblings('a').text(),
buttons: [
{
text: _t("Apply"),
classes: 'btn-primary',
click: function () {
- self._updateSubscription(event, res_id, is_channel);
+ self._updateSubscription(ev, res_id, is_channel);
},
close: true
},
@@ -349,16 +361,23 @@ var Followers = AbstractField.extend({
self.dialog.open();
});
},
+ /**
+ * @private
+ */
_onFollowButtonClicked: function () {
- if (!this.is_follower) {
+ if (!this._isFollower) {
this._follow();
} else {
this._unfollow({partner_ids: [this.partnerID]});
}
},
- _onRedirect: function (event) {
- event.preventDefault();
- var $target = $(event.target);
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onRedirect: function (ev) {
+ ev.preventDefault();
+ var $target = $(ev.target);
this.do_action({
type: 'ir.actions.act_window',
view_type: 'form',
@@ -368,18 +387,26 @@ var Followers = AbstractField.extend({
res_id: $target.data('oe-id'),
});
},
- _onRemoveFollower: function (event) {
- var res_model = $(event.target).parent().find('a').data('oe-model');
- var res_id = $(event.target).parent().find('a').data('oe-id');
- if (res_model === 'res.partner') {
- return this._unfollow({partner_ids: [res_id]});
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onRemoveFollower: function (ev) {
+ var resModel = $(ev.target).parent().find('a').data('oe-model');
+ var resID = $(ev.target).parent().find('a').data('oe-id');
+ if (resModel === 'res.partner') {
+ return this._unfollow({partner_ids: [resID]});
} else {
- return this._unfollow({channel_ids: [res_id]});
+ return this._unfollow({channel_ids: [resID]});
}
},
- _onSubtypeClicked: function (event) {
- event.stopPropagation();
- this._updateSubscription(event);
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSubtypeClicked: function (ev) {
+ ev.stopPropagation();
+ this._updateSubscription(ev);
var $list = this.$('.o_subtypes_list');
if (!$list.hasClass('open')) {
$list.addClass('open');
diff --git a/addons/mail/static/src/js/form_controller.js b/addons/mail/static/src/js/form_controller.js
index b8cb5ab1c25..a165fe850ba 100644
--- a/addons/mail/static/src/js/form_controller.js
+++ b/addons/mail/static/src/js/form_controller.js
@@ -19,12 +19,12 @@ FormController.include({
/**
* @private
- * @param {OdooEvent} event
+ * @param {OdooEvent} ev
* @param {string} event.data.id datapointID
- * @param {integer[]} event.data.msgIDs list of message ids
+ * @param {integer[]} event.data.messageIDs list of message IDs
*/
- _onNewMessage: function (event) {
- this.model.updateMessageIDs(event.data.id, event.data.msgIDs);
+ _onNewMessage: function (ev) {
+ this.model.updateMessageIDs(ev.data.id, ev.data.messageIDs);
},
});
diff --git a/addons/mail/static/src/js/models/channel.js b/addons/mail/static/src/js/models/channel.js
new file mode 100644
index 00000000000..e63cab2f907
--- /dev/null
+++ b/addons/mail/static/src/js/models/channel.js
@@ -0,0 +1,442 @@
+odoo.define('mail.model.Channel', function (require) {
+"use strict";
+
+var ThreadWithCache = require('mail.model.ThreadWithCache');
+var mailUtils = require('mail.utils');
+
+var time = require('web.time');
+
+/**
+ * This class represent channels in JS. In this context, the word channel
+ * has the same meaning of channel on the server, meaning that direct messages
+ * (DM) and livechats are also channels.
+ *
+ * Any piece of code in JS that make use of channels must ideally interact with
+ * such objects, instead of direct data from the server.
+ */
+var Channel = ThreadWithCache.extend({
+ /**
+ * @override
+ * @param {string} [data.anonymous_name]
+ * @param {string} data.channel_type
+ * @param {boolean} data.group_based_subscription
+ * @param {boolean} [data.is_minimized=false]
+ * @param {boolean} data.is_moderator whether the current user is
+ * moderator of this channel.
+ * @param {string} [data.last_message_date] date in server-format
+ * @param {boolean} [data.mass_mailing]
+ * @param {integer} [data.message_unread_counter]
+ * @param {string} [data.public] either 'public' or 'private'
+ * @param {string} data.state
+ * @param {string} [data.uuid]
+ * @param {Object} options
+ * @param {boolean} [options.autoswitch=true]
+ * @param {Object[]} commands
+ */
+ init: function (parent, data, options, commands) {
+ var self = this;
+ this._super.apply(this, arguments);
+
+ // If set, autoswitch channel on joining this channel in discuss
+ // the default behaviour is to autoswitch on join.
+ // exception: receiving channel or chat session notifications
+ //
+ this._autoswitch = 'autoswitch' in options ? options.autoswitch : true;
+ this._chat = undefined; // FIXME: could be dropped when livechat and DM are moved out of this class
+ this._commands = undefined;
+ this._detached = data.is_minimized;
+ this._directPartnerID = undefined;
+ this._folded = data.state === 'folded';
+ // if set: hide 'Leave channel' button
+ this._groupBasedSubscription = data.group_based_subscription;
+ this._isModerator = data.is_moderator;
+ this._lastMessageDate = undefined;
+ this._massMailing = data.mass_mailing;
+ // Deferred that is resolved on fetched members of this channel.
+ this._membersDef = undefined;
+ this._moderation = data.is_moderation;
+ // number of messages that are 'needaction', which is equivalent to the
+ // number of messages in this channel that are in inbox.
+ this._needactionCounter = data.message_needaction_counter || 0;
+ this._serverType = data.channel_type;
+ this._status = undefined;
+ this._throttleFetchSeen = _.throttle(this._fetchSeen.bind(this), 3000);
+ // unique identifier for this channel, which is required for some rpc
+ this._uuid = data.uuid;
+
+ // * list of commands available for this channel
+ this._commands = _.filter(commands, function (command) {
+ return !command.channel_types ||
+ _.contains(command.channel_types, self._serverType);
+ });
+ if (this._type === 'channel') {
+ this._type = data.public !== 'private' ? 'public' : 'private';
+ }
+ if ('anonymous_name' in data) {
+ this._name = data.anonymous_name;
+ }
+ if (data.last_message_date) {
+ this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date));
+ }
+ this._chat = !this.getType().match(/^(public|private)$/);
+ if (data.message_unread_counter) {
+ this._unreadCounter = data.message_unread_counter;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close the channel
+ *
+ * This operation is executed server-side and the chat window will be
+ * folded in all potential tabs from all browsers.
+ *
+ * @override
+ */
+ close: function () {
+ this._super.apply(this, arguments);
+ this._rpc({
+ model: 'mail.channel',
+ method: 'channel_fold',
+ kwargs: { uuid: this.getUUID(), state: 'closed' },
+ }, { shadow: true });
+ },
+ /**
+ * Decrement the needaction counter of the channel
+ * Floor value at 0.
+ *
+ * @param {integer} [num=1] the amount to decrement at most
+ */
+ decrementNeedactionCounter: function (num) {
+ num = _.isNumber(num) ? num : 1;
+ this._needactionCounter = Math.max(this._needactionCounter - 1, 0);
+ },
+ /**
+ * Open the chat window for this channel
+ * (in all potential tabs from all browsers)
+ *
+ * @pverride
+ */
+ detach: function () {
+ this._super.apply(this, arguments);
+ this._rpc({
+ model: 'mail.channel',
+ method: 'channel_minimize',
+ args: [this.getUUID(), true],
+ }, {
+ shadow: true,
+ });
+ },
+ /**
+ * Force fetch members of the channel
+ *
+ * @returns {$.Promise<Object[]>} resolved with list of channel listeners
+ */
+ forceFetchMembers: function () {
+ this._membersDef = undefined;
+ return this.getMentionPartnerSuggestions();
+ },
+ /**
+ * Folds/Minimize the channel
+ * (in all potential tabs from all browsers)
+ *
+ * @override
+ * @param {boolean} folded
+ */
+ fold: function (folded) {
+ this._super.apply(this, arguments);
+ var args = {
+ uuid: this.getUUID(),
+ };
+ if (_.isBoolean(folded)) {
+ args.state = folded ? 'folded' : 'open';
+ }
+ this._rpc({
+ model: 'mail.channel',
+ method: 'channel_fold',
+ kwargs: args,
+ }, {shadow: true});
+ },
+ /**
+ * Show the list of available commands on this channel (e.g. '/help')
+ *
+ * @override
+ * @returns {Array} list of commands
+ */
+ getCommands: function () {
+ return this._commands;
+ },
+ /**
+ * @returns {mail.model.Message|undefined} last message of channel, if any
+ */
+ getLastMessage: function () {
+ return _.last(this._cache['[]'].messages);
+ },
+ /**
+ * Get listeners of a channel
+ *
+ * @returns {$.Promise<Object[]>} resolved with list of channel listeners
+ */
+ getMentionPartnerSuggestions: function () {
+ if (!this._membersDef) {
+ this._membersDef = this._rpc({
+ model: 'mail.channel',
+ method: 'channel_fetch_listeners',
+ args: [this.getUUID()],
+ }, {
+ shadow: true
+ })
+ .then(function (members) {
+ return members;
+ });
+ }
+ return this._membersDef;
+ },
+ /**
+ * @returns {integer}
+ */
+ getNeedactionCounter: function () {
+ return this._needactionCounter;
+ },
+ /**
+ * @override
+ */
+ getPreview: function () {
+ var result = this._super.apply(this, arguments);
+ if (!this.isChat()) {
+ result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_small';
+ }
+ var lastMessage = this.getLastMessage();
+ return _.extend(result, {
+ author: lastMessage ? lastMessage.getDisplayedAuthor() : '',
+ body: lastMessage ? mailUtils.parseAndTransform(lastMessage.getBody(), mailUtils.inline) : '',
+ date: lastMessage ? lastMessage.getDate() : moment(),
+ isAuthor: this.hasMessages() && this.getLastMessage().isAuthor(),
+ });
+ },
+ /**
+ * Returns the title to display in thread window's headers.
+ * For channels, the title is prefixed with "#".
+ *
+ * @override
+ * @returns {string|Object} the name of the thread by default (see getName)
+ */
+ getTitle: function () {
+ return "#" + this._super.apply(this, arguments);
+ },
+ /**
+ * Get the UUID of the channel.
+ * This is a string that uniquely links this channel to a server channel.
+ *
+ * @returns {string} uuid of this channel
+ */
+ getUUID: function () {
+ return this._uuid;
+ },
+ /**
+ * Increment the needaction counter of this channel by 1 unit
+ */
+ incrementNeedactionCounter: function () {
+ this._needactionCounter++;
+ },
+ /**
+ * States whether the channel should be auto-selected on creation
+ *
+ * Note that this is not of the responsibility of the channel
+ * (see mail.model.Thread for more information)
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isAutoswitch: function () {
+ return this._autoswitch;
+ },
+ /**
+ * All instances of this class are chanels
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isChannel: function () {
+ return true;
+ },
+ /**
+ * States whether this channel is a chat or not.
+ * These types of channels are chat:
+ * - direct messages (DM)
+ * - livechat
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isChat: function () {
+ return this._chat;
+ },
+ /**
+ * States whether the channel auto-subscribes some users in a group
+ *
+ * @returns {boolean}
+ */
+ isGroupBasedSubscription: function () {
+ return this._groupBasedSubscription;
+ },
+ /**
+ * States whether the channel is moderated or not.
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isModerated: function () {
+ return this._moderation;
+ },
+ /**
+ * States whether the current user is moderator of this channel.
+ *
+ * @returns {boolean}
+ */
+ isModerator: function () {
+ return this._isModerator;
+ },
+ /**
+ * Marks this channel as read.
+ * The last seen message will be the last message.
+ * Resolved with the last seen message, only for non-mailbox channels
+ *
+ * @override
+ * @returns {$.Promise<integer|undefined>} resolved with last message ID
+ * seen in the channel, and when the channel has been marked as seen on
+ * the server.
+ */
+ markAsRead: function () {
+ if (this._unreadCounter > 0) {
+ this.resetUnreadCounter();
+ return this._throttleFetchSeen();
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Prepare and send a message to the server on this channel
+ *
+ * @override
+ * @param {Object} data data related to the new message
+ * @returns {$.Promise<Object>} resolved when the message has been sent to
+ * the server, with the object message that has been sent to the server.
+ */
+ postMessage: function (data) {
+ var self = this;
+ return this._super.apply(this, arguments).then(function (messageData) {
+ _.extend(messageData, {
+ message_type: 'comment',
+ subtype: 'mail.mt_comment',
+ command: data.command,
+ });
+ return self._rpc({
+ model: 'mail.channel',
+ method: data.command ? 'execute_command' : 'message_post',
+ args: [self._id],
+ kwargs: messageData,
+ }).then(function () {
+ return messageData;
+ });
+ });
+ },
+ /**
+ * Unsubscribes from channel
+ *
+ * @returns {$.Promise} resolve when unsubscribed
+ */
+ unsubscribe: function () {
+ if (_.contains(['public', 'private'], this.getType())) {
+ // unfollow channel
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'action_unfollow',
+ args: [[this._id]],
+ });
+ } else {
+ // unpin livechat
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_pin',
+ args: [this.getUUID(), false],
+ });
+ }
+ },
+ /**
+ * Updates the internal state of the channel, and reflects the changes in
+ * the UI.
+ *
+ * Called by {mail.Manager.Notification} on receiving a channel window
+ * notification. A notification is received after a client-side action
+ * that changes the state of the channel window visually, so that cross tab
+ * channels are updated similarly.
+ *
+ * TODO: This method should be dropped if the state of windows are
+ * completely handled client-side, which should be the case in the near
+ * future.
+ *
+ * @param {Object} params
+ * @param {boolean} params.detached
+ * @param {boolean} params.folded
+ */
+ updateWindowState: function (params) {
+ this._folded = 'folded' in params ? params.folded : this._folded;
+ this._detached = 'detached' in params ? params.detached : this._detached;
+ this._warnUpdatedWindowState();
+ },
+ /**
+ * Reset the needaction counter to 0.
+ */
+ resetNeedactionCounter: function () {
+ this._needactionCounter = 0;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {$.Promise<integer>} resolved with ID of last seen message
+ */
+ _fetchSeen: function () {
+ var self = this;
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_seen',
+ args: [[this._id]],
+ }, {
+ shadow: true
+ }).then(function (lastSeenMessageID) {
+ self._lastSeenMessageID = lastSeenMessageID;
+ return lastSeenMessageID;
+ });
+ },
+ /**
+ * Get the domain to fetch all the messages in the current channel
+ *
+ * Note that with moderation, even though some messages are not really
+ * linked to a channel ('channel_ids'), we should nonetheless display them.
+ * These messages are fetched from their associated document
+ * ('mail.channel' with provided channel ID), and messages that are pending
+ * moderation.
+ *
+ * @override
+ * @private
+ * @returns {Array}
+ */
+ _getThreadDomain: function () {
+ return ['|', '&', '&',
+ ['model', '=', 'mail.channel'],
+ ['res_id', 'in', [this._id]],
+ ['need_moderation', '=', true],
+ ['channel_ids', 'in', [this._id]]];
+ },
+});
+
+return Channel;
+
+});
diff --git a/addons/mail/static/src/js/models/dm.js b/addons/mail/static/src/js/models/dm.js
new file mode 100644
index 00000000000..cd08dd7526d
--- /dev/null
+++ b/addons/mail/static/src/js/models/dm.js
@@ -0,0 +1,94 @@
+odoo.define('mail.model.DM', function (require) {
+"use strict";
+
+var Channel = require('mail.model.Channel');
+
+/**
+ *
+ * Any piece of code in JS that make use of DMs must ideally interact with
+ * such objects, instead of direct data from the server.
+ */
+var DM = Channel.extend({
+ /**
+ * @override
+ * @param {Object} data
+ * @param {Object} data.direct_partner
+ * @param {integer} data.direct_partner[0].id
+ * @param {string} data.direct_partner[0].im_status
+ * @param {string} data.direct_partner[0].name
+ */
+ init: function (parent, data) {
+ this._super.apply(this, arguments);
+
+ this._type = 'dm';
+ this._name = data.direct_partner[0].name;
+ this._directPartnerID = data.direct_partner[0].id;
+ this._status = data.direct_partner[0].im_status;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the direct partner ID linked to the DM, i.e. the partner ID of the
+ * user at the other end of the DM conversation. All DM do have a direct
+ * partner iD.
+ *
+ * @returns {integer}
+ */
+ getDirectPartnerID: function () {
+ return this._directPartnerID;
+ },
+ /**
+ * @override
+ */
+ getPreview: function () {
+ var result = this._super.apply(this, arguments);
+ result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_small';
+ return result;
+ },
+ /**
+ * @returns {string}
+ */
+ getStatus: function () {
+ return this._status;
+ },
+ /**
+ * DM are chat
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isChat: function () {
+ return true;
+ },
+ /**
+ * @param {string} newStatus
+ */
+ setStatus: function (newStatus) {
+ this._status = newStatus;
+ },
+ /**
+ * Unpin this DM
+ *
+ * @override
+ * @return {$.Promise} resolve when unpinned
+ */
+ unsubscribe: function () {
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_pin',
+ args: [this.getUUID(), false],
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+});
+
+return DM;
+
+});
diff --git a/addons/mail/static/src/js/models/document_thread.js b/addons/mail/static/src/js/models/document_thread.js
new file mode 100644
index 00000000000..b9671107d9d
--- /dev/null
+++ b/addons/mail/static/src/js/models/document_thread.js
@@ -0,0 +1,276 @@
+odoo.define('mail.model.DocumentThread', function (require) {
+"use strict";
+
+var Thread = require('mail.model.Thread');
+
+var session = require('web.session');
+
+/**
+ * These threads represent threads that are directly attached to documents.
+ * It is sometimes called the "chatter", but technically this is just the
+ * part of the chatter containing the thread, as the chatter also contains
+ * the activity and listener fields.
+ *
+ * Note that there are still some hacks in the handling of document threads,
+ * due to document threads are instantiated from fetched messages, either from
+ * mail.ThreadField or by fetching messages in Inbox.
+ *
+ * Also, because messages are not sent on the longpoll bus for this kind of
+ * threads, it stores the list of message IDs that it contains, which should
+ * be up-to-date whenever someone wants to get the messages in the document
+ * thread.
+ */
+var DocumentThread = Thread.extend({
+
+ /**
+ * @override
+ * @param {string} [data.messagesIDs] the list of message ids linked
+ * to the document (if not given, they will be fetched before fetching the
+ * messages)
+ * @param {string} data.model
+ * @param {integer} data.resID
+ */
+ init: function (parent, data) {
+ data.id = data.resModel + '_' + data.resID;
+ data.type = 'document_thread';
+
+ this._super.apply(this, arguments);
+
+ // ID of the related document
+ this._documentID = data.resID;
+ // model of the related document
+ this._documentModel = data.resModel;
+ // used to handle history of messages
+ this._messageIDs = data.messageIDs || [];
+ // list of loaded messages
+ this._messages = [];
+
+ // if the messageIDs haven't been given, fetch them before fetching the
+ // messages
+ this._mustFetchMessageIDs = !_.isArray(data.messageIDs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add this message to this document thread.
+ *
+ * @override
+ * @param {mail.model.Message} message
+ */
+ addMessage: function (message) {
+ // update internal list of messages
+ this._messages.push(message);
+ this._messages = _.sortBy(this._messages, function (msg) {
+ return msg.getID();
+ });
+ // update message ids associated to this document thread
+ if (!_.contains(this._messageIDs, message.getID())) {
+ this._messageIDs.push(message.getID());
+ }
+ },
+ /**
+ * Get the model name of the document that is linked to this document thread
+ *
+ * @returns {string}
+ */
+ getDocumentModel: function () {
+ return this._documentModel;
+ },
+ /**
+ * Get the ID of the document that is linked to this document thread
+ *
+ * @returns {integer}
+ */
+ getDocumentID: function () {
+ return this._documentID;
+ },
+ /**
+ * Get he list of message IDs that this document threads contain.
+ *
+ * @returns {integer[]}
+ */
+ getMessageIDs: function () {
+ return this._messageIDs;
+ },
+ /**
+ *
+ * Get messages of the document thread
+ *
+ * @override
+ * @param {Object} options
+ * @param {boolean} [options.forceFetch] if true, fetch anyway, as user
+ * clicked on 'load more'.
+ * @returns {$.Promise<mail.model.Message[]>}
+ */
+ getMessages: function (options) {
+ var self = this;
+ return this._fetchMessages(options).then(function () {
+ self.call('mail_service', 'markMessagesAsRead', self._messageIDs);
+ return self._messages;
+ });
+ },
+ /**
+ * States whether the thread is linked to a document
+ * Document thread's are always linked to a document.
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isLinkedToDocument: function () {
+ return true;
+ },
+ /**
+ * Overrides this method so that all the messages of this document thread
+ * are marked as read on the server.
+ *
+ * @override
+ * @returns {$.Promise} resolved when messages have been marked as read on
+ * the server.
+ */
+ markAsRead: function () {
+ this._super.apply(this, arguments);
+ return this.call('mail_service', 'markMessagesAsRead', this._messageIDs);
+ },
+ /**
+ * Post message for document thread
+ *
+ * @override
+ * @param {Object} data data related to the new message
+ * @return {$.Promise<Object>} resolved when the message has been sent to
+ * the server, with the object message that has been sent to the server.
+ */
+ postMessage: function (data) {
+ var self = this;
+ var resModel = this.getDocumentModel();
+ var resID = this.getDocumentID();
+ return this._super.apply(this, arguments)
+ .then(function (messageData) {
+ _.extend(messageData, {
+ context: data.context,
+ message_type: data.message_type,
+ subtype: data.subtype,
+ subtype_id: data.subtype_id,
+ });
+ return self._rpc({
+ model: resModel,
+ method: 'message_post',
+ args: [resID],
+ kwargs: messageData,
+ })
+ .then(function (messageID) {
+ return self._rpc({
+ model: 'mail.message',
+ method: 'message_format',
+ args: [[messageID]],
+ })
+ .then(function (messages) {
+ messages[0].model = resModel;
+ messages[0].res_id = resID;
+ self.call('mail_service', 'addMessage', messages[0]);
+ return messages[0];
+ });
+ });
+ });
+ },
+ /**
+ * Set list of message IDs of this document thread
+ *
+ * Useful in order to handle message history of the document thread,
+ * in particular to fetch messages when necessary and/or display 'load more'.
+ *
+ * @param {integer[]} messageIDs
+ */
+ setMessageIDs: function (messageIDs) {
+ this._mustFetchMessageIDs = false;
+ this._messageIDs = messageIDs;
+ },
+ /**
+ * Set the name of this document thread
+ *
+ * This is useful if the name of the document related to the document thread
+ * has changed
+ *
+ * @param {string} newName
+ */
+ setName: function (newName) {
+ this._name = newName;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get most up to date messageIDs
+ *
+ * @private
+ * @returns {$.Promise} resolved when message IDs have been fetched and set
+ * in the model
+ */
+ _fetchMessageIDs: function () {
+ var self = this;
+ var resID = this.getDocumentID();
+ if (!resID || !this._mustFetchMessageIDs) {
+ return $.when();
+ }
+ return this._rpc({
+ model: this.getDocumentModel(),
+ method: 'read',
+ args: [[resID], ['message_ids']],
+ }).then(function (result) {
+ self.setMessageIDs(result[0].message_ids);
+ });
+ },
+ /**
+ * @override
+ * @private
+ * @param {Object} options
+ * @param {boolean} [options.forceFetch]
+ * @returns {$.Promise} resolved when messages have been fetched + document
+ * thread has updated messages
+ */
+ _fetchMessages: function (options) {
+ var self = this;
+ return this._fetchMessageIDs().then(function () {
+ var messageIDs = self._messageIDs;
+ var loadedMessages = _.filter(self._messages, function (message) {
+ return _.contains(messageIDs, message.getID());
+ });
+ var loadedMessageIDs = _.map(loadedMessages, function (message) {
+ return message.getID();
+ });
+
+ options = options || {};
+ var shouldFetch = _.difference(
+ messageIDs.slice(0, self._FETCH_LIMIT),
+ loadedMessageIDs
+ ).length > 0;
+ if (options.forceFetch || shouldFetch) {
+ var idsToLoad = _.difference(messageIDs, loadedMessageIDs)
+ .slice(0, self._FETCH_LIMIT);
+ return self._rpc({
+ model: 'mail.message',
+ method: 'message_format',
+ args: [idsToLoad],
+ context: session.user_context,
+ })
+ .then(function (messagesData) {
+ _.each(messagesData, function (messageData) {
+ self.call('mail_service', 'addMessage', messageData, { silent: true });
+ });
+ });
+ } else {
+ return $.when();
+ }
+
+ });
+ },
+
+});
+
+return DocumentThread;
+
+});
diff --git a/addons/mail/static/src/js/models/mail_failure.js b/addons/mail/static/src/js/models/mail_failure.js
new file mode 100644
index 00000000000..fe6241ff032
--- /dev/null
+++ b/addons/mail/static/src/js/models/mail_failure.js
@@ -0,0 +1,102 @@
+odoo.define('mail.model.MailFailure', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var core = require('web.core');
+var Mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+var time = require('web.time');
+
+var _t = core._t;
+
+var MailFailure = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @param {mail.Manager} parent
+ * @param {Object} data
+ * @param {string} data.last_message_date
+ * @param {integer} data.message_id
+ * @param {string} data.model
+ * @param {string} data.model_name
+ * @param {string} data.module_icon
+ * @param {Array} data.notifications
+ * @param {string} data.record_name
+ * @param {integer} data.res_id
+ * @param {string} data.uuid
+ */
+ init: function (parent, data) {
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.setParent(parent);
+
+ this._documentID = data.res_id;
+ this._documentModel = data.model;
+ this._lastMessageDate = moment(); // by default: current datetime
+ this._messageID = data.message_id;
+ this._modelName = data.model_name;
+ this._moduleIcon = data.module_icon;
+ this._notifications = data.notifications;
+ this._recordName = data.record_name;
+ this._uuid = data.uuid;
+
+ if (data.last_message_date) {
+ this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date));
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the document model that this mail failure is linked to.
+ *
+ * @returns {string}
+ */
+ getDocumentModel: function () {
+ return this._documentModel;
+ },
+ /**
+ * Get the document ID that this mail failure is linked to.
+ *
+ * @returns {integer}
+ */
+ getDocumentID: function () {
+ return this._documentID;
+ },
+ /**
+ * Get the ID of the message that this mail failure is related to.
+ *
+ * @returns {integer}
+ */
+ getMessageID: function () {
+ return this._messageID;
+ },
+ /**
+ * Get a valid object for the 'mail.preview' template
+ *
+ * @returns {Object}
+ */
+ getPreview: function () {
+ var preview = {
+ body: _t("An error occured when sending an email"),
+ date: this._lastMessageDate,
+ documentID: this._documentID,
+ documentModel: this._documentModel,
+ id: 'mail_failure',
+ imageSRC: this._moduleIcon,
+ title: this._recordName,
+ };
+ return preview;
+ },
+ /**
+ * Tell whether the mail failure comes from a message in a document thread.
+ *
+ * @returns {boolean}
+ */
+ isLinkedToDocumentThread: function () {
+ return !!(this._documentModel !== 'mail.channel' && this._documentID);
+ },
+});
+
+return MailFailure;
+
+});
diff --git a/addons/mail/static/src/js/models/mail_model.js b/addons/mail/static/src/js/models/mail_model.js
new file mode 100644
index 00000000000..8f081eb3464
--- /dev/null
+++ b/addons/mail/static/src/js/models/mail_model.js
@@ -0,0 +1,20 @@
+odoo.define('mail.Model', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var Mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+
+var MailModel = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @param {mail.Manager} parent
+ */
+ init: function (parent) {
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.setParent(parent);
+ },
+});
+
+return MailModel;
+
+});
diff --git a/addons/mail/static/src/js/models/mailbox.js b/addons/mail/static/src/js/models/mailbox.js
new file mode 100644
index 00000000000..bcb5ae609b7
--- /dev/null
+++ b/addons/mail/static/src/js/models/mailbox.js
@@ -0,0 +1,213 @@
+odoo.define('mail.model.Mailbox', function (require) {
+"use strict";
+
+var ThreadWithCache = require('mail.model.ThreadWithCache');
+
+var core = require('web.core');
+
+var _t = core._t;
+
+/**
+ * Mailboxes, called "static channels" in earlier version of Odoo, are threads
+ * that are not represented on the server. We can see them as "threads that are
+ * not conversations". For instance, the well-known "Inbox" folder contains a
+ * list of messages, but the inbox does not represent a conversation: Inbox is
+ * modeled as a mailbox.
+ */
+var Mailbox = ThreadWithCache.extend({
+
+ /**
+ * @override
+ * @param {Object} data
+ * @param {string} data.id the ID of the mailbox, without the 'mailbox_'
+ * prefix.
+ * @param {integer} [data.mailboxCounter=0] the initial mailbox counter of
+ * this mailbox.
+ */
+ init: function (parent, data) {
+ // ID or mailboxes are always prefixed with 'mailbox_'
+ data.id = 'mailbox_' + data.id;
+ data.type = 'mailbox';
+ this._super.apply(this, arguments);
+ this._mailboxCounter = data.mailboxCounter || 0;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Decrement the counter of this mailbox. It floors to 0.
+ *
+ * @param {integer} [num=1] how many, at most, the counter is decremented
+ */
+ decrementMailboxCounter: function (num) {
+ num = _.isNumber(num) ? num : 1;
+ this._mailboxCounter = Math.max(this._mailboxCounter - num, 0);
+ },
+ /**
+ * Get the local messages of the mailbox (by local messages, we mean
+ * messages that have already been fetched from the server).
+ *
+ * It is possible to filter on local messages that are specific to a
+ * document with the `options` parameter.
+ *
+ * @param {Object} [options]
+ * @param {string} [options.documentModel] model of the document that the
+ * local messages of inbox must be linked to.
+ * @param {integer} [options.documentID] ID of the document that the local
+ * messages of inbox must be linked to.
+ */
+ getLocalMessages: function (options) {
+ var localMessages = this._cache['[]'].messages;
+ if (!options) {
+ return localMessages;
+ }
+ if (options.documentModel && options.documentID) {
+ return _.filter(localMessages, function (localMessage) {
+ return localMessage.getDocumentModel() === options.documentModel &&
+ localMessage.getDocumentID() === options.documentID;
+ });
+ }
+ },
+ /**
+ * Get the mailbox counter of this mailbox.
+ *
+ * @returns {integer}
+ */
+ getMailboxCounter: function () {
+ return this._mailboxCounter;
+ },
+ /**
+ * Get the preview of the messages from the inbox mailbox.
+ * If there are multiple messages associated to a similar document or
+ * channel, only keep the lastest message of this document/channel in the
+ * previews.
+ *
+ * @returns {$.Promise<Object[]>}
+ */
+ getMessagePreviews: function () {
+ var self = this;
+
+ return this.getMessages().then(function (messages) {
+ // pick only last message of chatter
+ // items = list of objects
+ // { unreadCounter: integer, message: mail.model.Message }
+ var items = [];
+ _.each(messages, function (message) {
+ var unreadCounter = 1;
+ var similarItem = _.find(items, function (item) {
+ return self._areMessagesFromSameDocumentThread(item.message, message) ||
+ self._areMessagesFromSameChannel(item.message, message);
+ });
+ if (similarItem) {
+ unreadCounter = similarItem.unreadCounter + 1;
+ var index = _.findIndex(items, similarItem);
+ items[index] = {
+ unreadCounter: unreadCounter,
+ message: message,
+ };
+ } else {
+ items.push({
+ unreadCounter: unreadCounter,
+ message: message,
+ });
+ }
+ });
+ return _.map(items, function (item) {
+ return _.extend(item.message.getPreview(), {
+ unreadCounter: item.unreadCounter,
+ });
+ });
+ });
+ },
+ /**
+ * Increment the counter of this mailbox
+ *
+ * @param {integer} [num=1] how many, the counter is incremented
+ */
+ incrementMailboxCounter: function (num) {
+ num = _.isNumber(num) ? num : 1;
+ this._mailboxCounter = Math.max(this._mailboxCounter + num, 0);
+ },
+ /**
+ * Marks all messages from the mailbox as read. At the moment, this method
+ * makes only sense for 'Inbox'.
+ *
+ * @param {Array} domain
+ * @return {$.Promise} resolved when all messages have been marked as read
+ * on the server
+ */
+ markAllMessagesAsRead: function (domain) {
+ if (this._id === 'mailbox_inbox' && this.getMailboxCounter() > 0) {
+ return this._rpc({
+ model: 'mail.message',
+ method: 'mark_all_as_read',
+ kwargs: {
+ channel_ids: [],
+ domain: domain,
+ },
+ });
+ }
+ return $.when();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Tell whether the two messages originate from the same channel.
+ * If any one of them does not originate from a channel, returns false.
+ *
+ * @private
+ * @param {mail.model.Message} message1
+ * @param {mail.model.Message} message2
+ * @returns {boolean}
+ */
+ _areMessagesFromSameChannel: function (message1, message2) {
+ return message1.originatesFromChannel() &&
+ message2.originatesFromChannel() &&
+ message1.getOriginChannelID() === message2.getOriginChannelID();
+ },
+ /**
+ * Tell whether the two messages are linked to the same document thread.
+ * If any one of them is not linked to a document, returns false.
+ *
+ * @private
+ * @param {mail.model.Message} message1
+ * @param {mail.model.Message} message2
+ * @returns {boolean}
+ */
+ _areMessagesFromSameDocumentThread: function (message1, message2) {
+ return message1.isLinkedToDocumentThread() &&
+ message2.isLinkedToDocumentThread() &&
+ message1.getDocumentModel() === message2.getDocumentModel() &&
+ message1.getDocumentID() === message2.getDocumentID();
+ },
+ /**
+ * Get the domain to fetch all the messages in the current mailbox
+ *
+ * @override
+ * @private
+ * @returns {Array}
+ * @throws on missing domain for the provided mailbox (mailboxes should
+ * always have a domain on messages to fetch)
+ */
+ _getThreadDomain: function () {
+ if (this._id === 'mailbox_inbox') {
+ return [['needaction', '=', true]];
+ } else if (this._id === 'mailbox_starred') {
+ return [['starred', '=', true]];
+ } else if (this._id === 'mailbox_moderation') {
+ return [['need_moderation', '=', true]];
+ } else {
+ throw _t(_.str("Missing domain for mailbox with ID '%s'", this._id));
+ }
+ },
+
+});
+
+return Mailbox;
+
+});
diff --git a/addons/mail/static/src/js/models/messages/abstract_message.js b/addons/mail/static/src/js/models/messages/abstract_message.js
new file mode 100644
index 00000000000..4cdb2273008
--- /dev/null
+++ b/addons/mail/static/src/js/models/messages/abstract_message.js
@@ -0,0 +1,409 @@
+odoo.define('mail.model.AbstractMessage', function (require) {
+"use strict";
+
+var mailUtils = require('mail.utils');
+
+var Class = require('web.Class');
+var core = require('web.core');
+var session = require('web.session');
+var time = require('web.time');
+
+var _t = core._t;
+
+/**
+ * This is an abstract class for modeling messages in JS.
+ * The purpose of this interface is to make im_livechat compatible with
+ * mail.widget.Thread, as this widget was designed to work with messages that
+ * are instances of mail.model.Messages.
+ *
+ * Ideally, im_livechat should also handle mail.model.Message, but this is not
+ * feasible for the moment, as mail.model.Message requires mail.Manager to work,
+ * and this module should not leak outside of the backend, hence the use of
+ * mail.model.AbstractMessage as a work-around.
+ */
+var AbstractMessage = Class.extend({
+
+ /**
+ * @param {Widget} parent
+ * @param {Object} data
+ * @param {Array} [data.attachment_ids=[]]
+ * @param {Array} [data.author_id]
+ * @param {string} [data.body = ""]
+ * @param {string} [data.date] the server-format date time of the message.
+ * If not provided, use current date time for this message.
+ * @param {integer} data.id
+ * @param {boolean} [data.is_discussion = false]
+ * @param {boolean} [data.is_notification = false]
+ * @param {string} [data.message_type = undefined]
+ */
+ init: function (parent, data) {
+ this._attachmentIDs = data.attachment_ids || [];
+ this._body = data.body || "";
+ // by default: current datetime
+ this._date = data.date ? moment(time.str_to_datetime(data.date)) : moment();
+ this._id = data.id;
+ this._isDiscussion = data.is_discussion;
+ this._isNotification = data.is_notification;
+ // if set, the message should display the author of the message
+ this._displayAuthor = false;
+ this._serverAuthorID = data.author_id;
+ this._type = data.message_type || undefined;
+
+ this._processAttachmentURL();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Set whether the message should display the author or not
+ *
+ * @param {boolean} bool if set, display the author of this message
+ */
+ displayAuthor: function (bool) {
+ this._displayAuthor = bool;
+ },
+ /**
+ * Get the list of files attached to this message.
+ * Note that attachments are stored with server-format
+ *
+ * @return {Object[]}
+ */
+ getAttachments: function () {
+ return this._attachmentIDs;
+ },
+ /**
+ * Get the server ID (number) of the author of this message
+ * If there are no author, return -1;
+ *
+ * @return {integer}
+ */
+ getAuthorID: function () {
+ if (!this.hasAuthor()) {
+ return -1;
+ }
+ return this._serverAuthorID[0];
+ },
+ /**
+ * Get the relative url of the avatar to display next to the message
+ *
+ * @abstract
+ * @return {string}
+ */
+ getAvatarSource: function () {},
+ /**
+ * Get the body content of this message
+ *
+ * @return {string}
+ */
+ getBody: function () {
+ return this._body;
+ },
+ /**
+ * @return {moment}
+ */
+ getDate: function () {
+ return this._date;
+ },
+ /**
+ * Get the date day of this message
+ *
+ * @return {string}
+ */
+ getDateDay: function () {
+ var date = this.getDate().format('YYYY-MM-DD');
+ if (date === moment().format('YYYY-MM-DD')) {
+ return _t("Today");
+ } else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) {
+ return _t("Yesterday");
+ }
+ return this.getDate().format('LL');
+ },
+ /**
+ * Get the name of the author, if there is an author of this message
+ * If there are no author of this message, returns 'null'
+ *
+ * @return {string}
+ */
+ getDisplayedAuthor: function () {
+ return this.hasAuthor() ? this._getAuthorName() : null;
+ },
+ /**
+ * Get the server ID (number) of this message
+ *
+ * @override
+ * @return {integer}
+ */
+ getID: function () {
+ return this._id;
+ },
+ /**
+ * Get the list of images attached to this message.
+ * Note that attachments are stored with server-format
+ *
+ * @return {Object[]}
+ */
+ getImageAttachments: function () {
+ return _.filter(this.getAttachments(), function (attachment) {
+ return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
+ });
+ },
+ /**
+ * Get the list of non-images attached to this message.
+ * Note that attachments are stored with server-format
+ *
+ * @return {Object[]}
+ */
+ getNonImageAttachments: function () {
+ return _.difference(this.getAttachments(), this.getImageAttachments());
+ },
+ /**
+ * Get the time elapsed between sent message and now
+ *
+ * @return {string}
+ */
+ getTimeElapsed: function () {
+ return mailUtils.timeFromNow(this.getDate());
+ },
+ /**
+ * Get the type of message (e.g. 'comment', 'email', 'notification', ...)
+ * By default, messages are of type 'undefined'
+ *
+ * @override
+ * @return {string|undefined}
+ */
+ getType: function () {
+ return this._type;
+ },
+ /**
+ * State whether this message contains some attachments.
+ *
+ * @override
+ * @return {boolean}
+ */
+ hasAttachments: function () {
+ return this.getAttachments().length > 0;
+ },
+ /**
+ * State whether this message has an author
+ *
+ * @return {boolean}
+ */
+ hasAuthor: function () {
+ return !!(this._serverAuthorID && this._serverAuthorID[0]);
+ },
+ /**
+ * State whether this message contains some customer email data
+ * By default, messages do not have any customer email data
+ *
+ * @return {boolean}
+ */
+ hasCustomerEmailData: function () {
+ return false;
+ },
+ /**
+ * State whether this message has an email of its sender.
+ * By default, messages do not have any email of its sender.
+ *
+ * @return {string}
+ */
+ hasEmailFrom: function () {
+ return false;
+ },
+ /**
+ * State whether this image contains images attachments
+ *
+ * @return {boolean}
+ */
+ hasImageAttachments: function () {
+ return _.some(this.getAttachments(), function (attachment) {
+ return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
+ });
+ },
+ /**
+ * State whether this image contains non-images attachments
+ *
+ * @return {boolean}
+ */
+ hasNonImageAttachments: function () {
+ return _.some(this.getAttachments(), function (attachment) {
+ return !(attachment.mimetype && attachment.mimetype.split('/')[0] === 'image');
+ });
+ },
+ /**
+ * State whether this message originates from a channel.
+ * By default, messages do not originate from a channel.
+ *
+ * @override
+ * @return {boolean}
+ */
+ originatesFromChannel: function () {
+ return false;
+ },
+ /**
+ * State whether this message has a subject
+ * By default, messages do not have any subject.
+ *
+ * @return {boolean}
+ */
+ hasSubject: function () {
+ return false;
+ },
+ /**
+ * By default, messages do not have any subtype description
+ *
+ * @return {boolean}
+ */
+ hasSubtypeDescription: function () {
+ return false;
+ },
+ /**
+ * State whether this message contains some tracking values
+ * By default, messages do not have any tracking values.
+ *
+ * @return {boolean}
+ */
+ hasTrackingValues: function () {
+ return false;
+ },
+ /**
+ * State whether this message is a discussion
+ *
+ * @return {boolean}
+ */
+ isDiscussion: function () {
+ return this._isDiscussion;
+ },
+ /**
+ * State whether this message is linked to a document
+ * By default, messages are not linked to a document.
+ *
+ * @return {boolean}
+ */
+ isLinkedToDocument: function () {
+ return false;
+ },
+ /**
+ * State whether this message is needaction
+ * By default, messages are not needaction.
+ *
+ * @return {boolean}
+ */
+ isNeedaction: function () {
+ return false;
+ },
+ /**
+ * State whether this message is a note (i.e. a message from "Log note")
+ *
+ * @return {boolean}
+ */
+ isNote: function () {
+ return this._isNote;
+ },
+ /**
+ * State whether this message is a notification
+ *
+ * User notifications are defined as either
+ * - notes
+ * - pushed to user Inbox or email through classic notification process
+ * - not linked to any document, meaning model and res_id are void
+ *
+ * This is useful in order to display white background for user
+ * notifications in chatter
+ *
+ * @returns {boolean}
+ */
+ isNotification: function () {
+ return this._isNotification;
+ },
+ /**
+ * State whether this message is starred
+ * By default, messages are not starred.
+ *
+ * @return {boolean}
+ */
+ isStarred: function () {
+ return false;
+ },
+ /**
+ * State whether this message is a system notification
+ * By default, messages are not system notifications
+ *
+ * @override
+ * @return {boolean}
+ */
+ isSystemNotification: function () {
+ return false;
+ },
+ /**
+ * States whether the current message needs moderation in general.
+ * By default, messages do not require any moderation.
+ *
+ * @returns {boolean}
+ */
+ needsModeration: function () {
+ return false;
+ },
+ /**
+ * State whether this message should display the author
+ *
+ * @return {boolean}
+ */
+ shouldDisplayAuthor: function () {
+ return this._displayAuthor;
+ },
+ /**
+ * State whether this message should redirect to the author
+ * when clicking on the author of this message.
+ *
+ * Do not redirect on author clicked of self-posted messages.
+ *
+ * @return {boolean}
+ */
+ shouldRedirectToAuthor: function () {
+ return !this._isAuthor();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the name of the author of this message.
+ * If there are no author of this messages, returns '' (empty string).
+ *
+ * @private
+ * @returns {string}
+ */
+ _getAuthorName: function () {
+ if (!this.hasAuthor()) {
+ return "";
+ }
+ return this._serverAuthorID[1];
+ },
+ /**
+ * State whether the current user is the author of this message
+ *
+ * @private
+ * @return {boolean}
+ */
+ _isAuthor: function () {
+ return this.hasAuthor() && (this.getAuthorID() === session.partner_id);
+ },
+ /**
+ * Compute url of attachments of this message
+ *
+ * @private
+ */
+ _processAttachmentURL: function () {
+ _.each(this.getAttachments(), function (attachment) {
+ attachment.url = '/web/content/' + attachment.id + '?download=true';
+ });
+ },
+
+});
+
+return AbstractMessage;
+
+});
diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js
new file mode 100644
index 00000000000..06b776ea580
--- /dev/null
+++ b/addons/mail/static/src/js/models/messages/message.js
@@ -0,0 +1,780 @@
+odoo.define('mail.model.Message', function (require) {
+"use strict";
+
+var emojis = require('mail.emojis');
+var AbstractMessage = require('mail.model.AbstractMessage');
+var mailUtils = require('mail.utils');
+
+var core = require('web.core');
+var Mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+var session = require('web.session');
+
+var _t = core._t;
+
+/**
+ * This is the main class for the modeling messages in JS.
+ * Such messages are stored in the mail manager, and any piece of JS code whose
+ * logic relies on threads must ideally interact with such objects.
+ */
+var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @override
+ * @param {web.Manager} parent
+ * @param {Object} data
+ * @param {string} [data.body = ""]
+ * @param {(string|integer)[]} [data.channel_ids]
+ * @param {Object[]} [data.customer_email_data]
+ * @param {string} [data.customer_email_status]
+ * @param {string} [data.email_from]
+ * @param {string} [data.info]
+ * @param {string} [data.model]
+ * @param {string} [data.moderation_status='accepted']
+ * @param {string} [data.module_icon]
+ * @param {Array} [data.needaction_partner_ids = []]
+ * @param {string} [data.record_name]
+ * @param {integer} [data.res_id]
+ * @param {Array} [data.starred_partner_ids = []]
+ * @param {string} [data.subject]
+ * @param {string} [data.subtype_description]
+ * @param {Object[]} [data.tracking_value_ids]
+ * @param {Object[]} emojis
+ */
+ init: function (parent, data, emojis) {
+ this._super.apply(this, arguments);
+
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.setParent(parent);
+
+ this._customerEmailData = data.customer_email_data || [];
+ this._customerEmailStatus = data.customer_email_status;
+ this._documentModel = data.model;
+ this._documentName = data.record_name;
+ this._documentID = data.res_id;
+ this._emailFrom = data.email_from;
+ this._info = data.info;
+ this._moduleIcon = data.module_icon;
+ this._needactionPartnerIDs = data.needaction_partner_ids || [];
+ this._starredPartnerIDs = data.starred_partner_ids || [];
+ this._subject = data.subject;
+ this._subtypeDescription = data.subtype_description;
+ this._threadIDs = data.channel_ids || [];
+ this._trackingValueIDs = data.tracking_value_ids;
+
+ this._moderationStatus = data.moderation_status || 'accepted';
+
+ this._processBody(emojis);
+ this._processMailboxes();
+ this._processModeration();
+ this._processDocumentThread();
+ this._processTrackingValues();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {Object} data
+ */
+ addCustomerEmailData: function (data) {
+ this._customerEmailData.push(data);
+ },
+ /**
+ * Get the name of the author of this message
+ * If there are no author, return "".
+ *
+ * @return {string}
+ */
+ getAuthorName: function () {
+ return this._getAuthorName();
+ },
+ /**
+ * Get the relative url of the avatar to display next to the message
+ *
+ * @override
+ * @return {string}
+ */
+ getAvatarSource: function () {
+ if (this._isOdoobotAuthor()) {
+ return '/mail/static/src/img/odoo_o.png';
+ } else if (this.hasAuthor()) {
+ return '/web/image/res.partner/' + this.getAuthorID() + '/image_small';
+ } else if (this.getType() === 'email') {
+ return '/mail/static/src/img/email_icon.png';
+ }
+ return '/mail/static/src/img/smiley/avatar.jpg';
+ },
+ /**
+ * Get the customer email data of this email, if any.
+ * If this message has no such data, returns 'undefined'
+ *
+ * @return {Object[]|undefined}
+ */
+ getCustomerEmailData: function () {
+ if (!this.hasCustomerEmailData()) {
+ return undefined;
+ }
+ return this._customerEmailData;
+ },
+ /**
+ * Get the customer email status of this email, if any.
+ * If this message has no such data, returns 'undefined'
+ *
+ * @return {string|undefined}
+ */
+ getCustomerEmailStatus: function () {
+ if (!this.hasCustomerEmailData()) {
+ return undefined;
+ }
+ return this._customerEmailStatus;
+ },
+ /**
+ * Get the text to display for the author of the message
+ *
+ * Rule of precedence for the displayed author:
+ *
+ * author name > sender email > "anonymous"
+ *
+ * @override
+ * @return {string}
+ */
+ getDisplayedAuthor: function () {
+ var res = this._super.apply(this, arguments);
+ if (res) {
+ return res;
+ } else {
+ return this.hasEmailFrom() ? this.getEmailFrom() :
+ _t("Anonymous");
+ }
+ },
+ /**
+ * Get the ID of the document that this message is linked.
+ *
+ * @override
+ * @return {integer}
+ */
+ getDocumentID: function () {
+ return this._documentID;
+ },
+ /**
+ * Get the model of the document that this message is linked.
+ * If this message is not linked to a document, returns "".
+ *
+ * @override
+ * @return {string}
+ */
+ getDocumentModel: function () {
+ return this._documentModel;
+ },
+ /**
+ * Get the name of the document that this message is linked.
+ * If this message is not linked to a document, returns "".
+ *
+ * It uses the name of an existing document thread, in order to have the
+ * updated document name. If there is no document thread yet, use the
+ * currently aware document name.
+ *
+ * As a result, this method should ideally be called internally, in order
+ * to avoid having different names for messages linked to a same modified
+ * document.
+ *
+ * @override
+ * @return {string}
+ */
+ getDocumentName: function () {
+ if (!this.isLinkedToDocumentThread()) {
+ return "";
+ }
+ var model = this.getDocumentModel();
+ var id = this.getDocumentID();
+ var documentThread = this.call('mail_service', 'getDocumentThread', model, id);
+ if (documentThread) {
+ this._documentName = documentThread.getName();
+ }
+ return this._documentName;
+ },
+ /**
+ * Get the email of the sender of this message.
+ * If this email has no sender email, returns "".
+ *
+ * @return {string}
+ */
+ getEmailFrom: function () {
+ if (!this.hasEmailFrom()) {
+ return "";
+ }
+ return this._emailFrom;
+ },
+ /**
+ * Get the ID of the channel that this message originates from.
+ * If this message does not originate from a channel, returns `-1`.
+ *
+ * @override
+ * @return {integer}
+ */
+ getOriginChannelID: function () {
+ if (!this.originatesFromChannel()) {
+ return -1;
+ }
+ return this._documentID;
+ },
+ /**
+ * Get the name of the channel that this message originates from.
+ * If this message does not originate from a channel, returns "".
+ *
+ * @override
+ * @returns {string}
+ */
+ getOriginChannelName: function () {
+ if (!this.originatesFromChannel()) {
+ return "";
+ }
+ var originChannelID = this.getOriginChannelID();
+ var channel = originChannelID && this.call(
+ 'mail_service',
+ 'getChannel',
+ originChannelID);
+ if (!channel) {
+ return "";
+ }
+ return channel.getName();
+ },
+ /**
+ * Returns the information required to render the preview of this channel.
+ *
+ * @return {Object}
+ */
+ getPreview: function () {
+ var id, title;
+ if (this.isLinkedToDocumentThread()) {
+ id = this.getDocumentModel() + '_' + this.getDocumentID();
+ title = this.getDocumentName();
+ } else {
+ id = 'mailbox_inbox';
+ title = this.hasSubject() ? this.getSubject() : this.getDisplayedAuthor();
+ }
+ return {
+ author: this.getDisplayedAuthor(),
+ body: mailUtils.parseAndTransform(this.getBody(), mailUtils.inline),
+ date: this.getDate(),
+ documentModel: this.getDocumentModel(),
+ documentID: this.getDocumentID(),
+ id: id,
+ imageSRC: this._getModuleIcon() || this.getAvatarSource(),
+ status: this.status,
+ title: title,
+ };
+ },
+ /**
+ * Get the subject of this message
+ * If this message has no subject, returns "".
+ *
+ * @override
+ * @return {string}
+ */
+ getSubject: function () {
+ if (!this.hasSubject()) {
+ return "";
+ }
+ return this._subject;
+ },
+ /**
+ * @override
+ * @return {string}
+ */
+ getSubtypeDescription: function () {
+ return this._subtypeDescription;
+ },
+ /**
+ * Get the list of thread IDs that this message is linked to
+ * If this message is not linked to a thread, returns 'undefined'
+ *
+ * @return {string[]|undefined} list of thread IDs, if any
+ */
+ getThreadIDs: function () {
+ return this._threadIDs;
+ },
+ /**
+ * Get the tracking values of this message
+ * If this message has no tracking values, returns 'undefined'
+ *
+ * @override
+ * @return {Object[]|undefined}
+ */
+ getTrackingValues: function () {
+ if (!this.hasTrackingValues()) {
+ return undefined;
+ }
+ return this._trackingValueIDs;
+ },
+ /**
+ * @override
+ * @return {string}
+ */
+ getURL: function () {
+ return session.url('/mail/view?message_id=' + this._id);
+ },
+ /**
+ * State whether this message contains some customer email data
+ *
+ * @override
+ * @return {boolean}
+ */
+ hasCustomerEmailData: function () {
+ return !!(this._customerEmailData && (this._customerEmailData.length > 0));
+ },
+ /**
+ * State whether this message has an email of its sender.
+ *
+ * @override
+ * @return {string}
+ */
+ hasEmailFrom: function () {
+ return !!(this._emailFrom);
+ },
+ /**
+ * State whether this message has a subject
+ *
+ * @return {boolean}
+ */
+ hasSubject: function () {
+ return !!(this._subject);
+ },
+ /**
+ * @override
+ * @return {boolean}
+ */
+ hasSubtypeDescription: function () {
+ return !!(this._subtypeDescription);
+ },
+ /**
+ * State whether this message contains some tracking values
+ *
+ * @override
+ * @return {boolean}
+ */
+ hasTrackingValues: function () {
+ return !!(this._trackingValueIDs && (this._trackingValueIDs.length > 0));
+ },
+ /**
+ * State whether the current user is the author of this message
+ *
+ * @return {boolean}
+ */
+ isAuthor: function () {
+ return this._isAuthor();
+ },
+ /**
+ * State whether this message is linked to a document thread (not channel)
+ *
+ * Usually, if this is true, then this message comes from a document thread,
+ * but the document model could be a channel. In that case, the document
+ * resID tells the channel that this message originally comes from.
+ *
+ * To detect whether the message comes from a channel, see the method
+ * 'originatesFromChannel'.
+ *
+ * @override
+ * @return {boolean}
+ */
+ isLinkedToDocumentThread: function () {
+ return !!(this._documentModel !== 'mail.channel' && this._documentID);
+ },
+ /**
+ * States whether the current message needs moderation in general.
+ *
+ * @override
+ * @returns {boolean}
+ */
+ needsModeration: function () {
+ return this._moderationStatus === 'pending_moderation';
+ },
+ /**
+ * States whether the current message needs moderation by the current user.
+ * Such a message should be in the moderation mailbox.
+ *
+ * @returns {boolean}
+ */
+ needsModerationByUser: function () {
+ return _.contains(this._threadIDs, 'mailbox_moderation');
+ },
+ /**
+ * State whether this message is needaction
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isNeedaction: function () {
+ return _.contains(this._threadIDs, 'mailbox_inbox');
+ },
+ /**
+ * State whether this message is starred
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isStarred: function () {
+ return _.contains(this._threadIDs, 'mailbox_starred');
+ },
+ /**
+ * State whether this message is a system notification
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isSystemNotification: function () {
+ return (
+ this.getType() === 'notification' &&
+ this._documentModel === 'mail.channel'
+ ) || this._isTransient();
+ },
+ /**
+ * States whether the message originates from a channel or not
+ *
+ * @returns {boolean}
+ */
+ originatesFromChannel: function () {
+ return this._documentModel === 'mail.channel';
+ },
+ /**
+ * Unregister thread with ID `threadID` from this message
+ *
+ * @param {string|integer} threadID ID of thread
+ */
+ removeThread: function (threadID) {
+ this._threadIDs = _.without(this._threadIDs, threadID);
+ },
+ /**
+ * Update the moderation status of the message, so that it is now accepted
+ * or rejected. When the message is accepted, it may be linked to more
+ * threads, which is the case for relay channels on moderated channels.
+ *
+ * @param {string} newModerationStatus ['accepted', 'rejected']
+ * @param {Object} [options]
+ * @param {Object} [options.additionalThreadIDs] contains additional thread
+ * IDs to be registered on the message.
+ */
+ setModerationStatus: function (newModerationStatus, options) {
+ var self = this;
+ this._moderationStatus = newModerationStatus;
+ if (newModerationStatus === 'accepted' && options) {
+ _.each(options.additionalThreadIDs, function (threadID) {
+ self._addThread(threadID);
+ });
+ }
+ this._warnMessageModerated();
+ },
+ /**
+ * Set whether the message is starred or not.
+ * If it is starred, the message is moved to the "Starred" mailbox.
+ * Note that this function only applies it locally, the server is not aware
+ *
+ * @param {boolean} starred if set, the message is starred
+ */
+ setStarred: function (starred) {
+ if (starred) {
+ this._addThread('mailbox_starred');
+ } else {
+ this.removeThread('mailbox_starred');
+ }
+ },
+ /**
+ * State whether this message should display the subject
+ *
+ * @return {boolean}
+ */
+ shouldDisplaySubject: function () {
+ return this.hasSubject() &&
+ this.getType() !== 'notification' &&
+ !this.originatesFromChannel();
+ },
+ /**
+ * State whether this message should redirect to the author
+ * when clicking on the author of this message.
+ *
+ * Do not redirect on author clicked of self-posted or Odoobot messages
+ * (note: Odoobot is the default author of transient messages)
+ *
+ * @override
+ * @return {boolean}
+ */
+ shouldRedirectToAuthor: function () {
+ return this._super.apply(this, arguments) && !this._isOdoobotAuthor();
+ },
+ /**
+ * Toggle the star status of the message
+ *
+ * It relies on the star status of the message from the date of the server.
+ * The star status is updated from a 'toggle_star' notification on the
+ * longpoll bus
+ *
+ * @see {mail.Manager.Notification} for the receipt of 'toggle_star'
+ * notification after this rpc.
+ *
+ * @return {$.Promise}
+ */
+ toggleStarStatus: function () {
+ return this._rpc({
+ model: 'mail.message',
+ method: 'toggle_message_starred',
+ args: [[this._id]],
+ });
+ },
+ /**
+ * Update the customer email status
+ *
+ * @param {string} newCustomerEmailStatus
+ */
+ updateCustomerEmailStatus: function (newCustomerEmailStatus) {
+ this._customerEmailStatus = newCustomerEmailStatus;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Register a thread to the message
+ * Useful when you mark a message as 'to do'.
+ * This message will be available in 'Starred' mailbox.
+ *
+ * @private
+ * @param {string|integer} threadID
+ */
+ _addThread: function (threadID) {
+ if (!this._threadIDs) {
+ this._threadIDs = [];
+ }
+ this._threadIDs.push(threadID);
+ this._threadIDs = _.uniq(this._threadIDs);
+ },
+ /**
+ * Get the name of the author of this message.
+ * If Odoobot is the author (default author for transient messages),
+ * returns 'Odoobot'.
+ *
+ * @override
+ * @private
+ * @returns {string}
+ */
+ _getAuthorName: function () {
+ if (this._isOdoobotAuthor()) {
+ return "Odoobot";
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @private
+ * @return {string}
+ */
+ _getModuleIcon: function () {
+ return this._moduleIcon;
+ },
+ /**
+ * State if the author of this message is Odoobot
+ * This is the default author for transient messages.
+ *
+ * @private
+ * @return {boolean}
+ */
+ _isOdoobotAuthor: function () {
+ return this._serverAuthorID === this.call('mail_service', 'getOdoobotID');
+ },
+ /**
+ * State whether the message is transient or not
+ *
+ * @private
+ * @return {boolean}
+ */
+ _isTransient: function () {
+ return this._info === 'transient_message';
+ },
+ /**
+ * Convert the server-format body of the message to the client-format.
+ * Basically, it processes emojis and url.
+ *
+ * @private
+ */
+ _processBody: function () {
+ var self = this;
+
+ _.each(emojis, function (emoji) {
+ var unicode = emoji.unicode;
+ var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|</[a-z]*>)", 'g');
+ var originalBody = self.body;
+ self._body = self._body.replace(regexp,
+ ' <span class="o_mail_emoji">' + unicode + '</span> ');
+ // Idiot-proof limit. If the user had the amazing idea of
+ // copy-pasting thousands of emojis, the image rendering can lead
+ // to memory overflow errors on some browsers (e.g. Chrome). Set an
+ // arbitrary limit to 200 from which we simply don't replace them
+ // (anyway, they are already replaced by the unicode counterpart).
+ if (_.str.count(self._body, 'o_mail_emoji') > 200) {
+ self._body = originalBody;
+ }
+ });
+
+ // add anchor tags to urls
+ self._body = mailUtils.parseAndTransform(self._body, mailUtils.addLink);
+ },
+ /**
+ * If the message is linked to a document, link this message to the related
+ * document thread. If there is no document thread yet, create it
+ */
+ _processDocumentThread: function () {
+ if (!this.isLinkedToDocumentThread()) {
+ return;
+ }
+ var resModel = this.getDocumentModel();
+ var resID = this.getDocumentID();
+ var documentThread = this.call('mail_service', 'getOrAddDocumentThread', {
+ resModel: resModel,
+ resID: resID,
+ name: this.getDocumentName(),
+ });
+ this._threadIDs.push(documentThread.getID());
+ },
+ /**
+ * Set the appropriate mailboxes to this message based on server data
+ *
+ * @private
+ */
+ _processMailboxes: function () {
+ if (_.contains(this._needactionPartnerIDs, session.partner_id)) {
+ this._setNeedaction(true);
+ }
+ if (_.contains(this._starredPartnerIDs, session.partner_id)) {
+ this.setStarred(true);
+ }
+ if (
+ this.originatesFromChannel() &&
+ _.contains(
+ this.call('mail_service', 'getModeratedChannelIDs'),
+ this.getOriginChannelID()
+ ) &&
+ this.needsModeration()
+ ) {
+ this._setModeratedByUser(true);
+ }
+ },
+ /**
+ * Do some extra processing at message init, related to the
+ * moderated status of the message.
+ *
+ * If the message needs moderation, it is not yet linked to the
+ * moderated channel server-side. Therefore, it is not registered
+ * in the list of threadIDs, as it is built on server-side information
+ * at message initialisation (using data.channel_ids).
+ *
+ * Since the web client uses the list of thread IDs to show visually the
+ * message in a thread, we should hack the response of the server so that
+ * it assumes the message really belongs to this thread.
+ *
+ * @private
+ */
+ _processModeration: function () {
+ if (this.needsModeration()) {
+ // the message is not linked to the moderated channel on the
+ // server, therefore this message has not this channel in
+ // channel_ids. Here, just to show this message in the channel
+ // visually, it links this message to the channel
+ this._threadIDs.push(this.getOriginChannelID());
+ }
+ },
+ /**
+ * Process the tracking values on message creation, which
+ * basically format date to the local only once by message
+ *
+ * Cannot be done in preprocess, since it alter the original value
+ *
+ * @private
+ */
+ _processTrackingValues: function () {
+ if (this.hasTrackingValues()) {
+ _.each(this.getTrackingValues(), function (trackingValue) {
+ if (trackingValue.field_type === 'datetime') {
+ if (trackingValue.old_value) {
+ trackingValue.old_value =
+ moment
+ .utc(trackingValue.old_value)
+ .local()
+ .format('LLL');
+ }
+ if (trackingValue.new_value) {
+ trackingValue.new_value =
+ moment
+ .utc(trackingValue.new_value)
+ .local()
+ .format('LLL');
+ }
+ } else if (trackingValue.field_type === 'date') {
+ if (trackingValue.old_value) {
+ trackingValue.old_value =
+ moment(trackingValue.old_value)
+ .local()
+ .format('LL');
+ }
+ if (trackingValue.new_value) {
+ trackingValue.new_value =
+ moment(trackingValue.new_value)
+ .local()
+ .format('LL');
+ }
+ }
+ });
+ }
+ },
+ /**
+ * Set whether the message is moderated by current user or not.
+ * If it is moderated by the current user, the message is moved to the
+ * "Moderation" mailbox. Note that this function only applies it locally,
+ * the server is not aware
+ *
+ * @private
+ * @param {boolean} moderated if set, the message is moderated by user
+ */
+ _setModeratedByUser: function (moderated) {
+ if (moderated) {
+ this._addThread('mailbox_moderation');
+ } else {
+ this.removeThread('mailbox_moderation');
+ }
+ },
+ /**
+ * Set whether the message is needaction or not.
+ * If it is needaction, the message is moved to the "Inbox" mailbox.
+ * Note that this function only applies it locally, the server is not aware
+ *
+ * @private
+ * @param {boolean} needaction if set, the message is needaction
+ */
+ _setNeedaction: function (needaction) {
+ if (needaction) {
+ this._addThread('mailbox_inbox');
+ } else {
+ this.removeThread('mailbox_inbox');
+ }
+ },
+ /**
+ * @private
+ */
+ _warnMessageModerated: function () {
+ var mailBus = this.call('mail_service', 'getMailBus');
+ if (this.needsModerationByUser()) {
+ var moderationBox = this.call('mail_service', 'getMailbox', 'moderation');
+ moderationBox.decrementMailboxCounter();
+ moderationBox.removeMessage(this.getID());
+ mailBus.trigger('update_moderation_counter');
+ }
+ if (this._moderationStatus !== 'accepted') {
+ this.call('mail_service', 'removeMessageFromThreads', this);
+ }
+ mailBus.trigger('update_message', this);
+ },
+
+});
+
+return Message;
+
+});
diff --git a/addons/mail/static/src/js/models/thread.js b/addons/mail/static/src/js/models/thread.js
new file mode 100644
index 00000000000..de2c6628d67
--- /dev/null
+++ b/addons/mail/static/src/js/models/thread.js
@@ -0,0 +1,446 @@
+odoo.define('mail.model.Thread', function (require) {
+"use strict";
+
+var emojis = require('mail.emojis');
+var mailUtils = require('mail.utils');
+
+var Class = require('web.Class');
+var Mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+
+/**
+ * This is the super class modeling threads in JS.
+ * Such threads are stored in the mail manager, and any piece of JS code whose
+ * logic relies on threads must ideally interact with such objects.
+ *
+ * In particular, channels and mailboxes are two different kinds of threads.
+ */
+var Thread = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, {
+ /**
+ * @param {mail.Manager} parent
+ * @param {Object} data
+ * @param {string} [data.channel_type]
+ * @param {integer|string} data.id
+ * @param {string} data.name
+ * @param {string} [data.type]
+ */
+ init: function (parent, data) {
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.setParent(parent);
+
+ this._id = data.id;
+
+ // threads are not detached by default
+ this._detached = false;
+ // threads are unfolded by default
+ this._folded = false;
+ // if this._massMailing is set, display subject on messages, use
+ // extended composer and show "Send by messages by email" on discuss
+ // sidebar
+ this._massMailing = false;
+ this._name = data.name;
+ // on 1st request to getPreview, fetch data if incomplete. Otherwise it
+ // means that there is no message in this channel.
+ this._previewed = false;
+ this._type = data.type || data.channel_type;
+ // any new message that has not been read yet
+ this._unreadCounter = 0;
+ // max number of fetched messages from the server
+ this._FETCH_LIMIT = 30;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a message to this thread.
+ *
+ * @abstract
+ * @param {mail.model.Message} message
+ */
+ addMessage: function (message) {},
+ /**
+ * Updates the _detached state of the thread. Must be overriden to reflect
+ * the new state in the interface.
+ */
+ close: function () {
+ this._detached = false;
+ this._folded = false;
+ this._warnUpdatedWindowState();
+ },
+ /**
+ * Updates the _detached state of the thread. Must be overriden to reflect
+ * the new state in the interface.
+ *
+ * @param {Object} [options={}]
+ * @param {boolean} [options.keepFoldState=false] if set, keep the fold state
+ * of this thread. Otherwise unfold it while detaching it.
+ * @param {boolean} [options.passively=false] if set, if the thread window
+ * will be created passively.
+ */
+ detach: function (options) {
+ options = options || {};
+ this._detached = true;
+ this._folded = options.keepFoldState ? this._folded : false;
+ this._warnUpdatedWindowState({
+ passively: options.passively,
+ });
+ },
+ /**
+ * Updates the _folded state of the thread. Must be overriden to reflect
+ * the new state in the interface.
+ */
+ fold: function (folded) {
+ this._detached = true;
+ this._folded = folded;
+ this._warnUpdatedWindowState();
+ },
+ /**
+ * Get the list of available commands for the thread.
+ * By default, threads do not have any available command.
+ *
+ * @returns {Array}
+ */
+ getCommands: function () {
+ return [];
+ },
+ /**
+ * Get the ID of the thread
+ *
+ * @returns {integer|string} string for mailboxes (e.g. 'mailbox_inbox')
+ */
+ getID: function () {
+ return this._id;
+ },
+ /**
+ * Get the listeners of the thread.
+ * By default, a thread has not listener.
+ *
+ * @abstract
+ * @returns {$.Promise<Object[]>}
+ */
+ getMentionPartnerSuggestions: function () {
+ return $.when([]);
+ },
+ /**
+ * Get the list of messages in this thread.
+ * By default, a thread has no messages.
+ *
+ * Note that this method only returns some messages, as we do not want to
+ * fetch all messages of a thread at once, just to read the last message.
+ *
+ * As a result, this method fetches only some messages of the thread,
+ * starting from the last message in the thread. At most, it fetches
+ * `this.LIMIT` number of messages at a time.
+ *
+ * @abstract
+ * @returns {$.Promise<mail.model.Message[]>}
+ */
+ getMessages: function () {
+ return $.when([]);
+ },
+ /**
+ * Get the name of this thread
+ *
+ * @returns {string|Object} object for lazy translated names with _lt
+ */
+ getName: function () {
+ return this._name;
+ },
+ /**
+ * Returns the information required to render the preview of this channel.
+ *
+ * @returns {Object} a valid object for the rendering of previews
+ * (@see mail.Preview template)
+ */
+ getPreview: function () {
+ return {
+ id: this.getID(),
+ imageSRC: '/mail/static/src/img/smiley/avatar.jpg',
+ isChat: this.isChat(),
+ status: this.getStatus(),
+ title: this.getName(),
+ unreadCounter: this.getUnreadCounter(),
+ };
+ },
+ /**
+ * Get the status of the thread (e.g. 'online', 'offline', etc.)
+ *
+ * @returns {string}
+ */
+ getStatus: function () {
+ return this._status;
+ },
+ /**
+ * Returns the title to display in thread window's headers.
+ *
+ * @returns {string|Object} the name of the thread by default (see @getName)
+ */
+ getTitle: function () {
+ return this.getName();
+ },
+ /**
+ * @returns {string}
+ */
+ getType: function () {
+ return this._type;
+ },
+ /**
+ * @returns {integer}
+ */
+ getUnreadCounter: function () {
+ return this._unreadCounter;
+ },
+ /**
+ * State whether this channel has been previewed
+ *
+ * A channel that has been previewed means that it had the necessary data
+ * to display its preview format. A channel needs its meta data and the
+ * last message in order to build its preview format.
+ *
+ * This is useful in order to not fetch preview info on this channel more
+ * than once on channels that have no message at all.
+ *
+ * Any received message updates the last_message, so a channel should
+ * always have all the necessary information to display its preview after
+ * the 1st time.
+ *
+ * @returns {boolean}
+ */
+ hasBeenPreviewed: function () {
+ return this._previewed;
+ },
+ /**
+ * State whether there are unread messages in this thread
+ *
+ * @returns {boolean}
+ */
+ hasUnreadMessages: function () {
+ return this._unreadCounter !== 0;
+ },
+ /**
+ * Increments the needaction counter of this thread
+ * FIXME: this method makes only sense for channels. Not sure, but I think
+ * this method is necessary at this level because of the model
+ * im_support.SupportChannel
+ *
+ * @abstract
+ */
+ incrementNeedactionCounter: function () {},
+ /**
+ * Increment the unread counter of this thread by 1 unit
+ */
+ incrementUnreadCounter: function () {
+ this._unreadCounter++;
+ this._warnNewUnreadCounter();
+ },
+ /**
+ * States whether the thread should be auto-selected on creation
+ *
+ * Note that this is not of the responsibility of the thread: it only
+ * provides guidance for the object that uses threads (e.g. mail.Discuss
+ * must listen on threads and auto-select the thread if autoswitch is set).
+ *
+ * By default, threads are not in autoswitch mode.
+ *
+ * @returns {boolean}
+ */
+ isAutoswitch: function () {
+ return false;
+ },
+ /**
+ * States whether this thread is a channel or not. A thread is a channel if
+ * it is an instance of mail.model.Channel (direct or indirect).
+ * By default, any thread is not a channel
+ *
+ * @returns {boolean}
+ */
+ isChannel: function () {
+ return false;
+ },
+ /**
+ * States whether this thread is a chat or not. In particular, public and
+ * private channels are not chat, but DMs and Livechats are chats. Chats
+ * are threads used for communication between two users.
+ * By default, any thread is not a chat
+ *
+ * @returns {boolean}
+ */
+ isChat: function () {
+ return false;
+ },
+ /**
+ * States whether this thread is detached or not.
+ * A thread that is detached must have a thread window linked to itself.
+ *
+ * @return {boolean}
+ */
+ isDetached: function () {
+ return this._detached;
+ },
+ /**
+ * States whether this thread is folded or not.
+ *
+ * @return {boolean}
+ */
+ isFolded: function () {
+ return this._folded;
+ },
+ /**
+ * States whether the thread is linked to a document
+ * By default, threads are not linked to a document.
+ *
+ * @returns {boolean}
+ */
+ isLinkedToDocument: function () {
+ return false;
+ },
+ /**
+ * States whether this thread has the mass mailing setting active or not.
+ * This is a server-side setting, that determine the type of composer that
+ * is used (basic or extended composer).
+ *
+ * @return {boolean}
+ */
+ isMassMailing: function () {
+ return this._massMailing;
+ },
+ /**
+
+ * States whether the thread is moderated or not.
+ * By default, threads are not moderated.
+ *
+ * @returns {boolean}
+ */
+ isModerated: function () {
+ return false;
+ },
+ /**
+ * States whether the current user is moderator of this thread.
+ * By default, the current user is not moderator of this thread.
+ *
+ * @returns {boolean}
+ */
+ isModerator: function () {
+ return false;
+ },
+ /**
+ * Mark this channel as previewed
+ *
+ * This is useful in order to not fetch preview info on this channel
+ * is the server has no preview in the first place.
+ *
+ * Note: preview fetch is useful only when the channel contains messages
+ * that have not been fetched at all. After that, this channel instance
+ * is updated regularly so that the most up-to-date info are available
+ * to make the preview of this channel.
+ */
+ markAsPreviewed: function () {
+ this._previewed = true;
+ },
+ /**
+ * Mark the thread as read, which resets the unread counter to 0.
+ *
+ * @returns {$.Promise} resolved
+ */
+ markAsRead: function () {
+ if (this._unreadCounter > 0) {
+ this._unreadCounter = 0;
+ this._warnNewUnreadCounter();
+ }
+ return $.when();
+ },
+ /**
+ * Post a message on the thread.
+ *
+ * This method must be completed by concrete threads,
+ * As it currently only pre-process the messages at the moment.
+ *
+ * @abstract
+ * @param {Object} data
+ * @returns {$.Promise<Object>} resolved with the message object to be sent
+ * to the server
+ */
+ postMessage: function (data) {
+ // This message will be received from the mail composer as html content
+ // subtype but the urls will not be linkified. If the mail composer
+ // takes the responsibility to linkify the urls we end up with double
+ // linkification a bit everywhere. Ideally we want to keep the content
+ // as text internally and only make html enrichment at display time but
+ // the current design makes this quite hard to do.
+ var body = mailUtils.parseAndTransform(_.str.trim(data.content), mailUtils.addLink);
+ body = this._generateEmojis(body);
+ var messageData = {
+ partner_ids: data.partner_ids,
+ body: body,
+ attachment_ids: data.attachment_ids,
+ };
+ if ('subject' in data) {
+ messageData.subject = data.subject;
+ }
+ return $.when(messageData);
+ },
+ /**
+ * @private
+ */
+ resetUnreadCounter: function () {
+ this._unreadCounter = 0;
+ this._warnNewUnreadCounter();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @abstract
+ * @private
+ * @return {$.Promise}
+ */
+ _fetchMessages: function () {
+ return $.when();
+ },
+ /**
+ * Replace character representations of emojis by their unicode
+ * representation in the provided HTML string. Note that the provided html
+ * string is altered by this function.
+ *
+ * @param {string} htmlString
+ * @returns {string}
+ */
+ _generateEmojis: function (htmlString) {
+ _.each(emojis, function (emoji) {
+ _.each(emoji.sources, function (source) {
+ var escapedSource = String(_.escape(source)).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1');
+ var regexp = new RegExp("(\\s|^)(" + escapedSource + ")(?=\\s|$)", 'g');
+ htmlString = htmlString.replace(regexp, '$1' + emoji.unicode);
+ });
+ });
+ return htmlString;
+ },
+ /**
+ * Warn other mail components that the window state of this thread has
+ * changed (it has become closed, folded, unfolded, detached, etc.)
+ *
+ * @private
+ * @param {Object} [options={}]
+ */
+ _warnUpdatedWindowState: function (options) {
+ options = options || {};
+ this.call('mail_service', 'updateThreadWindow', this.getID(), options);
+ },
+ /**
+ * Warn on the chat bus that the unread counter has been updated
+ *
+ * @private
+ */
+ _warnNewUnreadCounter: function () {
+ this.call('mail_service', 'getMailBus')
+ .trigger('update_thread_unread_counter', this);
+ },
+});
+
+return Thread;
+
+});
diff --git a/addons/mail/static/src/js/models/thread_with_cache.js b/addons/mail/static/src/js/models/thread_with_cache.js
new file mode 100644
index 00000000000..f25a19005f2
--- /dev/null
+++ b/addons/mail/static/src/js/models/thread_with_cache.js
@@ -0,0 +1,204 @@
+odoo.define('mail.model.ThreadWithCache', function (require) {
+"use strict";
+
+var Thread = require('mail.model.Thread');
+
+var session = require('web.session');
+
+/**
+ * This is a version of a thread that is handling a cache.
+ * Basically, this cache is an object indexed by stringified indexes.
+ * These indexes represent the domain of messages that match this domain.
+ *
+ * Any threads that are instances of this Class can be used with a search view,
+ * in order to make searches on messages in a thread.
+ */
+var ThreadWithCache = Thread.extend({
+ /**
+ * @override
+ * @param {integer} [data.seen_message_id=undefined]
+ */
+ init: function (parent, data) {
+ this._super.apply(this, arguments);
+
+ this._cache = {
+ '[]': {
+ allHistoryLoaded: false,
+ loaded: false,
+ messages: [],
+ }
+ };
+ this._lastSeenMessageID = data.seen_message_id;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add the message to this thread
+ *
+ * @override
+ * @private
+ * @param {mail.model.Message} message
+ * @param {Array} domain
+ */
+ addMessage: function (message, domain) {
+ var cache = this._getCache(domain);
+ var index = _.sortedIndex(cache.messages, message, function (msg) {
+ return msg.getID();
+ });
+ if (cache.messages[index] !== message) {
+ cache.messages.splice(index, 0, message);
+ }
+ },
+ /**
+ * Get the last seen message for a given thread
+ *
+ * @return {integer|null} last seen message ID (if any)
+ */
+ getLastSeenMessageID: function () {
+ return this._lastSeenMessageID || null;
+ },
+ /**
+ * @override
+ * @param {Array} [domain]
+ * @param {boolean} [loadMore=false]
+ */
+ getMessages: function (domain, loadMore) {
+ if (loadMore) {
+ return this._fetchMessages(domain, true);
+ }
+ var cache = this._getCache(domain);
+ if (cache.loaded) {
+ return $.when(cache.messages);
+ } else {
+ return this._fetchMessages(domain);
+ }
+ },
+ /**
+ * @private
+ * @return {boolean} true if there is at least a message, false otherwise
+ */
+ hasMessages: function () {
+ return !!(_.last(this._cache['[]'].messages));
+ },
+ /**
+ * Invalidate the caches of the thread
+ */
+ invalidateCaches: function () {
+ this._cache = _.pick(this._cache, '[]');
+ },
+ /**
+ * State whether all messages have been loaded or not
+ *
+ * @param {Array} domain
+ * @return {boolean}
+ */
+ isAllHistoryLoaded: function (domain) {
+ return this._getCache(domain).allHistoryLoaded;
+ },
+ /**
+ * Remove message with ID `messageID` from this thread
+ *
+ * This is equivalent to removing this message from all the caches
+ *
+ * @param {integer} messageID
+ */
+ removeMessage: function (messageID) {
+ _.each(this._cache, function (cache) {
+ cache.messages = _.reject(cache.messages, function (message) {
+ return message.getID() === messageID;
+ });
+ });
+ },
+ /**
+ * Set 'messageID' as the ID of the last message seen in this thread
+ *
+ * @param {integer} messageID
+ */
+ setLastSeenMessageID: function (messageID) {
+ this._lastSeenMessageID = messageID;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Gets messages from thread
+ *
+ * @override
+ * @private
+ * @param {Array} [pDomain] filter on the messages of the channel
+ * @param {boolean} [loadMore] Whether it should load more message
+ * @return {$.Promise<mail.model.Message[]>} resolved with list of messages
+ */
+ _fetchMessages: function (pDomain, loadMore) {
+ var self = this;
+
+ var domain = this._getThreadDomain();
+
+ var cache = this._getCache(pDomain);
+ if (pDomain) {
+ domain = domain.concat(pDomain || []);
+ }
+ if (loadMore) {
+ var minMessageID = cache.messages[0].getID();
+ domain = [['id', '<', minMessageID]].concat(domain);
+ }
+ return this._rpc({
+ model: 'mail.message',
+ method: 'message_fetch',
+ args: [domain],
+ kwargs: {
+ limit: this._FETCH_LIMIT,
+ context: session.user_context
+ },
+ }).then(function (messages) {
+ if (!cache.allHistoryLoaded) {
+ cache.allHistoryLoaded = messages.length < self._FETCH_LIMIT;
+ }
+ cache.loaded = true;
+ _.each(messages, function (message) {
+ self.call('mail_service', 'addMessage', message, {
+ silent: true,
+ domain: pDomain,
+ });
+ });
+ cache = self._getCache(pDomain || []);
+ return cache.messages;
+ });
+ },
+ /**
+ * Get thread content from the cache
+ * Useful to get cached messages.
+ *
+ * @private
+ * @param {Array} domain
+ * @return {Object|undefined}
+ */
+ _getCache: function (domain) {
+ var stringifiedDomain = JSON.stringify(domain || []);
+ if (!this._cache[stringifiedDomain]) {
+ this._cache[stringifiedDomain] = {
+ allHistoryLoaded: false,
+ loaded: false,
+ messages: [],
+ };
+ }
+ return this._cache[stringifiedDomain];
+ },
+ /**
+ * Get the domain to fetch all the messages in the current thread
+ *
+ * @abstract
+ * @private
+ * @returns {Array}
+ */
+ _getThreadDomain: function () {},
+});
+
+return ThreadWithCache;
+
+});
diff --git a/addons/mail/static/src/js/services/chat_manager.js b/addons/mail/static/src/js/services/chat_manager.js
deleted file mode 100644
index 8a90fc57482..00000000000
--- a/addons/mail/static/src/js/services/chat_manager.js
+++ /dev/null
@@ -1,2016 +0,0 @@
-odoo.define('mail.ChatManager', function (require) {
-"use strict";
-
-var utils = require('mail.utils');
-
-var AbstractService = require('web.AbstractService');
-var Bus = require('web.Bus');
-var config = require('web.config');
-var core = require('web.core');
-var session = require('web.session');
-var time = require('web.time');
-var web_client = require('web.web_client');
-
-var _t = core._t;
-var _lt = core._lt;
-
-var LIMIT = 30; // max number of fetched messages from the server
-var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers
-var ODOOBOT_ID = "ODOOBOT"; // default author_id for messages
-
-var emojiUnicodes={
- ":)":"😊", ":-)":"😊","=)":"😊", ":]":"😊",
- ":D":"😃",":-D":"😃","=D":"😃",
- "xD":"😆","XD":"😆",
- "x'D":"😂",
- ";)":"😉",";-)":"😉",
- "B)":"😎","8)":"😎","B-)":"😎","8-)":"😎",
- ";p":"😜",";P":"😜",
- ":p":"😋",":P":"😋",":-p":"😋",":-P":"😋","=P":"😋",
- "xp":"😝","xP":"😝",
- "o_o":"😳",
- ":|":"😐",":-|":"😐",
- ":/":"😕",":-/":"😕",
- ":(":"😞",
- ":@":"😱",
- ":O":"😲",":-O":"😲",":o":"😲",":-o":"😲",
- ":'o":"😨",
- "3:(":"😠",">:(":"😠","3:":"😠",
- "3:)":"😈",">:)":"😈",
- ":*":"😘",":-*":"😘",
- "o:)":"😇",
- ":'(":"😢",
- ":'-(":"😭",":\"(":"😭",
- "&lt;3":"❤️",":heart":"❤️",
- ":heart_eyes":"😍",
- ":turban":"👳",
- ":+1":"👍",
- ":-1":"👎",
- ":ok":"👌",
- ":poop":"💩",
- ":no_see":"🙈",
- ":no_hear":"🙉",
- ":no_speak":"🙊",
- ":bug":"🐞",
- ":kitten":"😺",
- ":bear":"🐻",
- ":snail":"🐌",
- ":boar":"🐗",
- ":clover":"🍀",
- ":sunflower":"🌹",
- ":fire":"🔥",
- ":sun":"☀️",
- ":partly_sunny:":"⛅️",
- ":rainbow":"🌈",
- ":cloud":"☁️",
- ":zap":"⚡️",
- ":star":"⭐️",
- ":cookie":"🍪",
- ":pizza":"🍕",
- ":hamburger":"🍔",
- ":fries":"🍟",
- ":cake":"🎂",
- ":cake_part":"🍰",
- ":coffee":"☕️",
- ":banana":"🍌",
- ":sushi":"🍣",
- ":rice_ball":"🍙",
- ":beer":"🍺",
- ":wine":"🍷",
- ":cocktail":"🍸",
- ":tropical":"🍹",
- ":beers":"🍻",
- ":ghost":"👻",
- ":skull":"💀",
- ":et":"👽",":alien":"👽",
- ":party":"🎉",
- ":trophy":"🏆",
- ":key":"🔑",
- ":pin":"📌",
- ":postal_horn":"📯",
- ":music":"🎵",
- ":trumpet":"🎺",
- ":guitar":"🎸",
- ":soccer":"⚽️",
- ":football":"🏈",
- ":8ball":"🎱",
- ":clapper":"🎬",
- ":microphone":"🎤"
- };
-
-/**
- * This service handles everything about chat channels and messages.
- *
- * There are basically two points of entry:
- *
- * 1. Calling a public method by means of 'this.call'
- * 2. Receiving events on busBus (e.g. 'notification')
- */
-var ChatManager = AbstractService.extend({
- name: 'chat_manager',
- dependencies: ['ajax', 'bus_service'],
- CHANNEL_SEEN_THROTTLE: 3000,
- /**
- * @override
- */
- init: function () {
- var self = this;
- this._super.apply(this, arguments);
-
- this.messages = [];
- this.channels = [];
- this.messageFailures = [];
- this.channelsPreviewDef;
- this.channelDefs = {};
- this.unreadConversationCounter = 0;
- this.emojis = [];
- this.needactionCounter = 0;
- this.starredCounter = 0;
- this.moderationCounter = 0;
- this.moderatedChannelIDs = [];
- this.mentionPartnerSuggestions = [];
- this.cannedResponses = [];
- this.commands = [];
- this.discussMenuID;
- this.globalUnreadCounter = 0;
- this.pinnedDmPartners = []; // partner_ids we have a pinned DM with
- this.discussOpen = false;
-
- this.chatBus = new Bus(this);
- this.busBus = this.call('bus_service', 'getBus');
-
- this.chatBus.on('discuss_open', null, function (open) {
- self.discussOpen = open;
- });
-
- this.busBus.on('notification', this, this._onNotification);
- this.busBus.on('window_focus', this, this._onWindowFocus);
-
- // TODO create private fonction on prototype
- this.channelSeen = _.throttle(function (channel) {
- return self._rpc({
- model: 'mail.channel',
- method: 'channel_seen',
- args: [[channel.id]],
- }, {
- shadow: true
- });
- }, self.CHANNEL_SEEN_THROTTLE);
-
- this._isReady = this._initMessaging();
-
- // Add static channels
- this._addChannel({
- id: "channel_inbox",
- name: _lt("Inbox"),
- type: "static",
- }, { displayNeedactions: true });
- this._addChannel({
- id: "channel_starred",
- name: _lt("Starred"),
- type: "static"
- });
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Closes the chat window of a corresponding channel.
- * This operation is executed server-side and the chat window will be
- * folded in all potential tabs from all browsers.
- *
- * @param {integer|string} channelID
- * @return {$.Promise}
- */
- closeChatSession: function (channelID) {
- var channel = this.getChannel(channelID);
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_fold',
- kwargs: {uuid : channel.uuid, state : 'closed'},
- }, {shadow: true});
- },
- /**
- * Creates a channel
- *
- * @param {integer|string} name id of partner (in case of dm) or name
- * @param {string} type ['dm', 'public', 'private']
- * @return {$.Promise}
- */
- createChannel: function (name, type) {
- var method = type === "dm" ? "channel_get" : "channel_create";
- var args = type === "dm" ? [[name]] : [name, type];
- var context = _.extend({isMobile: config.device.isMobile}, session.user_context);
- return this._rpc({
- model: 'mail.channel',
- method: method,
- args: args,
- kwargs: {context: context},
- })
- .then(this._addChannel.bind(this));
- },
- /**
- * Open the chat window for a given channel
- * (in all potential tabs from all browsers)
- *
- * @param {integer} channelID
- * @return {$.Promise}
- */
- detachChannel: function (channelID) {
- var channel = this.getChannel(channelID);
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_minimize',
- args: [channel.uuid, true],
- }, {
- shadow: true,
- });
- },
- /**
- * Folds/Minimize the chat window
- * (in all potential tabs from all browsers)
- *
- * @param {integer} channelID
- * @param {boolean} folded
- * @return {$.Promise}
- */
- foldChannel: function (channelID, folded) {
- var args = {
- uuid: this.getChannel(channelID).uuid,
- };
- if (_.isBoolean(folded)) {
- args.state = folded ? 'folded' : 'open';
- }
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_fold',
- kwargs: args,
- }, {shadow: true});
- },
- /**
- * Returns the list of canned responses
- * A canned response is a pre-formatted text that is triggered with
- * some keystrokes, such as with ':'.
- *
- * @return {Array} array of Objects (mail.shortcode)
- */
- getCannedResponses: function () {
- return this.cannedResponses;
- },
- /**
- * Returns a channel corresponding to the given id.
- *
- * @param {string|integer} id e.g. 'channel_inbox', 'channel_starred'
- * @return {Object|undefined} the channel, if it exists
- */
- getChannel: function (id) {
- return _.findWhere(this.channels, {id: id});
- },
- /**
- * Returns a list of channels
- *
- * @return {Object[]} list of channels
- */
- getChannels: function () {
- return _.clone(this.channels);
- },
- /**
- * Returns the content that will be shown in the mail navbar dropdown
- *
- * @param {Object[]} channels
- * @return {$.Promise<Array>} resolved with array of preview msgs
- */
- getChannelsPreview: function (channels) {
- var self = this;
- var channelsPreview = _.map(channels, function (channel) {
- var info;
- if ((channel.channel_ids && _.contains(channel.channel_ids, "channel_inbox")) || channel.id === "mail_failure") {
- // this is a hack to preview messages and mail failure
- // map inbox(mail_message) data with existing channel/chat template
- info = _.pick(channel,
- 'id', 'body', 'avatar_src', 'res_id', 'model', 'module_icon',
- 'subject','date', 'record_name', 'status', 'displayed_author',
- 'email_from', 'unread_counter');
- info.last_message = {
- body: info.body,
- date: info.date,
- displayed_author: info.displayed_author || info.email_from,
- };
- info.name = info.record_name || info.subject || info.displayed_author;
- info.image_src = info.module_icon || info.avatar_src;
- info.message_id = info.id;
- if (channel.id !== "mail_failure") {
- info.id = 'channel_inbox';
- }
- return info;
- }
- info = _.pick(channel, 'id', 'is_chat', 'name', 'status', 'unread_counter');
- info.last_message = channel.last_message || _.last(channel.cache['[]'].messages);
- if (!info.is_chat) {
- info.image_src = '/web/image/mail.channel/'+channel.id+'/image_small';
- } else if (channel.direct_partner_id) {
- info.image_src = '/web/image/res.partner/'+channel.direct_partner_id+'/image_small';
- } else {
- info.image_src = '/mail/static/src/img/smiley/avatar.jpg';
- }
- return info;
- });
- var missingChannels = _.where(channelsPreview, {last_message: undefined});
- if (!this.channelsPreviewDef) {
- if (missingChannels.length) {
- var missingChannelIDs = _.pluck(missingChannels, 'id');
- this.channelsPreviewDef = this._rpc({
- model: 'mail.channel',
- method: 'channel_fetch_preview',
- args: [missingChannelIDs],
- }, {
- shadow: true,
- });
- } else {
- this.channelsPreviewDef = $.when();
- }
- }
- return this.channelsPreviewDef.then(function (channels) {
- _.each(missingChannels, function (channelPreview) {
- var channel = _.findWhere(channels, {id: channelPreview.id});
- if (channel) {
- channelPreview.last_message = self._addMessage(channel.last_message);
- }
- });
- // sort channels: 1. unread, 2. chat, 3. date of last msg
- channelsPreview.sort(function (c1, c2) {
- return Math.min(1, c2.unread_counter) - Math.min(1, c1.unread_counter) ||
- c2.is_chat - c1.is_chat ||
- !!c2.last_message - !!c1.last_message ||
- (c2.last_message && c2.last_message.date.diff(c1.last_message.date));
- });
-
- // generate last message preview (inline message body and compute date to display)
- _.each(channelsPreview, function (channel) {
- if (channel.last_message) {
- channel.last_message_preview = utils.parse_and_transform(channel.last_message.body, utils.inline);
- channel.last_message_date = channel.last_message.date.fromNow();
- }
- });
- return channelsPreview;
- });
- },
- /**
- * @return {web.Bus} the chat bus
- */
- getChatBus: function () {
- return this.chatBus;
- },
- /**
- * Show the list of available commands next to a message (e.g. star)
- *
- * @param {Object} channel
- * @return {Array} list of commands
- */
- getCommands: function (channel) {
- var commands = _.filter(this.commands, function (command) {
- return !command.channel_types || _.contains(command.channel_types, channel.server_type);
- });
- return commands;
- },
- /**
- * Returns the record id of ir.ui.menu for Discuss
- *
- * @return {integer} record id
- */
- getDiscussMenuID: function () {
- return this.discussMenuID;
- },
- /**
- * Gets direct message channel
- *
- * @param {integer} partnerID
- * @return {Object|undefined} channel
- */
- getDmFromPartnerID: function (partnerID) {
- return _.findWhere(this.channels, {direct_partner_id: partnerID});
- },
- /**
- * Returns list of emojis Objects
- *
- * @return {Object[]} list of emojis
- * ['id', 'source', 'unicode_source', 'substitution', 'description']
- */
- getEmojis: function () {
- return this.emojis;
- },
- /**
- * Get the last seen message for a given channel
- *
- * @param {Object} channel
- * @return {Object|undefined} last seen Message Object (if any)
- */
- getLastSeenMessage: function (channel) {
- var result;
- if (channel.last_seen_message_id) {
- var messages = channel.cache['[]'].messages;
- var msg = _.findWhere(messages, {id: channel.last_seen_message_id});
- if (msg) {
- var i = _.sortedIndex(messages, msg, 'id') + 1;
- while (i < messages.length &&
- (messages[i].is_author || messages[i].is_system_notification)) {
- msg = messages[i];
- i++;
- }
- result = msg;
- }
- }
- return result;
- },
- /**
- * Returns a list of mail failures
- *
- * @return {Object[]} list of channels
- */
- getMessageFailures: function () {
- return this.messageFailures;
- },
- /**
- * Get all listeners of a channel.
- *
- * @param {Object} channel
- * @return {$.Promise<Array<Object[]>>|Array<Object[]>} Two cases:
- *
- * 1. 'Channel' provided
- * => Promise resolved with a list containing a list of members
- * (list is cached in channel.membersDeferred)
- * 2. No 'channel' provided
- * => list containing a list of members (cached by chat_manager)
- */
- getMentionPartnerSuggestions: function (channel) {
- if (!channel) {
- return this.mentionPartnerSuggestions;
- }
- if (!channel.membersDeferred) {
- channel.membersDeferred = this._rpc({
- model: 'mail.channel',
- method: 'channel_fetch_listeners',
- args: [channel.uuid],
- }, {
- shadow: true
- })
- .then(function (members) {
- return [members];
- });
- }
- return channel.membersDeferred;
- },
- /**
- * Gets message from its id
- *
- * @param {integer} msgID
- * @return {Object|undefined} Message Object (if any)
- */
- getMessage: function (msgID) {
- return _.findWhere(this.messages, {id: msgID});
- },
- /**
- * Gets messages from channel or ids or record (model and res_id):
- *
- * 1. From channel if we have 'channelID' in options
- * 2. From ids if we have 'ids' in options
- * 3. From model if we have 'model' and 'res_id' in options
- *
- * Rule of precedence:
- *
- * 'channelID' < 'ids' < 'model' and 'res_id'
- *
- * If we have none of the cases above, we return an empty list.
- *
- * @param {Object} [options]
- * @param {integer|string} [options.channelID]
- * @param {Array} [options.domain]
- * @param {integer[]} [options.ids]
- * @param {boolean} [options.loadMore]
- * @param {string} [options.model]
- * @param {integer} [options.res_id]
- * @return {$.Promise<Object[]>} list of messages
- */
- getMessages: function (options) {
- var channel;
- var self = this;
-
- if ('channelID' in options && options.loadMore) {
- // get channel messages, force load more
- channel = this.getChannel(options.channelID);
- return this._fetchFromChannel(channel, {domain: options.domain || {}, loadMore: true});
- }
- if ('channelID' in options) {
- // channel message, check in cache first
- channel = this.getChannel(options.channelID);
- var channelCache = this._getChannelCache(channel, options.domain);
- if (channelCache.loaded) {
- return $.when(channelCache.messages);
- } else {
- return this._fetchFromChannel(channel, {domain: options.domain});
- }
- }
- if ('ids' in options) {
- // get messages from their ids (chatter is the main use case)
- return this._fetchDocumentMessages(options.ids, options).then(function (result) {
- self.markAsRead(options.ids);
- return result;
- });
- }
- if ('model' in options && 'res_id' in options) {
- // get messages for a chatter, when it doesn't know the ids (use
- // case is when using the full composer)
- var domain = [['model', '=', options.model], ['res_id', '=', options.res_id]];
- return this._rpc({
- model: 'mail.message',
- method: 'message_fetch',
- args: [domain],
- kwargs: {limit: LIMIT},
- })
- .then(function (msgs) {
- return _.map(msgs, self._addMessage.bind(self));
- });
- }
- return $.when([]);
- },
- /**
- * Returns the number of messages that the user needs to moderate
- *
- * @return {integer}
- */
- getModerationCounter: function () {
- return this.moderationCounter;
- },
- /**
- * Returns the number of messages received from followed channels
- * + all messages where the current user is notified.
- *
- * @return {integer} needaction counter
- */
- getNeedactionCounter: function () {
- return this.needactionCounter;
- },
- /**
- * Gets the number of starred message
- *
- * @return {integer} starred counter
- */
- getStarredCounter: function () {
- return this.starredCounter;
- },
- /**
- * Gets the number of conversation which contains unread messages
- *
- * @return {integer} unread conversation counter
- */
- getUnreadConversationCounter: function () {
- return this.unreadConversationCounter;
- },
- /**
- * States whether all messages have been loaded or not
- *
- * @param {Object} channel
- * @param {Array} domain
- * @return {boolean}
- */
- isAllHistoryLoaded: function (channel, domain) {
- return this._getChannelCache(channel, domain).all_history_loaded;
- },
- /**
- * @return {boolean}
- */
- isModerator: function () {
- return this._isModerator;
- },
- /**
- * @return {$.Promise}
- */
- isReady: function () {
- return this._isReady;
- },
- /**
- * join an existing channel
- * See @createChannel to join a new channel
- *
- * @param {integer} channelID
- * @param {Object} [options]
- * @return {$.Promise<Object>} resolved with channel object
- */
- joinChannel: function (channelID, options) {
- var self = this;
- if (channelID in this.channelDefs) {
- // prevents concurrent calls to channel_join_and_get_info
- return this.channelDefs[channelID];
- }
- var channel = this.getChannel(channelID);
- if (channel) {
- // channel already joined
- this.channelDefs[channelID] = $.when(channel);
- } else {
- this.channelDefs[channelID] = this._rpc({
- model: 'mail.channel',
- method: 'channel_join_and_get_info',
- args: [[channelID]],
- })
- .then(function (result) {
- return self._addChannel(result, options);
- });
- }
- return this.channelDefs[channelID];
- },
- /**
- * Marks all messages from a channel as read
- *
- * @param {Object} channel
- * @param {Array} domain
- * @return {$.Promise}
- */
- markAllAsRead: function (channel, domain) {
- if ((channel.id === "channel_inbox" && this.needactionCounter) ||
- (channel && channel.needaction_counter)) {
- return this._rpc({
- model: 'mail.message',
- method: 'mark_all_as_read',
- kwargs: {
- channel_ids: channel.id !== "channel_inbox" ? [channel.id] : [],
- domain: domain,
- },
- });
- }
- return $.when();
- },
- /**
- * Mark messages as read
- *
- * @param {Array} msgIDs list of messages ids
- * @return {$.Promise}
- */
- markAsRead: function (msgIDs) {
- var self = this;
- var ids = _.filter(msgIDs, function (id) {
- var message = _.findWhere(self.messages, {id: id});
- // If too many messages, not all are fetched, and some might not be found
- return !message || message.is_needaction;
- });
- if (ids.length) {
- return this._rpc({
- model: 'mail.message',
- method: 'set_message_done',
- args: [ids],
- });
- } else {
- return $.when();
- }
- },
- /**
- * Marks a channel as seen.
- * The seen message will be the last message.
- * Resolved with the last seen message, only for non-static channels
- *
- * @param {Object} channel
- * @return {$.Promise<integer|undefined>} last message id seen in the channel
- */
- markChannelAsSeen: function (channel) {
- if (channel.unread_counter > 0 && channel.type !== 'static') {
- this._updateChannelUnreadCounter(channel, 0);
- return this.channelSeen(channel);
- }
- return $.when();
- },
- /**
- * Opens the chat window in discuss.
- *
- * @param {integer} partnerID
- * @return {$.Promise<Object>} resolved with the dm channel
- */
- openAndDetachDm: function (partnerID) {
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_get_and_minimize',
- args: [[partnerID]],
- })
- .then(this._addChannel.bind(this));
- },
- /**
- * Open the channel:
- *
- * 1. If discuss is opened, asks discuss to open the channel
- * 2. Otherwise, asks the chat_window_manager to detach the channel
- *
- * @param {Object} channel
- */
- openChannel: function (channel) {
- this.chatBus.trigger(this.discussOpen ? 'open_channel' : 'detach_channel', channel);
- },
- /**
- * Prepares and sends a message to the server:
- *
- * 1. Either the message is posted on a channel
- * 2. Or the message is posted in a model's record (chatter)
- *
- * Rule of precedence:
- *
- * 'channelID' < 'model' & 'res_id'
- *
- * If options as none of these parameters, do nothing and return
- * a promise no resolved item.
- *
- * @param {Object} data data related to the new message
- * @param {Object} options
- * @param {string|integer} [options.channelID]
- * @param {string} [options.model]
- * @param {integer} [options.res_id]
- * @return {$.Promise}
- */
- postMessage: function (data, options) {
- var self = this;
- options = options || {};
-
- // This message will be received from the mail composer as html content subtype
- // but the urls will not be linkified. If the mail composer takes the responsibility
- // to linkify the urls we end up with double linkification a bit everywhere.
- // Ideally we want to keep the content as text internally and only make html
- // enrichment at display time but the current design makes this quite hard to do.
- var body = utils.parse_and_transform(_.str.trim(data.content), utils.add_link);
-
- var msg = {
- partner_ids: data.partner_ids,
- body: body,
- attachment_ids: data.attachment_ids,
- };
-
- // Replace emojis by their unicode character
- _.each(emojiUnicodes, function (unicode, key) {
- var escapedKey = String(key).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1');
- var regexp = new RegExp("(\\s|^)(" + escapedKey + ")(?=\\s|$)", "g");
- msg.body = msg.body.replace(regexp, "$1" + unicode);
- });
- if ('subject' in data) {
- msg.subject = data.subject;
- }
- if ('channelID' in options) {
- // post a message in a channel or execute a command
- return this._rpc({
- model: 'mail.channel',
- method: data.command ? 'execute_command' : 'message_post',
- args: [options.channelID],
- kwargs: _.extend(msg, {
- message_type: 'comment',
- subtype: 'mail.mt_comment',
- command: data.command,
- }),
- });
- }
- if ('model' in options && 'res_id' in options) {
- // post a message in a chatter
- _.extend(msg, {
- context: data.context,
- message_type: data.message_type,
- subtype: data.subtype,
- subtype_id: data.subtype_id,
- });
-
- return this._rpc({
- model: options.model,
- method: 'message_post',
- args: [options.res_id],
- kwargs: msg,
- })
- .then(function (msgID) {
- return self._rpc({
- model: 'mail.message',
- method: 'message_format',
- args: [[msgID]],
- })
- .then(function (msgs) {
- msgs[0].model = options.model;
- msgs[0].res_id = options.res_id;
- self._addMessage(msgs[0]);
- });
- });
- }
- return $.when();
- },
- /**
- * Special redirection handling for given model and id
- *
- * If the model is res.partner, and there is a user associated with this
- * partner which isn't the current user, open the DM with this user.
- * Otherwhise, open the record's form view, if this is not the current user's.
- *
- * @param {string} resModel model to open
- * @param {integer} resID record to open
- * @param {function} [dmRedirectionCallback] only used if 'res.partner'
- */
- redirect: function (resModel, resID, dmRedirectionCallback) {
- var self = this;
- var redirectToDocument = function (resModel, resID, viewID) {
- web_client.do_action({
- type:'ir.actions.act_window',
- view_type: 'form',
- view_mode: 'form',
- res_model: resModel,
- views: [[viewID || false, 'form']],
- res_id: resID,
- });
- };
- if (resModel === 'res.partner') {
- var domain = [['partner_id', '=', resID]];
- this._rpc({
- model: 'res.users',
- method: 'search',
- args: [domain],
- })
- .then(function (userIDs) {
- if (userIDs.length && userIDs[0] !== session.uid && dmRedirectionCallback) {
- self.createChannel(resID, 'dm').then(dmRedirectionCallback);
- } else {
- redirectToDocument(resModel, resID);
- }
- });
- } else {
- this._rpc({
- model: resModel,
- method: 'get_formview_id',
- args: [[resID], session.user_context],
- })
- .then(function (viewID) {
- redirectToDocument(resModel, resID, viewID);
- });
- }
- },
- /**
- * Removes all messages from the current model except 'needaction'.
- * We want to keep it in inbox.
- *
- * @param {string} model
- */
- removeChatterMessages: function (model) {
- this.messages = _.reject(this.messages, function (message) {
- return (!message.channel_ids || message.channel_ids.length === 0) && message.model === model;
- });
- },
- /**
- * Search among prefetched partners, using the string 'searchVal'
- *
- * @param {string} searchVal
- * @param {integer} limit max number of found partners in the response
- * @return {$.Promise<Object[]>} list of found partners (matching 'searchVal')
- */
- searchPartner: function (searchVal, limit) {
- var def = $.Deferred();
- var values = [];
- // search among prefetched partners
- var searchRegexp = new RegExp(_.str.escapeRegExp(utils.unaccent(searchVal)), 'i');
- _.each(this.mentionPartnerSuggestions, function (partners) {
- if (values.length < limit) {
- values = values.concat(_.filter(partners, function (partner) {
- return session.partner_id !== partner.id && searchRegexp.test(partner.name);
- })).splice(0, limit);
- }
- });
- if (!values.length) {
- // extend the research to all users
- def = this._rpc({
- model: 'res.partner',
- method: 'im_search',
- args: [searchVal, limit || 20],
- }, {
- shadow: true,
- });
- } else {
- def = $.when(values);
- }
- return def.then(function (values) {
- var autocompleteData = _.map(values, function (value) {
- return { id: value.id, value: value.name, label: value.name };
- });
- return _.sortBy(autocompleteData, 'label');
- });
- },
- /**
- * Stars or unstars message
- *
- * @param {integer} msgID
- * @return {$.Promise}
- */
- toggleStarStatus: function (msgID) {
- return this._rpc({
- model: 'mail.message',
- method: 'toggle_message_starred',
- args: [[msgID]],
- });
- },
- /**
- * Unstars all messages from all channels
- *
- * @return {$.Promise}
- */
- unstarAll: function () {
- return this._rpc({
- model: 'mail.message',
- method: 'unstar_all',
- args: [[]]
- });
- },
- /**
- * Unsubscribes from channel
- *
- * @param {Object} channel
- * @param {integer|string} [channel.id] mandatory if channel is of type 'public' or 'private'
- * @param {string} [channel.type]
- * @param {string} [channel.uuid] mandatory if channel is not of type 'public' or 'private'
- * @return {$.Promise}
- */
- unsubscribe: function (channel) {
- if (_.contains(['public', 'private'], channel.type)) {
- return this._rpc({
- model: 'mail.channel',
- method: 'action_unfollow',
- args: [[channel.id]],
- });
- } else {
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_pin',
- args: [channel.uuid, false],
- });
- }
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Adds a channel and returns it.
- * Simply returns the channel if already exists.
- *
- * @private
- * @param {Object} data
- * @param {string|integer} data.id id of channel or 'channel_inbox', 'channel_starred', ...
- * @param {string|Object} data.name name of channel, e.g. 'general'
- * @param {string} data.type type of channel, e.g. 'static'
- * @param {string} [data.state] e.g. 'open', 'folded'
- * @param {Object|integer} [options=undefined]
- * @param {boolean} [options.silent]
- * @return {Object} the newly or already existing channel
- */
- _addChannel: function (data, options) {
- options = typeof options === "object" ? options : {};
- var channel = this.getChannel(data.id);
- if (channel) {
- if (channel.is_folded !== (data.state === "folded")) {
- channel.is_folded = (data.state === "folded");
- this.call('chat_window_manager', 'toggleFoldChat', channel);
- }
- } else {
- channel = this._makeChannel(data, options);
- this.channels.push(channel);
- if (data.last_message) {
- channel.last_message = this._addMessage(data.last_message);
- }
- // In case of a static channel (Inbox, Starred), the name is translated thanks to _lt
- // (lazy translate). In this case, channel.name is an object, not a string.
- this.channels = _.sortBy(this.channels, function (channel) {
- return _.isString(channel.name) ? channel.name.toLowerCase() : '';
- });
- if (!options.silent) {
- this.chatBus.trigger("new_channel", channel);
- }
- if (channel.is_detached) {
- this.call('chat_window_manager', 'openChat', channel);
- }
- }
- return channel;
- },
- /**
- * Adds a channel to a message
- * Usefull when you mark a message as 'to do'.
- * The message will be available in 'Starred' channel.
- *
- * @private
- * @param {Object} message
- * @param {string} channelID
- */
- _addChannelToMessage: function (message, channelID) {
- if (!message.channel_ids) {
- message.channel_ids = [];
- }
- message.channel_ids.push(channelID);
- message.channel_ids = _.uniq(message.channel_ids);
- },
- /**
- * Creates a new message
- *
- * @private
- * @param {Object} data message data
- * @param {integer} data.id
- * @param {Object} [options]
- * @param {Array} [options.domain]
- * @param {boolean} [options.increment_unread] whether we should increment
- * the unread_counter of channel.
- * @param {boolean} [options.silent] whether it should inform in the chatBus
- * of the newly created message.
- * @return {Object} message object
- */
- _addMessage: function (data, options) {
- var self = this;
- options = options || {};
- var msg = _.findWhere(this.messages, { id: data.id });
-
- if (!msg) {
- msg = this._makeMessage(data);
- // Keep the array ordered by id when inserting the new message
- this.messages.splice(_.sortedIndex(this.messages, msg, 'id'), 0, msg);
- _.each(msg.channel_ids, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- // update the channel's last message (displayed in the channel
- // preview, in mobile)
- if (!channel.last_message || msg.id > channel.last_message.id) {
- channel.last_message = msg;
- }
- self._addToCache(msg, []);
- if (options.domain && options.domain !== []) {
- self._addToCache(msg, options.domain);
- }
- if (channel.hidden) {
- channel.hidden = false;
- self.chatBus.trigger('new_channel', channel);
- }
- if (channel.type !== 'static' && !msg.is_author && !msg.is_system_notification) {
- if (options.increment_unread) {
- self._updateChannelUnreadCounter(channel, channel.unread_counter+1);
- }
- if (channel.is_chat && options.show_notification) {
- if (!self.discussOpen && !config.device.isMobile) {
- // automatically open chat window
- self.call('chat_window_manager', 'openChat', channel, { passively: true });
- }
- var query = {is_displayed: false};
- self.chatBus.trigger('anyone_listening', channel, query);
- self._notifyIncomingMessage(msg, query);
- }
- }
- }
- });
- if (!options.silent) {
- this.chatBus.trigger('new_message', msg);
- }
- } else if (options.domain && options.domain !== []) {
- this._addToCache(msg, options.domain);
- } else if (data.moderation_status === 'accepted') {
- msg.channel_ids = data.channel_ids;
- msg.needsModeration = false;
- if (msg.isModerator) {
- this.moderationCounter--;
- this._removeMessageFromChannel("channel_moderation", msg);
- this.chatBus.trigger('update_moderation_counter');
- }
- this.chatBus.trigger('update_message', msg);
- }
- return msg;
- },
- /**
- * Stores message to channelCache.
- *
- * @private
- * @param {Object} message
- * @param {Array} domain
- */
- _addToCache: function (message, domain) {
- var self = this;
- _.each(message.channel_ids, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- var channelCache = self._getChannelCache(channel, domain);
- var index = _.sortedIndex(channelCache.messages, message, 'id');
- if (channelCache.messages[index] !== message) {
- channelCache.messages.splice(index, 0, message);
- }
- }
- });
- },
- /**
- * Gets messages from their ids
- * This method is used when the chatter linked to a record need to be loaded.
- *
- * @private
- * @param {Array} msgIDs message ids to load
- * @param {Object} options
- * @return {$.Promise<Object[]>} resolved with fetched messages
- */
- _fetchDocumentMessages : function (msgIDs, options) {
- var self = this;
- var loadedMsgs = _.filter(this.messages, function (message) {
- return _.contains(msgIDs, message.id);
- });
- var loadedMsgIDs = _.pluck(loadedMsgs, 'id');
-
- options = options || {};
- if (options.forceFetch || _.difference(msgIDs.slice(0, LIMIT), loadedMsgIDs).length) {
- var idsToLoad = _.difference(msgIDs, loadedMsgIDs).slice(0, LIMIT);
- return this._rpc({
- model: 'mail.message',
- method: 'message_format',
- args: [idsToLoad],
- context: session.user_context,
- })
- .then(function (msgs) {
- var processedMsgs = [];
- _.each(msgs, function (msg) {
- processedMsgs.push(self._addMessage(msg, {silent: true}));
- });
- return _.sortBy(loadedMsgs.concat(processedMsgs), function (msg) {
- return msg.id;
- });
- });
- } else {
- return $.when(loadedMsgs);
- }
- },
- /**
- * Gets messages from channel
- *
- * @private
- * @param {Object} channel
- * @param {integer|string} channel.id string for static channels, e.g. 'channel_inbox'
- * @param {Object} [option={}]
- * @param {Array} [options.domain] filter on the messages of the channel
- * @param {boolean} [options.loadMore] Whether it should load more message
- * @return {$.Promise<Object[]>} resolved with list of messages
- */
- _fetchFromChannel: function (channel, options) {
- var self = this;
- options = options || {};
- var domain =
- (channel.id === 'channel_inbox') ? [['needaction', '=', true]] :
- (channel.id === 'channel_starred') ? [['starred', '=', true]] :
- (channel.id === 'channel_moderation') ? [['need_moderation', '=', true]] :
- ['|',
- '&', '&',
- ['model', '=', 'mail.channel'],
- ['res_id', 'in', [channel.id]],
- ['need_moderation', '=', true],
- ['channel_ids', 'in', [channel.id]]
- ];
- var cache = this._getChannelCache(channel, options.domain);
-
- if (options.domain) {
- domain = domain.concat(options.domain || []);
- }
- if (options.loadMore) {
- var minMessageID = cache.messages[0].id;
- domain = [['id', '<', minMessageID]].concat(domain);
- }
-
- return this._rpc({
- model: 'mail.message',
- method: 'message_fetch',
- args: [domain],
- kwargs: {limit: LIMIT, context: session.user_context},
- })
- .then(function (msgs) {
- if (!cache.all_history_loaded) {
- cache.all_history_loaded = msgs.length < LIMIT;
- }
- cache.loaded = true;
-
- _.each(msgs, function (msg) {
- self._addMessage(msg, {
- channel_id: channel.id,
- silent: true,
- domain: options.domain,
- });
- });
- var channelCache = self._getChannelCache(channel, options.domain || []);
- return channelCache.messages;
- });
- },
- /**
- * Gets channel content from the cache.
- * Usefull to get cached messages from a channel.
- *
- * @private
- * @param {Object} channel
- * @param {Object[]} channel.cache
- * @param {Array} domain
- * @return {Object|undefined}
- */
- _getChannelCache: function (channel, domain) {
- var stringifiedDomain = JSON.stringify(domain || []);
- if (!channel.cache[stringifiedDomain]) {
- channel.cache[stringifiedDomain] = {
- all_history_loaded: false,
- loaded: false,
- messages: [],
- };
- }
- return channel.cache[stringifiedDomain];
- },
- /**
- * @private
- * @returns {$.Promise}
- */
- _initMessaging: function () {
- var self = this;
- return session.is_bound.then(function (){
- var context = _.extend({isMobile: config.device.isMobile}, session.user_context);
- return self._rpc({
- route: '/mail/init_messaging',
- params: {context: context},
- });
- }).then(function (result) {
- _.each(result.channel_slots, function (channels) {
- _.each(channels, self._addChannel.bind(self));
- });
- self.messageFailures = result.mail_failures;
- self.needactionCounter = result.needaction_inbox_counter || 0;
- self.starredCounter = result.starred_counter || 0;
- self.moderationCounter = result.moderation_counter;
- self.moderatedChannelIDs = result.moderation_channel_ids;
- self._isModerator = result.is_moderator;
-
- //if user is moderator then add moderation channel
- if (self._isModerator) {
- self._addChannel({
- id: "channel_moderation",
- name: _lt("Moderate Messages"),
- type: "static"
- });
- }
- self.commands = _.map(result.commands, function (command) {
- return _.extend({ id: command.name }, command);
- });
- self.mentionPartnerSuggestions = result.mention_partner_suggestions;
- self.discussMenuID = result.menu_id;
-
- // Shortcodes: canned responses
- _.each(result.shortcodes, function (s) {
- self.cannedResponses.push(_.pick(s, ['id', 'source', 'substitution']));
- });
- // emojis
-
- var lastAdded = null;
- _.each(emojiUnicodes, function (unicode, key) {
- if (lastAdded != unicode){
- lastAdded = unicode;
- self.emojis.push({source:key, unicode_source:unicode, description:key});
- }
- });
-
- self.busBus.start_polling();
- });
- },
- /**
- * Clear cache of a channel
- *
- * @private
- * @param {Array} channelIDs must be int or string
- */
- _invalidateCaches: function (channelIDs) {
- var self = this;
- _.each(channelIDs, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- channel.cache = { '[]': channel.cache['[]']};
- }
- });
- },
- /**
- * Creates channel object
- *
- * @private
- * @param {Object} data
- * @param {string} [data.anonymous_name]
- * @param {string} data.channel_type
- * @param {Object[]} [data.direct_partner]
- * @param {boolean} [data.group_based_subscription]
- * @param {integer|string} data.id
- * @param {boolean} data.is_moderator
- * @param {boolean} data.is_minimized
- * @param {string} [data.last_message_date]
- * @param {boolean} data.mass_mailing
- * @param {integer} [data.message_needaction_counter]
- * @param {integer} [data.message_unread_counter]
- * @param {boolean} data.moderation whether this channel is moderated or not
- * @param {string} data.name
- * @param {string} [data.public]
- * @param {integer} data.seen_message_id
- * @param {string} [data.state]
- * @param {string} [data.type]
- * @param {string} data.uuid
- * @param {Object} options
- * @param {boolean} [options.autoswitch]
- * @param {boolean} options.displayNeedactions
- * @param {boolean} [options.hidden]
- * @return {Object} channel
- */
- _makeChannel: function (data, options) {
- var channel = {
- id: data.id,
- name: data.name,
- server_type: data.channel_type,
- type: data.type || data.channel_type,
- all_history_loaded: false,
- uuid: data.uuid,
- is_detached: data.is_minimized,
- is_folded: data.state === "folded",
- autoswitch: 'autoswitch' in options ? options.autoswitch : true,
- hidden: options.hidden,
- display_needactions: options.displayNeedactions,
- mass_mailing: data.mass_mailing,
- isModerated: data.moderation,
- isModerator: data.is_moderator,
- group_based_subscription: data.group_based_subscription,
- needaction_counter: data.message_needaction_counter || 0,
- unread_counter: 0,
- last_seen_message_id: data.seen_message_id,
- cache: {'[]': {
- all_history_loaded: false,
- loaded: false,
- messages: [],
- }},
- };
- if (channel.type === "channel") {
- channel.type = data.public !== "private" ? "public" : "private";
- }
- if (_.size(data.direct_partner) > 0) {
- channel.type = "dm";
- channel.name = data.direct_partner[0].name;
- channel.direct_partner_id = data.direct_partner[0].id;
- channel.status = data.direct_partner[0].im_status;
- this.pinnedDmPartners.push(channel.direct_partner_id);
- this.busBus.update_option('bus_presence_partner_ids', this.pinnedDmPartners);
- } else if ('anonymous_name' in data) {
- channel.name = data.anonymous_name;
- }
- if (data.last_message_date) {
- channel.last_message_date = moment(time.str_to_datetime(data.last_message_date));
- }
- channel.is_chat = !channel.type.match(/^(public|private|static)$/);
- if (data.message_unread_counter) {
- this._updateChannelUnreadCounter(channel, data.message_unread_counter);
- }
- return channel;
- },
- /**
- * Creates message object
- *
- * @private
- * @param {Object} data
- * @param {integer[]} [data.attachment_ids=[]]
- * @param {integer[]} [data.author_id]
- * @param {string} [data.body=""]
- * @param {Array} data.channel_ids contains integers and strings
- * @param {string} data.customer_email_status
- * @param {string} data.date
- * @param {string} data.email_from
- * @param {integer} data.id
- * @param {string} [data.info]
- * @param {boolean} data.is_discussion
- * @param {boolean} data.is_note
- * @param {boolean} data.is_notification
- * @param {string} data.message_type
- * @param {string} [data.moderation_status]
- * @param {string} [data.model]
- * @param {boolean} data.module_icon src url of the module icon
- * @param {string} data.record_name
- * @param {integer} data.res_id
- * @param {string} data.subject
- * @param {string} data.subtype_description
- * @param {integer[]} data.tracking_value_ids
- * @return {Object} message
- */
- _makeMessage: function (data) {
- var self = this;
- var msg = {
- id: data.id,
- author_id: data.author_id,
- body: data.body || "",
- date: moment(time.str_to_datetime(data.date)),
- message_type: data.message_type,
- subtype_description: data.subtype_description,
- is_author: data.author_id && data.author_id[0] === session.partner_id,
- isModerator: data.model === 'mail.channel' &&
- _.contains(this.moderatedChannelIDs, data.res_id) &&
- data.moderation_status === 'pending_moderation',
- is_note: data.is_note,
- is_discussion: data.is_discussion,
- is_notification: data.is_notification,
- is_system_notification: (data.message_type === 'notification' && data.model === 'mail.channel')
- || data.info === 'transient_message',
- attachment_ids: data.attachment_ids || [],
- subject: data.subject,
- email_from: data.email_from,
- customer_email_status: data.customer_email_status,
- customer_email_data: data.customer_email_data,
- record_name: data.record_name,
- tracking_value_ids: data.tracking_value_ids,
- channel_ids: data.channel_ids,
- model: data.model,
- res_id: data.res_id,
- url: session.url("/mail/view?message_id=" + data.id),
- module_icon:data.module_icon,
- };
- _.each(emojiUnicodes, function (value, key) {
- //add o_mail_emoji class on each unicode to manage size and font
- var unicode = String(value);
- var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|</[a-z]*>)", "g");
- var msg_bak = msg.body;
- msg.body = msg.body.replace(regexp, ' <span class="o_mail_emoji">'+unicode+'</span> ');
- // Idiot-proof limit. If the user had the amazing idea of copy-pasting thousands of emojis,
- // the image rendering can lead to memory overflow errors on some browsers (e.g. Chrome).
- // Set an arbitrary limit to 200 from which we simply don't replace them (anyway, they are
- // already replaced by the unicode counterpart).
- if (_.str.count(msg.body, 'o_mail_emoji') > 200) {
- msg.body = msg_bak;
- }
- });
- function propertyDescr(channel) {
- return {
- enumerable: true,
- get: function () {
- return _.contains(msg.channel_ids, channel);
- },
- set: function (bool) {
- if (bool) {
- self._addChannelToMessage(msg, channel);
- } else {
- msg.channel_ids = _.without(msg.channel_ids, channel);
- }
- }
- };
- }
-
- Object.defineProperties(msg, {
- is_starred: propertyDescr("channel_starred"),
- is_needaction: propertyDescr("channel_inbox"),
- needsModerationByUser: propertyDescr("channel_moderation"),
- });
-
- if (_.contains(data.needaction_partner_ids, session.partner_id)) {
- msg.is_needaction = true;
- }
- if (_.contains(data.starred_partner_ids, session.partner_id)) {
- msg.is_starred = true;
- }
- if (data.moderation_status === 'pending_moderation') {
- msg.needsModeration = true;
- msg.needsModerationByUser = msg.isModerator;
- // the message is not linked to the moderated channel on the
- // server, therefore this message has not this channel in
- // channel_ids. Here, just to show this message in the channel
- //visually, it links this message to the channel
- msg.channel_ids.push(msg.res_id);
- }
- if (msg.model === 'mail.channel') {
- var realChannels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred', 'channel_moderation');
- var origin = realChannels.length === 1 ? realChannels[0] : undefined;
- var channel = origin && this.getChannel(origin);
- if (channel) {
- msg.origin_id = origin;
- msg.origin_name = channel.name;
- }
- }
-
- // Compute displayed author name or email
- if ((!msg.author_id || !msg.author_id[0]) && msg.email_from) {
- msg.mailto = msg.email_from;
- } else {
- msg.displayed_author = (msg.author_id === ODOOBOT_ID) && "OdooBot" ||
- msg.author_id && msg.author_id[1] ||
- msg.email_from || _t('Anonymous');
- }
-
- // Don't redirect on author clicked of self-posted or OdooBot messages
- msg.author_redirect = !msg.is_author && msg.author_id !== ODOOBOT_ID;
-
- // Compute the avatar_url
- if (msg.author_id === ODOOBOT_ID) {
- msg.avatar_src = "/mail/static/src/img/odoo_o.png";
- } else if (msg.author_id && msg.author_id[0]) {
- msg.avatar_src = "/web/image/res.partner/" + msg.author_id[0] + "/image_small";
- } else if (msg.message_type === 'email') {
- msg.avatar_src = "/mail/static/src/img/email_icon.png";
- } else {
- msg.avatar_src = "/mail/static/src/img/smiley/avatar.jpg";
- }
-
- // add anchor tags to urls
- msg.body = utils.parse_and_transform(msg.body, utils.add_link);
-
- // Compute url of attachments
- _.each(msg.attachment_ids, function (a) {
- a.url = '/web/content/' + a.id + '?download=true';
- });
-
- // format date to the local only once by message
- // can not be done in preprocess, since it alter the original value
- if (msg.tracking_value_ids && msg.tracking_value_ids.length) {
- var format;
- _.each(msg.tracking_value_ids, function (f) {
- if (f.field_type === 'datetime') {
- format = 'LLL';
- if (f.old_value) {
- f.old_value = moment.utc(f.old_value).local().format(format);
- }
- if (f.new_value) {
- f.new_value = moment.utc(f.new_value).local().format(format);
- }
- } else if (f.field_type === 'date') {
- format = 'LL';
- if (f.old_value) {
- f.old_value = moment(f.old_value).local().format(format);
- }
- if (f.new_value) {
- f.new_value = moment(f.new_value).local().format(format);
- }
- }
- });
- }
-
- return msg;
- },
- /**
- * @private
- * @param {Object} data key, value to decide activity created or deleted
- */
- _manageActivityUpdateNotification: function (data) {
- this.chatBus.trigger('activity_updated', data);
- },
- /**
- * @private
- * @param {Object} data
- */
- _manageAuthorNotification: function (data) {
- this._addMessage(data.message);
- },
- /**
- * @private
- * @param {Object} message
- * @param {Array} message.channel_ids list of integers and strings,
- * where strings for static channels, e.g. 'channel_inbox'.
- */
- _manageChannelNotification: function (message) {
- var self = this;
- var def;
- var channelAlreadyInCache = true;
- if (message.channel_ids.length === 1) {
- channelAlreadyInCache = !!this.getChannel(message.channel_ids[0]);
- def = this.joinChannel(message.channel_ids[0], {autoswitch: false});
- } else {
- def = $.when();
- }
- def.then(function () {
- // don't increment unread if channel wasn't in cache yet as
- // its unread counter has just been fetched
- self._addMessage(message, {
- show_notification: true,
- increment_unread: channelAlreadyInCache
- });
- self._invalidateCaches(message.channel_ids);
- });
- },
- /**
- * @private
- * @param {Object} data
- * @param {integer|string} data.id string for static channels, e.g. 'channel_inbox'
- * @param {integer} [data.last_message_id] mandatory if 'id' refers to an
- * existing channel.
- */
- _manageChannelSeenNotification: function (data) {
- var channel = this.getChannel(data.id);
- if (channel) {
- channel.last_seen_message_id = data.last_message_id;
- if (channel.unread_counter) {
- this._updateChannelUnreadCounter(channel, 0);
- }
- }
- },
- /**
- * Controls the session of the chat window
- *
- * @private
- * @param {Object} chatSession
- * @param {string} chatSession.channel_type
- * @param {string|integer} chatSession.id string for static channels, e.g. 'channel_inbox'
- * @param {string} chatSession.info
- * @param {boolean} chatSession.is_minimized
- * @param {string} chatSession.name
- * @param {string} chatSession.state
- */
- _manageChatSessionNotification: function (chatSession) {
- var channel;
- if ((chatSession.channel_type === "channel") && (chatSession.state === "open")) {
- this._addChannel(chatSession, {autoswitch: false});
- if (!chatSession.is_minimized && chatSession.info !== 'creation') {
- web_client.do_notify(_t("Invitation"), _t("You have been invited to: ") + chatSession.name);
- }
- }
- // partner specific change (open a detached window for example)
- if ((chatSession.state === "open") || (chatSession.state === "folded")) {
- channel = chatSession.is_minimized && this.getChannel(chatSession.id);
- if (channel) {
- channel.is_detached = true;
- channel.is_folded = (chatSession.state === "folded");
- this.call('chat_window_manager', 'openChat', channel);
- }
- } else if (chatSession.state === "closed") {
- channel = this.getChannel(chatSession.id);
- if (channel) {
- channel.is_detached = false;
- this.call('chat_window_manager', 'closeChat', channel, {keep_open_if_unread: true});
- }
- }
- },
- /**
- * @private
- * @param {Object} data
- * @param {Object[]} [data.message_ids]
- */
- _manageDeletionNotification: function (data) {
- var self = this;
- _.each(data.message_ids, function (msgID) {
- var message = _.findWhere(self.messages, { id: msgID });
- if (message) {
- if (message.isModerator) {
- self._removeMessageFromChannel("channel_moderation", message);
- self.moderationCounter--;
- }
- message.needsModeration = false;
- self._removeMessageFromChannel(message.res_id, message);
- self.chatBus.trigger('update_message', message);
- }
- });
- this.chatBus.trigger('update_moderation_counter');
- },
- /**
- * Add or remove failure when receiving a failure update message
- * @private
- * @param {Object} data
- */
- _manageMessageFailureNotification: function (data) {
- var self = this;
- _.each(data.elements, function (updateMessage) {
-
- var isAddFailure = _.some(updateMessage.notifications, function(notif) {
- return notif[0] === 'exception' || notif[0] === 'bounce';
- });
- var res = _.find(self.messageFailures, {'message_id': updateMessage.message_id});
- if (res) {
- var index = _.findIndex(self.messageFailures, res);
- if (isAddFailure) {
- self.messageFailures[index] = updateMessage;
- } else {
- self.messageFailures.splice(index, 1);
- }
- } else if (isAddFailure) {
- self.messageFailures.push(updateMessage);
- }
- var message = _.findWhere(self.messages, { id: updateMessage.message_id });
- if (message) {
- if (isAddFailure) {
- message.customer_email_status = "exception";
- } else{
- message.customer_email_status = "sent";
- }
- self._updateMessageNotificationStatus(updateMessage, message);
- self.chatBus.trigger('update_message', message);
- }
- });
- this.chatBus.trigger('update_needaction', this.needactionCounter);
- },
- /**
- * Updates channel_inbox when a message has marked as read.
- *
- * @private
- * @param {Object} data
- * @param {integer[]} [data.channel_ids]
- * @param {integer[]} [data.message_ids]
- * @param {string} [data.type]
- */
- _manageMarkAsReadNotification: function (data) {
- var self = this;
- _.each(data.message_ids, function (msgID) {
- var message = _.findWhere(self.messages, { id: msgID });
- if (message) {
- self._invalidateCaches(message.channel_ids);
- self._removeMessageFromChannel("channel_inbox", message);
- self.chatBus.trigger('update_message', message, data.type);
- }
- });
- if (data.channel_ids) {
- _.each(data.channel_ids, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- channel.needaction_counter = Math.max(channel.needaction_counter - data.message_ids.length, 0);
- }
- });
- } else { // if no channel_ids specified, this is a 'mark all read' in the inbox
- _.each(this.channels, function (channel) {
- channel.needaction_counter = 0;
- });
- }
- this.needactionCounter = Math.max(this.needactionCounter - data.message_ids.length, 0);
- this.chatBus.trigger('update_needaction', this.needactionCounter);
- },
- /**
- * @private
- * @param {Object} data notification data
- * @param {Object} data.message
- */
- _manageModeratorNotification: function (data) {
- this.moderationCounter++;
- this._addMessage(data.message);
- this.chatBus.trigger('update_moderation_counter');
- },
- /**
- * @private
- * @param {Object} message
- * @param {integer[]} message.channel_ids
- */
- _manageNeedactionNotification: function (message) {
- var self = this;
- message = this._addMessage(message, {
- channel_id: 'channel_inbox',
- increment_unread: true,
- show_notification: true,
- });
- this._invalidateCaches(message.channel_ids);
- if (message.channel_ids.length !== 0) {
- this.needactionCounter++;
- }
- _.each(message.channel_ids, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- channel.needaction_counter++;
- }
- });
- this.chatBus.trigger('update_needaction', this.needactionCounter);
- },
- /**
- * @private
- * @param {Object} data structure depending on the type
- * @param {integer} data.id
- */
- _managePartnerNotification: function (data) {
- if (data.info === "unsubscribe") {
- var channel = this.getChannel(data.id);
- if (channel) {
- var msg;
- if (_.contains(['public', 'private'], channel.type)) {
- msg = _.str.sprintf(_t('You unsubscribed from <b>%s</b>.'), channel.name);
- } else {
- msg = _.str.sprintf(_t('You unpinned your conversation with <b>%s</b>.'), channel.name);
- }
- this._removeChannel(channel);
- this.chatBus.trigger("unsubscribe_from_channel", data.id);
- web_client.do_notify(_t("Unsubscribed"), msg);
- }
- } else if (data.type === 'toggle_star') {
- this._manageToggleStarNotification(data);
- } else if (data.type === 'mark_as_read') {
- this._manageMarkAsReadNotification(data);
- } else if (data.type === 'moderator') {
- this._manageModeratorNotification(data);
- } else if (data.type === 'author') {
- this._manageAuthorNotification(data);
- } else if (data.type === 'deletion') {
- this._manageDeletionNotification(data);
- } else if (data.info === 'channel_seen') {
- this._manageChannelSeenNotification(data);
- } else if (data.info === 'transient_message') {
- this._manageTransientMessageNotification(data);
- } else if (data.type === 'activity_updated') {
- this._manageActivityUpdateNotification(data);
- } else if (data.type === 'mail_failure') {
- this._manageMessageFailureNotification(data);
- } else {
- this._manageChatSessionNotification(data);
- }
- },
- /**
- * @private
- * @param {Object} data partner infos
- * @param {integer} data.id
- * @param {string} data.im_status
- */
- _managePresenceNotification: function (data) {
- var dm = this.getDmFromPartnerID(data.id);
- if (dm) {
- dm.status = data.im_status;
- this.chatBus.trigger('update_dm_presence', dm);
- }
- },
- /**
- * @private
- * @param {Object} data
- * @param {integer[]} data.message_ids
- * @param {boolean} data.starred
- * @param {string} data.type
- */
- _manageToggleStarNotification: function (data) {
- var self = this;
- _.each(data.message_ids, function (msgID) {
- var message = _.findWhere(self.messages, { id: msgID });
- if (message) {
- self._invalidateCaches(message.channel_ids);
- message.is_starred = data.starred;
- if (!message.is_starred) {
- self._removeMessageFromChannel("channel_starred", message);
- } else {
- self._addToCache(message, []);
- var channelStarred = self.getChannel('channel_starred');
- channelStarred.cache = _.pick(channelStarred.cache, "[]");
- }
- self.chatBus.trigger('update_message', message);
- }
- });
-
- if (data.starred) { // increase starred counter if message is marked as star
- this.starredCounter += data.message_ids.length;
- } else { // decrease starred counter if message is remove from star if unstar_all then it will set to 0.
- this.starredCounter -= data.message_ids.length;
- }
-
- this.chatBus.trigger('update_starred', this.starredCounter);
- },
- /**
- * @private
- * @param {Object} data
- * @param {string} data.author_id
- */
- _manageTransientMessageNotification: function (data) {
- var lastMessage = _.last(this.messages);
- data.id = (lastMessage ? lastMessage.id : 0) + 0.01;
- data.author_id = data.author_id || ODOOBOT_ID;
- this._addMessage(data);
- },
- /**
- * shows a popup to notify a new received message.
- * This will also rename the odoo tab browser if
- * the user is not in it.
- *
- * @private
- * @param {Object} msg message received
- * @param {Array} msg.author_id contains [integer, string]
- * @param {string} msg.body
- * @param {Object} options
- * @param {boolean} options.is_displayed
- */
- _notifyIncomingMessage: function (msg, options) {
- if (this.busBus.is_odoo_focused() && options.is_displayed) {
- // no need to notify
- return;
- }
- var title = _t('New message');
- if (msg.author_id[1]) {
- title = _.escape(msg.author_id[1]);
- }
- var content = utils.parse_and_transform(msg.body, utils.strip_html)
- .substr(0, PREVIEW_MSG_MAX_SIZE);
-
- if (!this.busBus.is_odoo_focused()) {
- this.globalUnreadCounter++;
- var tabTitle = _.str.sprintf(_t("%d Messages"), this.globalUnreadCounter);
- web_client.set_title_part("_chat", tabTitle);
- }
-
- this.call('bus_service', 'sendNotification', web_client, title, content);
- },
- /**
- * Removes channel
- *
- * @private
- * @param {Object} [channel]
- * @param {integer} [channel.direct_partner_id] mandatory if type is 'dm'
- * @param {integer|string} channel.id string for static channels, e.g. 'channel_inbox'
- * @param {string} [channel.type]
- */
- _removeChannel: function (channel) {
- if (!channel) { return; }
- if (channel.type === 'dm') {
- var index = this.pinnedDmPartners.indexOf(channel.direct_partner_id);
- if (index > -1) {
- this.pinnedDmPartners.splice(index, 1);
- this.busBus.update_option('bus_presence_partner_ids', this.pinnedDmPartners);
- }
- }
- this.channels = _.without(this.channels, channel);
- delete this.channelDefs[channel.id];
- },
- /**
- * Removes a message from a channel.
- * it will also remove cached message from the channel
- *
- * @private
- * @param {integer|string} channelID string for static channels, e.g. 'channel_inbox'
- * @param {Object} message
- */
- _removeMessageFromChannel: function (channelID, message) {
- message.channel_ids = _.without(message.channel_ids, channelID);
- var channel = _.findWhere(this.channels, { id: channelID });
- _.each(channel.cache, function (cache) {
- cache.messages = _.without(cache.messages, message);
- });
- },
- /**
- * Increments or decrements unreadConversationCounter
- *
- * @private
- * @param {Object} channel
- * @param {integer} counter
- */
- _updateChannelUnreadCounter: function (channel, counter) {
- if (channel.unread_counter > 0 && counter === 0) {
- this.unreadConversationCounter = Math.max(0, this.unreadConversationCounter-1);
- } else if (channel.unread_counter === 0 && counter > 0) {
- this.unreadConversationCounter++;
- }
- channel.unread_counter = counter;
- this.chatBus.trigger("update_channel_unread_counter", channel);
- },
- /**
- * Update the message notification status of message based on update_message
- *
- * @private
- * @param {Object} updateMessage
- * @param {Object[]} updateMessage.notifications
- */
- _updateMessageNotificationStatus: function (updateMessage, message) {
- _.each(updateMessage.notifications, function (notif, id, list) {
- var partnerName = notif[1];
- var notifStatus = notif[0];
- var res = _.find(message.customer_email_data, function(entry){
- return entry[0] === parseInt(id);
- });
- if (res) {
- res[2] = notifStatus;
- } else {
- message.customer_email_data.push([parseInt(id), partnerName, notifStatus]);
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Notification handlers
- * Sometimes, the web client receives unsubscribe notification and an extra
- * notification on that channel. This is then followed by an attempt to
- * rejoin the channel that we just left. The next few lines remove the
- * extra notification to prevent that situation to occur.
- *
- * @private
- * @param {Array} notifications
- */
- _onNotification: function (notifications) {
- var self = this;
- var unsubscribedNotif = _.find(notifications, function (notif) {
- return notif[1].info === "unsubscribe";
- });
- if (unsubscribedNotif) {
- notifications = _.reject(notifications, function (notif) {
- return notif[0][1] === "mail.channel" && notif[0][2] === unsubscribedNotif[1].id;
- });
- }
- _.each(notifications, function (notification) {
- var model = notification[0][1];
- if (model === 'ir.needaction') {
- // new message in the inbox
- self._manageNeedactionNotification(notification[1]);
- } else if (model === 'mail.channel') {
- // new message in a channel
- self._manageChannelNotification(notification[1]);
- } else if (model === 'res.partner') {
- // channel joined/left, message marked as read/(un)starred, chat open/closed
- self._managePartnerNotification(notification[1]);
- } else if (model === 'bus.presence') {
- // update presence of users
- self._managePresenceNotification(notification[1]);
- }
- });
- },
- /**
- * Global unread counter and notifications
- *
- * @private
- */
- _onWindowFocus: function () {
- this.globalUnreadCounter = 0;
- web_client.set_title_part("_chat");
- },
-
-});
-
-core.serviceRegistry.add('chat_manager', ChatManager);
-
-return ChatManager;
-
-});
diff --git a/addons/mail/static/src/js/services/chat_window_manager.js b/addons/mail/static/src/js/services/chat_window_manager.js
deleted file mode 100644
index fd45b336c23..00000000000
--- a/addons/mail/static/src/js/services/chat_window_manager.js
+++ /dev/null
@@ -1,534 +0,0 @@
-odoo.define('mail.ChatWindowManager', function (require) {
-"use strict";
-
-var ExtendedChatWindow = require('mail.ExtendedChatWindow');
-
-var AbstractService = require('web.AbstractService');
-var config = require('web.config');
-var core = require('web.core');
-var utils = require('web.utils');
-var web_client = require('web.web_client');
-
-var _t = core._t;
-var QWeb = core.qweb;
-
-//----------------------------------------------------------------
-var CHAT_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows
-
-/**
- * This service handles chat channels that are displayed in "chat window" mode
- */
-var ChatWindowManager = AbstractService.extend({
- name: 'chat_window_manager',
- dependencies: ['chat_manager'],
-
- /**
- * @override
- */
- init: function () {
- var self = this;
-
- this._super.apply(this, arguments);
-
- this.chatSessions = [];
- this.newChatSession;
- this.displayState = {
- chatWindowsHidden: false, // chat windows aren't displayed when the client action is open
- hiddenSessions: [],
- hiddenUnreadCounter: 0, // total number of unread msgs in hidden chat windows
- nbSlots: 0,
- spaceLeft: 0,
- windowsDropdownIsOpen: false, // used to keep dropdown open when closing chat windows
- };
- this.call('chat_manager', 'isReady').then(function () {
- var channels = self.call('chat_manager', 'getChannels');
- _.each(channels, function (channel) {
- if (channel.is_detached) {
- self.openChat(channel);
- }
- });
- });
-
- var chatBus = this.call('chat_manager', 'getChatBus');
- chatBus.on('update_message', this, this._onUpdateMessage);
- chatBus.on('new_message', this, this._onNewMessage);
- chatBus.on('anyone_listening', this, this._onAnyoneListening);
- chatBus.on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel);
- chatBus.on('update_channel_unread_counter', this, this._onUpdateChannelUnreadCounter);
- chatBus.on('update_dm_presence', this, this._onUpdateDmPresence);
- chatBus.on('detach_channel', this, this._onDetachChannel);
- chatBus.on('discuss_open', this, this._onDiscussOpen);
-
- core.bus.on('resize', this, _.debounce(this._repositionWindows.bind(this), 100));
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @param {Object} chatSession
- * @param {integer} chatSession.id
- * @param {Object} options
- */
- closeChat: function (chatSession, options) {
- var session = _.find(this.chatSessions, {id: chatSession.id});
- if (session) {
- this._closeChat(session, options);
- }
- },
- /**
- * @param {Object} session
- * @param {integer} session.id
- * @param {boolean} [session.is_chat] true for dm? => server_type="chat" & type="dm"...
- * @param {boolean} [session.mass_mailing]
- * @param {string} [session.status] e.g. 'offline'
- * @param {Object} [options]
- * @param {boolean} [options.passively] if set to true, open the chat window
- * without focusing the input and marking messages as read if it is not
- * open yet, and do nothing otherwise
- */
- openChat: function (session, options) {
- var self = this;
- if (!session) {
- this._openChatWithoutSession();
- return;
- }
- options = options || {};
- var chatSession = _.findWhere(this.chatSessions, {id: session.id});
- if (!chatSession) {
- var prefix = !session.is_chat ? "#" : "";
- var windowOptions = {
- autofocus: !options.passively,
- input_less: session.mass_mailing,
- status: session.status,
- };
- chatSession = {
- id: session.id,
- uuid: session.uuid,
- name: session.name,
- keep_unread: options.passively, // don't automatically mark unread messages as seen
- window: new ExtendedChatWindow(this, session.id, prefix + session.name, session.is_folded, session.unread_counter, windowOptions),
- };
- chatSession.window.on("close_chat_session", null, function () {
- self._closeChat(chatSession);
- self.call('chat_manager', 'closeChatSession', chatSession.id);
- });
- chatSession.window.on("toggle_star_status", null, function (messageID) {
- self.call('chat_manager', 'toggleStarStatus', messageID);
- });
-
- chatSession.window.on("fold_channel", null, function (channelID, folded) {
- self.call('chat_manager', 'foldChannel', channelID, folded);
- });
-
- chatSession.window.on("post_message", null, function (message, channelID) {
- self.call('chat_manager', 'postMessage', message, {
- channelID: channelID
- }).then(function () {
- chatSession.window.thread.scroll_to();
- });
- });
- chatSession.window.on("redirect", null, function (resModel, resID) {
- self.call('chat_manager', 'redirect', resModel, resID, self.openChat.bind(self));
- });
- chatSession.window.on("redirect_to_channel", null, function (channelID) {
- var session = _.findWhere(self.chatSessions, {id: channelID});
- if (!session) {
- self.call('chat_manager', 'joinChannel', channelID).then(function (channel) {
- self.call('chat_manager', 'detachChannel', channelID);
- });
- } else {
- session.window.toggle_fold(false);
- }
- });
-
- var removeNewChat = false;
- if (options.passively) {
- this.chatSessions.push(chatSession); // simply insert the window to the left
- } else if (this.newChatSession && this.newChatSession.partner_id && this.newChatSession.partner_id === session.direct_partner_id) {
- // the window takes the place of the 'newChatSession' window
- this.chatSessions[_.indexOf(this.chatSessions, this.newChatSession)] = chatSession;
- removeNewChat = true;
- } else {
- this._addChatSession(chatSession); // add session such that window is visible
- }
-
- chatSession.window.appendTo($('body'))
- .then(function () {
- self._repositionWindows({remove_new_chat: removeNewChat});
- return self.call('chat_manager', 'getMessages', {channelID: chatSession.id});
- }).then(function (messages) {
- chatSession.window.render(messages);
- chatSession.window.thread.scroll_to();
- setTimeout(function () {
- chatSession.window.thread.$el.on("scroll", null, _.debounce(function () {
- if (!chatSession.keep_unread && chatSession.window.thread.is_at_bottom()) {
- self.call('chat_manager', 'markChannelAsSeen', session);
- }
- }, 100));
- }, 0); // setTimeout to prevent to execute handler on first scroll_to, which is asynchronous
- if (options.passively) {
- // mark first unread messages as seen when focusing the window, then on scroll to bottom as usual
- chatSession.window.$('.o_mail_thread, .o_chat_composer').one('click', function () {
- self.call('chat_manager', 'markChannelAsSeen', session);
- });
- } else if (!self.displayState.chatWindowsHidden && !session.is_folded) {
- self.call('chat_manager', 'markChannelAsSeen', session);
- }
- });
- } else if (!options.passively) {
- if (chatSession.window.is_hidden) {
- this._makeSessionVisible(chatSession);
- } else if (session.is_folded !== chatSession.window.folded) {
- chatSession.window.toggle_fold(session.is_folded);
- }
- }
- },
- /**
- * Called when unfolding the chat window
- *
- * @param {Object} channel
- * @param {integer} channel.id
- * @param {boolean} [channel.is_folded]
- */
- toggleFoldChat: function (channel) {
- var session = _.find(this.chatSessions, {id: channel.id});
- if (session) {
- session.window.toggle_fold(channel.is_folded);
- }
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Add chatSession such that it will be the left-most visible window
- *
- * @private
- * @param {Object} chatSession
- */
- _addChatSession: function (chatSession) {
- this._computeAvailableSlots(this.chatSessions.length+1);
- this.chatSessions.splice(this.displayState.nbSlots-1, 0, chatSession);
- },
- /**
- * @private
- * @param {Object} chatSession
- * @param {boolean} [chatSession.keep_unread]
- * @param {Object} chatSession.window
- * @param {Object} [options]
- * @param {boolean} [options.keep_open_if_unread]
- */
- _closeChat: function (chatSession, options) {
- if (options && options.keep_open_if_unread && chatSession.keep_unread) {
- return;
- }
- this.chatSessions = _.without(this.chatSessions, chatSession);
- chatSession.window.destroy();
- this._repositionWindows();
- },
- /**
- * @private
- */
- _closeNewChat: function () {
- this.chatSessions = _.without(this.chatSessions, this.newChatSession);
- this._repositionWindows({remove_new_chat: true});
- },
- /**
- * @private
- * @param {integer} nbWindows
- */
- _computeAvailableSlots: function (nbWindows) {
- if (config.device.isMobile) {
- this.displayState.nbSlots = 1; // one chat window full screen in mobile
- return;
- }
- var width = window.innerWidth;
- var nbSlots = Math.floor(width/CHAT_WINDOW_WIDTH);
- var spaceLeft = width - (Math.min(nbSlots, nbWindows)*CHAT_WINDOW_WIDTH);
- if (nbSlots < nbWindows && spaceLeft < 50) {
- nbSlots--; // leave at least 50px for the hidden windows dropdown button
- spaceLeft += CHAT_WINDOW_WIDTH;
- }
- this.displayState.nbSlots = nbSlots;
- this.displayState.spaceLeft = spaceLeft;
- },
- /**
- * @private
- */
- _destroyNewChat: function () {
- this.newChatSession.window.destroy();
- this.newChatSession = undefined;
- },
- /**
- * @private
- * @param {Object} session
- * @param {Widget} session.window
- */
- _makeSessionVisible: function (session) {
- utils.swap(this.chatSessions, session, this.chatSessions[this.displayState.nbSlots-1]);
- this._repositionWindows();
- session.window.toggle_fold(false);
- },
- /**
- * @private
- */
- _openChatWithoutSession: function () {
- var self = this;
- if (!this.newChatSession) {
- this.newChatSession = {
- id: '_open',
- window: new ExtendedChatWindow(web_client, undefined, _t('New message'), false, 0, {thread_less: true}),
- };
- this.newChatSession.window.on("close_chat_session", null, this._closeNewChat.bind(this));
- this.newChatSession.window.on('open_dm_session', null, function (partner_id) {
- self.newChatSession.partner_id = partner_id;
- var dm = self.call('chat_manager', 'getDmFromPartnerID', partner_id);
- if (!dm) {
- self.call('chat_manager', 'openAndDetachDm', partner_id);
- } else {
- var dmSession = _.findWhere(self.chatSessions, {id: dm.id});
- if (!dmSession) {
- self.call('chat_manager', 'detachChannel', dm.id);
- } else {
- self._closeChat(dmSession);
- dm.is_folded = false;
- self.openChat(dm);
- }
- }
- });
- this._addChatSession(this.newChatSession);
- this.newChatSession.window.appendTo($('body')).then(this._repositionWindows.bind(this));
- } else {
- if (this.newChatSession.window.is_hidden) {
- this._makeSessionVisible(this.newChatSession);
- } else if (this.newChatSession.window.folded) {
- this.newChatSession.window.toggle_fold(false);
- }
- }
- },
- /**
- * @private
- * @param {Object} message
- * @param {integer[]} message.channel_ids
- * @param {boolean} scrollBottom
- */
- _updateSessions: function (message, scrollBottom) {
- var self = this;
- _.each(this.chatSessions, function (session) {
- if (_.contains(message.channel_ids, session.id)) {
- var messageVisible = !self.displayState.chatWindowsHidden && !session.window.folded &&
- !session.window.is_hidden && session.window.thread.is_at_bottom();
- if (messageVisible && !session.keep_unread) {
- var channel = self.call('chat_manager', 'getChannel', session.id);
- self.call('chat_manager', 'markChannelAsSeen', channel);
- }
- self.call('chat_manager', 'getMessages', {channelID: session.id})
- .then(function (messages) {
- session.window.render(messages);
- if (scrollBottom && messageVisible) {
- session.window.thread.scroll_to();
- }
- });
- }
- });
- },
- /**
- * @private
- */
- _renderHiddenSessionsDropdown: function () {
- var $dropdown = $(QWeb.render("mail.ChatWindowsDropdown", {
- sessions: this.displayState.hiddenSessions,
- open: this.displayState.windowsDropdownIsOpen,
- unread_counter: this.displayState.hiddenUnreadCounter,
- widget: {isMobile: config.device.isMobile},
- }));
- return $dropdown;
- },
- /**
- * @private
- */
- _repositionHiddenSessionsDropdown: function () {
- // Unfold dropdown to the left if there is enough place
- var $dropdownUL = this.displayState.$hiddenWindowsDropdown.children('ul');
- if (this.displayState.spaceLeft > $dropdownUL.width() + 10) {
- $dropdownUL.addClass('dropdown-menu-right');
- }
- },
- /**
- * @private
- * @param {Object} [options]
- * @param {boolean} [options.remove_new_chat]
- */
- _repositionWindows: function (options) {
- var self = this;
- if (options && options.remove_new_chat) {
- this._destroyNewChat();
- }
- if (this.displayState.chatWindowsHidden) {
- return;
- }
- this._computeAvailableSlots(this.chatSessions.length);
- var hiddenSessions = [];
- var hiddenUnreadCounter = 0;
- var nbSlots = this.displayState.nbSlots;
- _.each(this.chatSessions, function (session, index) {
- if (index < nbSlots) {
- session.window.$el.css({right: CHAT_WINDOW_WIDTH*index, bottom: 0});
- session.window.do_show();
- } else {
- hiddenSessions.push(session);
- hiddenUnreadCounter += session.window.unread_msgs;
- session.window.do_hide();
- }
- });
- this.displayState.hiddenSessions = hiddenSessions;
- this.displayState.hiddenUnreadCounter = hiddenUnreadCounter;
-
- if (this.displayState.$hiddenWindowsDropdown) {
- this.displayState.$hiddenWindowsDropdown.remove();
- }
- if (hiddenSessions.length) {
- this.displayState.$hiddenWindowsDropdown = this._renderHiddenSessionsDropdown();
- var $hiddenWindowsDropdown = this.displayState.$hiddenWindowsDropdown;
- $hiddenWindowsDropdown.css({right: CHAT_WINDOW_WIDTH * nbSlots, bottom: 0})
- .appendTo($('body'));
- this._repositionHiddenSessionsDropdown();
- this.displayState.windowsDropdownIsOpen = false;
-
- $hiddenWindowsDropdown.on('click', '.o_chat_header', function (event) {
- var sessionID = $(event.currentTarget).data('session-id');
- var session = _.findWhere(hiddenSessions, {id: sessionID});
- if (session) {
- self._makeSessionVisible(session);
- }
- });
- $hiddenWindowsDropdown.on('click', '.o_chat_window_close', function (event) {
- var sessionID = $(event.currentTarget).closest('.o_chat_header').data('session-id');
- var session = _.findWhere(hiddenSessions, {id: sessionID});
- if (session) {
- session.window.on_click_close(event);
- self.displayState.windowsDropdownIsOpen = true; // keep the dropdown open
- }
- });
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {Object} channel
- * @param {integer|string} channel.id
- * @param {Object} query
- */
- _onAnyoneListening: function (channel, query) {
- _.each(this.chatSessions, function (session) {
- if (channel.id === session.id && session.window.thread.is_at_bottom() && !session.window.is_hidden) {
- query.is_displayed = true;
- }
- });
- },
- /**
- * @private
- * @param {Object} channel
- * @param {integer} channel.id
- */
- _onDetachChannel: function (channel) {
- var chatSession = _.findWhere(this.chatSessions, {id: channel.id});
- if (!chatSession || chatSession.window.folded) {
- this.call('chat_manager', 'detachChannel', channel.id);
- } else if (chatSession.window.is_hidden) {
- this._makeSessionVisible(chatSession);
- } else {
- chatSession.window.focus_input();
- }
- },
- /**
- * @private
- * @param {boolean} open
- */
- _onDiscussOpen: function (open) {
- this.displayState.chatWindowsHidden = open;
- if (open) {
- $('body').addClass('o_no_chat_window');
- } else {
- $('body').removeClass('o_no_chat_window');
- this._repositionWindows();
- }
- },
- /**
- * @private
- * @param {Object} message
- */
- _onNewMessage: function (message) {
- this._updateSessions(message, true);
- },
- /**
- * @private
- * @param {Object} channel
- * @param {integer} channel.unread_counter
- */
- _onUpdateChannelUnreadCounter: function (channel) {
- var self = this;
- this.displayState.hiddenUnreadCounter = 0;
- _.each(self.chatSessions, function (session) {
- if (channel.id === session.id) {
- session.window.update_unread(channel.unread_counter);
- if (channel.unread_counter === 0) {
- session.keep_unread = false;
- }
- }
- if (session.window.is_hidden) {
- self.displayState.hiddenUnreadCounter += session.window.unread_msgs;
- }
- });
- if (self.displayState.$hiddenWindowsDropdown) {
- self.displayState.$hiddenWindowsDropdown.html(self._renderHiddenSessionsDropdown().html());
- self._repositionHiddenSessionsDropdown();
- }
- },
- /**
- * @private
- * @param {Object} channel
- * @param {string|integer} channel.id
- * @param {string} channel.status e.g. 'offline'
- */
- _onUpdateDmPresence: function (channel) {
- _.each(this.chatSessions, function (session) {
- if (channel.id === session.id) {
- session.window.update_status(channel.status);
- }
- });
- },
- /**
- * @private
- * @param {Object} message
- */
- _onUpdateMessage: function (message) {
- this._updateSessions(message, false);
- },
- /**
- * @private
- * @param {string|integer} channelID
- */
- _onUnsubscribeFromChannel: function (channelID) {
- var self = this;
- _.each(self.chatSessions, function (session) {
- if (channelID === session.id) {
- self._closeChat(session);
- }
- });
- },
-
-});
-
-core.serviceRegistry.add('chat_window_manager', ChatWindowManager);
-
-return ChatWindowManager;
-
-});
diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js
new file mode 100644
index 00000000000..841c2c5cbfd
--- /dev/null
+++ b/addons/mail/static/src/js/services/mail_manager.js
@@ -0,0 +1,1246 @@
+odoo.define('mail.Manager', function (require) {
+"use strict";
+
+/**
+ * This service is responsible for anything related to mail in JS.
+ * In particular, it stores all threads and messages that have been fetched
+ * from the server.
+ *
+ * As this service does quite a lot of stuffs, it is split into many different
+ * files. For instance, the handling of notification in the longpoll is in
+ * mail_notification_manager.js. This file contains the core functionnality
+ * of the mail service.
+ *
+ * The main point of entry of this service is to call public methods by mean
+ * of a service call.
+ *
+ * @example
+ *
+ * To get all threads that have been fetched from the server:
+ *
+ * this.call('mail_service', 'getThreads');
+ *
+ * To get a particular thread with server ID 1:
+ *
+ * this.call('mail_service', 'getThread', 1);
+ */
+var AbstractService = require('web.AbstractService');
+
+var Channel = require('mail.model.Channel');
+var DM = require('mail.model.DM');
+var Mailbox = require('mail.model.Mailbox');
+var MailFailure = require('mail.model.MailFailure');
+var Message = require('mail.model.Message');
+var mailUtils = require('mail.utils');
+
+var Bus = require('web.Bus');
+var config = require('web.config');
+var core = require('web.core');
+var session = require('web.session');
+
+var _t = core._t;
+var _lt = core._lt;
+
+var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers
+
+var MailManager = AbstractService.extend({
+ name: 'mail_service',
+ dependencies: ['ajax', 'bus_service', 'local_storage'],
+ _ODOOBOT_ID: "ODOOBOT", // default authorID for transient messages
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._busBus = this.call('bus_service', 'getBus');
+ this._cannedResponses = [];
+ this._mailBus = new Bus(this);
+ this._commands = [];
+ this._discussMenuID = undefined;
+ this._discussOpen = false;
+ this._isModerator = false;
+ this._mailFailures = [];
+ // list of employees for chatter mentions
+ this._mentionPartnerSuggestions = [];
+ this._messages = [];
+ this._moderatedChannelIDs = [];
+ // # of message received when odoo is out of focus
+ this._outOfFocusUnreadMessageCounter = 0;
+ // partner_ids we have a pinned DM with
+ this._pinnedDmPartners = [];
+ // all threads, including channels, DM, mailboxes, document threads, ...
+ this._threads = [];
+
+ // listen on buses
+ this._mailBus
+ .on('discuss_open', this, this._onDiscussOpen)
+ .on('window_focus', this, this._onWindowFocus);
+
+ this._initializeFromServer();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a message
+ *
+ * @param {Object} data message data
+ * @param {integer} data.id
+ * @param {string} [data.moderation_status]
+ * @param {Object} [options]
+ * @param {Array} [options.domain]
+ * @param {boolean} [options.incrementUnread] whether we should increment
+ * the unread_counter of channel.
+ * @param {boolean} [options.silent] whether it should inform in the mailBus
+ * of the newly created message.
+ * @returns {mail.model.Message} message object
+ */
+ addMessage: function (data, options) {
+ options = options || {};
+ var message = this.getMessage(data.id);
+ if (!message) {
+ message = this._makeMessage(data);
+ // Keep the array ordered by ID when inserting the new message
+ var index = _.sortedIndex(this._messages, message, function (msg) {
+ return msg.getID();
+ });
+ this._messages.splice(index, 0, message);
+ this._addNewMessagePostprocessThread(message, options);
+ this._addMessageToThreads(message, []);
+ if (!options.silent) {
+ this._mailBus.trigger('new_message', message);
+ }
+ }
+ if (data.moderation_status === 'accepted') {
+ message.setModerationStatus('accepted', {
+ additionalThreadIDs: data.channel_ids
+ });
+ }
+ if (options.domain && options.domain !== []) {
+ this._addMessageToThreads(message, options.domain);
+ }
+ return message;
+ },
+ /**
+ * Creates a channel, can be either a true channel or a DM based on `type`
+ *
+ * @param {integer|string} name id of partner (in case of dm) or name
+ * @param {string} type ['dm', 'public', 'private']
+ * @returns {$.Promise<integer>} resolved with ID of the newly created
+ * channel
+ */
+ createChannel: function (name, type) {
+ if (type === 'dm') {
+ return this._createDM(name);
+ } else {
+ return this._createChannel(name, type);
+ }
+ },
+ /**
+ * Returns the list of canned responses
+ * A canned response is a pre-formatted text that is triggered with
+ * some keystrokes, such as with ':'.
+ *
+ * @returns {Array} array of Objects (mail.shortcode)
+ */
+ getCannedResponses: function () {
+ return this._cannedResponses;
+ },
+ /**
+ * Returns a channel corresponding to the given id.
+ *
+ * @param {integer} threadID
+ * @returns {mail.model.Channel|undefined} the channel, if it exists
+ */
+ getChannel: function (threadID) {
+ return _.find(this._threads, function (thread) {
+ return thread.isChannel() && (thread.getID() === threadID);
+ });
+ },
+ /**
+ * Returns a list of channels
+ *
+ * @returns {mail.model.Channel[]} list of channels
+ */
+ getChannels: function () {
+ return _.filter(this._threads, function (thread) {
+ return thread.isChannel();
+ });
+ },
+ /**
+ * Returns the content that will be shown in the mail navbar dropdown
+ *
+ * @param {mail.model.Channel[]} channels
+ * @returns {$.Promise<Object[]>} resolved with list of channel previews
+ */
+ getChannelPreviews: function (channels) {
+ var self = this;
+ return this._getChannelPreviews(channels).then(function (previews) {
+ var sortedPreviews = self._sortPreviews(previews);
+ return sortedPreviews;
+ });
+ },
+ /**
+ * @returns {web.Bus} the mail bus
+ */
+ getMailBus: function () {
+ return this._mailBus;
+ },
+ /**
+ * Returns the record id of ir.ui.menu for Discuss
+ *
+ * @returns {integer} record id
+ */
+ getDiscussMenuID: function () {
+ return this._discussMenuID;
+ },
+ /**
+ * Gets direct message channel
+ *
+ * @param {integer} partnerID
+ * @returns {Object|undefined} channel
+ */
+ getDmFromPartnerID: function (partnerID) {
+ return _.find(this._threads, function (thread) {
+ return thread.getType() === 'dm' &&
+ thread.getDirectPartnerID() === partnerID;
+ });
+ },
+ /**
+ * @param {string} mailboxID
+ * @returns {mail.model.Mailbox} the mailbox, if any
+ */
+ getMailbox: function (mailboxID) {
+ return _.find(this._threads, function (thread) {
+ return thread.getID() === 'mailbox_' + mailboxID;
+ });
+ },
+ /**
+ * Returns a list of mail failures
+ *
+ * @returns {mail.model.MailFailure[]} list of mail failures
+ */
+ getMailFailures: function () {
+ return this._mailFailures;
+ },
+ /**
+ * Get partners as mentions from a chatter
+ * Typically all employees as partner suggestions.
+ *
+ * @returns {Array<Object[]>}
+ */
+ getMentionPartnerSuggestions: function () {
+ return this._mentionPartnerSuggestions;
+ },
+ /**
+ * Gets message from its ID
+ *
+ * @param {integer} messageID
+ * @returns {mail.model.Message|undefined} the matched message (if any)
+ */
+ getMessage: function (messageID) {
+ return _.find(this._messages, function (message) {
+ return message.getID() === messageID;
+ });
+ },
+ /**
+ * Returns the previews to display in the systray's messaging menu.
+ *
+ * @param {string} [filter=undefined] specify 'chat' or 'channels' to only
+ * get previews for that type of threads
+ * @returns {$.Promise<Object[]>} resolved with list of objects that have a
+ * valid format for rendering a messaging menu preview items.
+ */
+ getSystrayPreviews: function (filter) {
+ var self = this;
+ var channelDef = this._getSystrayChannelPreviews(filter);
+ var inboxDef = this._getSystrayInboxPreviews(filter);
+ var failureDef = this._getSystrayMailFailurePreviews(filter);
+
+ return $.when(channelDef, inboxDef, failureDef)
+ .then(function (previewsChannel, previewsInbox, previewsFailure) {
+ // order: failures > inbox > channel, each group must be sorted
+ previewsChannel = self._sortPreviews(previewsChannel);
+ previewsInbox = self._sortPreviews(previewsInbox);
+ previewsFailure = self._sortPreviews(previewsFailure);
+ return _.union(previewsFailure, previewsInbox, previewsChannel);
+ });
+ },
+ /**
+ * Get the list of channel IDs where the current user is a moderator
+ *
+ * @returns {integer[]}
+ */
+ getModeratedChannelIDs: function () {
+ return this._moderatedChannelIDs || [];
+ },
+ /**
+ * Get the Odoobot ID, which is the default authorID for transient messages
+ *
+ * @returns {string}
+ */
+ getOdoobotID: function () {
+ return this._ODOOBOT_ID;
+ },
+ /**
+ * Returns thread matching provided ID, if any
+ *
+ * @param {string|integer} threadID
+ * @returns {mail.model.Thread|undefined} the thread, if it exists
+ */
+ getThread: function (threadID) {
+ return _.find(this._threads, function (thread) {
+ return thread.getID() === threadID;
+ });
+ },
+ /**
+ * Returns a list of threads
+ *
+ * @returns {mail.model.Thread[]} list of threads
+ */
+ getThreads: function () {
+ return this._threads;
+ },
+ /**
+ * States whether the current user is a moderator or not
+ *
+ * @returns {boolean}
+ */
+ isModerator: function () {
+ return this._isModerator;
+ },
+ /**
+ * States whether the mail manager is ready or not
+ * This is the case when it has fetched the initial state from the server,
+ * by means of the route 'mail/init_messaging'
+ *
+ * @returns {$.Promise}
+ */
+ isReady: function () {
+ return this._isReady;
+ },
+ /**
+ * Join an existing channel
+ * See @createChannel to join a new channel
+ *
+ * @param {integer} channelID
+ * @param {Object} [options] options to be passed on channel add
+ * @returns {$.Promise<integer>} resolved with channelID joined
+ */
+ joinChannel: function (channelID, options) {
+ var def;
+ var channel = this.getChannel(channelID);
+ if (channel) {
+ // channel already joined
+ def = $.when(channelID);
+ } else {
+ def = this._joinAndAddChannel(channelID, options);
+ }
+ return def;
+ },
+ /**
+ * Mark messages as read
+ *
+ * @param {Array} messageIDs list of messages IDs
+ * @returns {$.Promise} resolved when messages have been marked as read on
+ * the server.
+ */
+ markMessagesAsRead: function (messageIDs) {
+ var self = this;
+ var ids = _.filter(messageIDs, function (id) {
+ var message = self.getMessage(id);
+ // If too many messages, not all are fetched,
+ // and some might not be found
+ return !message || message.isNeedaction();
+ });
+ if (ids.length) {
+ return this._rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [ids],
+ });
+ } else {
+ return $.when();
+ }
+ },
+ /**
+ * @param {integer|string} threadID
+ */
+ openThread: function (threadID) {
+ this._openThreadInDiscuss(threadID);
+ },
+ /**
+ * Special redirection handling for given model and id
+ *
+ * If the model is res.partner, and there is a user associated with this
+ * partner which isn't the current user, open the DM with this user.
+ * Otherwhise, open the record's form view (if not current user's).
+ *
+ * @param {string} resModel model to open
+ * @param {integer} resID record to open
+ * @param {function} [dmRedirectionCallback] only used if 'res.partner',
+ * a function that has a threadID as input
+ */
+ redirect: function (resModel, resID, dmRedirectionCallback) {
+ if (resModel === 'res.partner') {
+ this._redirectPartner(resModel, resID, dmRedirectionCallback);
+ } else {
+ this._redirectDefault(resModel, resID);
+ }
+ },
+ /**
+ * Remove the message from all of its threads
+ *
+ * @param {mail.model.Message} message
+ */
+ removeMessageFromThreads: function (message) {
+ var self = this;
+ _.each(message.getThreadIDs(), function (threadID) {
+ var thread = self.getThread(threadID);
+ if (thread) {
+ thread.removeMessage(message.getID());
+ }
+ });
+ },
+ /**
+ * Search among prefetched partners, using the string 'searchVal'
+ *
+ * @param {string} searchVal
+ * @param {integer} limit max number of found partners in the response
+ * @returns {$.Promise<Object[]>} list of found partners (matching
+ * 'searchVal')
+ */
+ searchPartner: function (searchVal, limit) {
+ var def = $.Deferred();
+ var partners = this._searchPartnerPrefetch(searchVal, limit);
+
+ if (!partners.length) {
+ def = this._searchPartnerFetch(searchVal, limit);
+ } else {
+ def = $.when(partners);
+ }
+ return def.then(function (partners) {
+ var suggestions = _.map(partners, function (partner) {
+ return {
+ id: partner.id,
+ value: partner.name,
+ label: partner.name
+ };
+ });
+ return _.sortBy(suggestions, 'label');
+ });
+ },
+ /**
+ * Unstars all messages from all channels
+ *
+ * @returns {$.Promise}
+ */
+ unstarAll: function () {
+ return this._rpc({
+ model: 'mail.message',
+ method: 'unstar_all',
+ args: [[]]
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a channel and returns it.
+ * Simply returns the channel if already exists.
+ *
+ * @private
+ * @param {Object} data
+ * @param {integer} data.id id of channel
+ * @param {string|Object} data.name name of channel, e.g. 'general'
+ * @param {string} data.type type of channel, e.g. 'public'
+ * @param {string} [data.state] e.g. 'open', 'folded'
+ * @param {Object|integer} [options=undefined]
+ * @param {boolean} [options.silent=false]
+ * @returns {integer} the ID of the newly or already existing channel
+ */
+ _addChannel: function (data, options) {
+ options = typeof options === 'object' ? options : {};
+ var channel = this.getChannel(data.id);
+ if (!channel) {
+ channel = this._makeChannel(data, options);
+ if (channel.getType() === 'dm') {
+ this._pinnedDmPartners.push(channel.getDirectPartnerID());
+ this._busBus.update_option(
+ 'bus_presence_partner_ids',
+ this._pinnedDmPartners
+ );
+ }
+ this._threads.push(channel);
+ if (data.last_message) {
+ // channel_info in mobile, necessary for showing channel
+ // preview in mobile
+ this.addMessage(data.last_message);
+ }
+ this._sortThreads();
+ if (!options.silent) {
+ this._mailBus.trigger('new_channel', channel);
+ }
+ }
+ return channel.getID();
+ },
+ /**
+ * Add a new mailbox
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object} options
+ * @returns {mail.model.Mailbox}
+ */
+ _addMailbox: function (data, options) {
+ options = typeof options === 'object' ? options : {};
+ var mailbox = new Mailbox(this, data, options, this._commands);
+ this._threads.push(mailbox);
+ this._sortThreads();
+ return mailbox;
+ },
+ /**
+ * Stores a message to the cache `domain` of all of its threads
+ *
+ * @private
+ * @param {mail.model.Message} message
+ * @param {Array} domain
+ */
+ _addMessageToThreads: function (message, domain) {
+ var self = this;
+ _.each(message.getThreadIDs(), function (threadID) {
+ var thread = self.getThread(threadID);
+ if (thread) {
+ thread.addMessage(message, domain);
+ }
+ });
+ },
+ /**
+ * For newly added message, postprocess threads linked to this message
+ *
+ * @private
+ * @param {mail.model.Message} message
+ * @param {Object} options
+ * @param {Array} [options.domain]
+ * @param {boolean} [options.incrementUnread]
+ * @param {boolean} [options.showNotification]
+ */
+ _addNewMessagePostprocessThread: function (message, options) {
+ var self = this;
+ _.each(message.getThreadIDs(), function (threadID) {
+ var thread = self.getThread(threadID);
+ if (thread) {
+ if (
+ thread.getType() !== 'mailbox' &&
+ !message.isAuthor() &&
+ !message.isSystemNotification()
+ ) {
+ if (options.incrementUnread) {
+ thread.incrementUnreadCounter();
+ }
+ if (thread.isChat() && options.showNotification) {
+ if (!self._isDiscussOpen() && !config.device.isMobile) {
+ // automatically open thread window
+ // while keeping it unread
+ thread.detach({ passively: true });
+ }
+ var query = { isBottomVisible: false };
+ self._mailBus.trigger('is_thread_bottom_visible', thread, query);
+ if (!query.isBottomVisible) {
+ self._notifyIncomingMessage(message, query);
+ }
+ }
+ }
+ }
+ });
+ },
+ /**
+ * Create a Channel (other than a DM)
+ *
+ * @private
+ * @param {string} name
+ * @param {string} type
+ * @returns {$.Promise<integer>} ID of the created channel
+ */
+ _createChannel: function (name, type) {
+ var context = _.extend({ isMobile: config.device.isMobile }, session.user_context);
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_create',
+ args: [name, type],
+ kwargs: { context: context },
+ })
+ .then(this._addChannel.bind(this));
+ },
+ /**
+ * Create a Direct Messages Chat
+ *
+ * @private
+ * @param {string} name
+ * @returns {$.Promise<integer>} ID of the created channel
+ */
+ _createDM: function (name) {
+ var context = _.extend({ isMobile: config.device.isMobile }, session.user_context);
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_get',
+ args: [[name]],
+ kwargs: { context: context },
+ })
+ .then(this._addChannel.bind(this));
+ },
+ /**
+ * @private
+ * @param {integer[]} channelIDs
+ * @returns {$.Promise<Object[]>} resolved with list of channel preview
+ */
+ _fetchChannelPreviews: function (channelIDs) {
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_fetch_preview',
+ args: [channelIDs],
+ }, { shadow: true });
+ },
+ /**
+ * Get previews of the channels
+ *
+ * The preview of a channel is built from the channel information and its
+ * lastest message. A channel without a last message is incomplete, so it
+ * must fetch this message.
+ *
+ * Note that a channel could have no message at all, so it fetches for the
+ * last message only once per channel. This is correct to not fetch more
+ * than once in this case, because any later received message updates
+ * automatically the last message of a channel.
+ *
+ * @private
+ * @param {mail.model.Channel[]} channels
+ * @returns {$.Deferred<Object[]>} resolved with list of channel previews
+ */
+ _getChannelPreviews: function (channels) {
+ var self = this;
+
+ var unpreviewedChannels = _.reject(channels, function (channel) {
+ return channel.hasBeenPreviewed();
+ });
+ var fetchDef;
+ if (unpreviewedChannels.length) {
+ var ids = _.map(unpreviewedChannels, function (channel) {
+ return channel.getID();
+ });
+ fetchDef = this._fetchChannelPreviews(ids);
+ } else {
+ fetchDef = $.when();
+ }
+ return fetchDef.then(function (fetchedPreviews) {
+ // add fetched messages
+ _.each(fetchedPreviews, function (fetchedPreview) {
+ if (fetchedPreview.last_message) {
+ self.addMessage(fetchedPreview.last_message);
+ }
+ // mark the channel as previewed, so that we do not need to
+ // fetch preview again
+ var channel = self.getChannel(fetchedPreview.id);
+ channel.markAsPreviewed();
+ });
+ return _.map(channels, function (channel) {
+ return channel.getPreview();
+ });
+ });
+ },
+ /**
+ * Get the previews of the mail failures
+ * Mail failures of a same model are grouped together, so that there are few
+ * preview items on the messaging menu in the systray.
+ *
+ * To determine whether this is a model preview or a document preview review
+ * the documentID is omitted for a model preview, whereas it is set for a
+ * preview of a failure related to a document.
+ *
+ * @returns {$.Promise<Object[]>} resolved with previews of mail failures
+ */
+ _getMailFailurePreviews: function () {
+ // items = list of objects:
+ // item = {
+ // unreadCounter: {integer},
+ // failure: {mail.model.MailFailure},
+ // isSameDocument: {boolean}
+ // }
+ var items = [];
+ _.each(this._mailFailures, function (failure) {
+ var unreadCounter = 1;
+ var isSameDocument = true;
+ var sameModelItem = _.find(items, function (item) {
+ if (
+ item.failure.isLinkedToDocumentThread() &&
+ (item.failure.getDocumentModel() === failure.getDocumentModel())
+ ) {
+ isSameDocument = item.failure.getDocumentID() === failure.getDocumentID();
+ return true;
+ }
+ return false;
+ });
+
+ if (failure.isLinkedToDocumentThread() && sameModelItem) {
+ unreadCounter = sameModelItem.unreadCounter + 1;
+ isSameDocument = sameModelItem.isSameDocument && isSameDocument;
+ var index = _.findIndex(items, sameModelItem);
+ items[index] = {
+ unreadCounter: unreadCounter,
+ failure: failure,
+ isSameDocument: isSameDocument,
+ };
+ } else {
+ items.push({
+ unreadCounter: unreadCounter,
+ failure: failure,
+ isSameDocument: true,
+ });
+ }
+ });
+ return _.map(items, function (item) {
+ // return preview with correct unread counter
+ // also unset documentID if the grouped mail failures are from
+ // same model but different document
+ var preview = {};
+ _.extend(preview, item.failure.getPreview(), {
+ unreadCounter: item.unreadCounter,
+ });
+ if (!item.failure.isSameDocument) {
+ preview.documentID = undefined;
+ }
+ return preview;
+ });
+ },
+ /**
+ * @private
+ * @param {string|undefined} [filter] the filter to apply on channel
+ * previews in systray messaging menu
+ * @returns {$.Promise<Object[]>} resolved with object valid for template
+ * mail.Preview on channel previews, based on selected filter
+ */
+ _getSystrayChannelPreviews: function (filter) {
+ var filteredThreads = _.filter(this._threads, function (thread) {
+ if (!thread.isChannel()) {
+ return false;
+ }
+ if (filter === 'chat') {
+ return thread.isChat();
+ } else if (filter === 'channels') {
+ return !thread.isChat();
+ }
+ return true;
+ });
+ return this.getChannelPreviews(filteredThreads);
+ },
+ /**
+ * Get the previews of needaction messages, i.e. messages in Inbox mailbox,
+ * based on selected filter.
+ *
+ * FIXME: it will use the current fetched messages in inbox + perform
+ * another message fetch on inbox. In other words, it means that some thread
+ * previews may be missing in the systray messaging menu. For example:
+ *
+ * - Each message fetch on Inbox fetches at most 30 messages.
+ * - There are 31 messages on Inbox.
+ * - No message on Inbox has been fetched at the moment
+ * - the 1st message on Inbox is linked to a document, and no other
+ * message in Inbox is linked to this document
+ *
+ * When clicking on the messaging menu, it will fetch all messages of Inbox,
+ * except the 1st message, in order to show the previews. The preview of
+ * the document thread of the 1st message will not be shown in the message
+ * menu
+ *
+ * This is a limitation, which should be solved by making changes on the
+ * server logic (e.g. provide list of all document threads in inbox on
+ * the rpc mail/init_messaging).
+ *
+ * @private
+ * @param {string|undefined} [filter]
+ * @returns {$.Deferred<Object[]>} resolved with valid objects for template
+ * mail.Preview on inbox message previews
+ */
+ _getSystrayInboxPreviews: function (filter) {
+ // 'All' filter, show messages preview from inbox and mail failures
+ // inbox previews
+ if (filter === 'mailbox_inbox' || !filter) {
+ var inbox = this.getMailbox('inbox');
+ return inbox.getMessagePreviews();
+ } else {
+ return $.when([]);
+ }
+ },
+ /**
+ * Get the previews of mail failure, based on selected filter.
+ *
+ * @private
+ * @param {string|undefined} [filter]
+ */
+ _getSystrayMailFailurePreviews: function (filter) {
+ // mail failure previews
+ if (filter === 'mailbox_inbox' || !filter) {
+ return this._getMailFailurePreviews();
+ } else {
+ return $.when([]);
+ }
+ },
+ /**
+ * Initialize the canned responses from the server data
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} [data.shortcodes]
+ * @param {integer} data.shortcodes[i].id
+ * @param {string} data.shortcodes[i].source
+ * @param {string} data.shortcodes[i].substitution
+ */
+ _initializeCannedResponses: function (data) {
+ var self = this;
+ _.each(data.shortcodes, function (s) {
+ var cannedResponse = _.pick(s, ['id', 'source', 'substitution']);
+ self._cannedResponses.push(cannedResponse);
+ });
+ },
+ /**
+ * Initialize the channels from the server, including public, private, DM,
+ * livechat, etc.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object} [data.channel_slots] contains the data of channels to
+ * initialize, which are grouped by channel type by key of the object
+ * (e.g. list of public channel data are stored in 'channel_channel')
+ * @param {Object[]} [data.channel_slots[i] list of data of channel of type
+ * `i`
+ */
+ _initializeChannels: function (data) {
+ var self = this;
+ _.each(data.channel_slots, function (channels) {
+ _.each(channels, self._addChannel.bind(self));
+ });
+ },
+ /**
+ * Initialize commands from the server
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} data.commands list of command data from the server
+ */
+ _initializeCommands: function (data) {
+ this._commands = _.map(data.commands, function (command) {
+ return _.extend({ id: command.name }, command);
+ });
+ },
+ /**
+ * @private
+ * @returns {$.Promise}
+ */
+ _initializeFromServer: function () {
+ var self = this;
+ this._isReady = session.is_bound.then(function () {
+ var context = _.extend(
+ { isMobile: config.device.isMobile },
+ session.user_context
+ );
+ return self._rpc({
+ route: '/mail/init_messaging',
+ params: { context: context },
+ });
+ }).then(function (result) {
+ self._updateFromServer(result);
+ self._busBus.start_polling();
+ });
+ },
+ /**
+ * Initialize the mailboxes, namely 'Inbox', 'Starred',
+ * and 'Moderation Queue' if the user is a moderator of a channel
+ *
+ * @private
+ * @param {Object} data
+ * @param {boolean} [data.is_moderator=false] states whether the user is
+ * moderator of a channel
+ * @param {integer} [data.moderation_counter=0] states the mailbox counter
+ * to set to 'Moderation Queue'
+ * @param {integer} [data.needaction_inbox_counter=0] states the mailbox
+ * counter to set to 'Inbox'
+ * @param {integer} [data.starred_counter=0] states the mailbox counter to
+ * set to 'Starred'
+ */
+ _initializeMailboxes: function (data) {
+ this._addMailbox({
+ id: 'inbox',
+ name: _lt("Inbox"),
+ mailboxCounter: data.needaction_inbox_counter || 0,
+ });
+ this._addMailbox({
+ id: 'starred',
+ name: _lt("Starred"),
+ mailboxCounter: data.starred_counter || 0,
+ });
+
+ if (data.is_moderator) {
+ this._addMailbox({
+ id: 'moderation',
+ name: _lt("Moderate Messages"),
+ mailboxCounter: data.moderation_counter || 0,
+ });
+ }
+ },
+ /**
+ * Initialize mail failures from the server data
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} data.mail_failures data to initialize mail failures
+ * locally
+ */
+ _initializeMailFailures: function (data) {
+ var self = this;
+ this._mailFailures = _.map(data.mail_failures, function (mailFailureData) {
+ return new MailFailure(self, mailFailureData);
+ });
+ },
+ /**
+ * Initialize moderation settings from the server data
+ *
+ * @private
+ * @param {Object} data
+ * @param {boolean} [data.is_moderator=false]
+ * @param {integer[]} [data.moderation_channel_ids]
+ */
+ _initializeModerationSettings: function (data) {
+ this._moderatedChannelIDs = data.moderation_channel_ids;
+ this._isModerator = data.is_moderator;
+ },
+ /**
+ * State whether discuss app is open or not
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _isDiscussOpen: function () {
+ return this._discussOpen;
+ },
+ /**
+ * Join the channel, and add it locally afterwards
+ *
+ * @private
+ * @param {integer|string} channelID
+ * @param {Object} options options passed on channel add
+ * @returns {$.Promise<integer>} channelID of joined and added channel
+ */
+ _joinAndAddChannel: function (channelID, options) {
+ var self = this;
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_join_and_get_info',
+ args: [[channelID]],
+ })
+ .then(function (result) {
+ return self._addChannel(result, options);
+ });
+ },
+ /**
+ * Creates a new instance of Channel with the given data and options.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Array} [data.direct_partner] if set and is an non-empty array,
+ * the channel is a DM
+ * @param {Object} [options]
+ * @returns {mail.model.Channel}
+ */
+ _makeChannel: function (data, options) {
+ if (_.size(data.direct_partner) > 0) {
+ return new DM(this, data, options, this._commands);
+ }
+ return new Channel(this, data, options, this._commands);
+ },
+ /**
+ * Creates a new instance of Message with the given data.
+ *
+ * @private
+ * @param {Object} data
+ * @returns {mail.model.Message}
+ */
+ _makeMessage: function (data) {
+ return new Message(this, data);
+ },
+ /**
+ * shows a popup to notify a new received message.
+ * This will also rename the odoo tab browser if
+ * the user has no focus on it.
+ *
+ * @private
+ * @param {mail.model.Message} message message received
+ * @param {Object} options
+ * @param {boolean} options.isDisplayed
+ */
+ _notifyIncomingMessage: function (message, options) {
+ if (this._busBus.is_odoo_focused() && options.isDisplayed) {
+ // no need to notify
+ return;
+ }
+ var title = _t("New message");
+ if (message.hasAuthor()) {
+ title = _.escape(message.getAuthorName());
+ }
+ var content = mailUtils.parseAndTransform(message.getBody(), mailUtils.stripHTML)
+ .substr(0, PREVIEW_MSG_MAX_SIZE);
+
+ if (!this._busBus.is_odoo_focused()) {
+ this._outOfFocusUnreadMessageCounter++;
+ var tabTitle = _.str.sprintf(
+ _t("%d Messages"),
+ this._outOfFocusUnreadMessageCounter
+ );
+ this.trigger_up('set_title_part', {
+ part: '_chat',
+ title: tabTitle
+ });
+ }
+
+ this.call('bus_service', 'sendNotification', title, content);
+ },
+ /**
+ * Open the thread in the Discuss app
+ *
+ * @private
+ * @param {integer|string} threadID a valid threadID
+ */
+ _openThreadInDiscuss: function (threadID) {
+ var thread = this.getThread(threadID);
+ this._mailBus.trigger('open_thread_in_discuss', thread);
+ },
+ /**
+ * @private
+ * @param {string} resModel
+ * @param {integer} resID
+ */
+ _redirectDefault: function (resModel, resID) {
+ var self = this;
+ this._rpc({
+ model: resModel,
+ method: 'get_formview_id',
+ args: [[resID], session.user_context],
+ })
+ .then(function (viewID) {
+ self._redirectToDocument(resModel, resID, viewID);
+ });
+ },
+ /**
+ * @private
+ * @param {Object} message
+ * @param {Array} message.channel_ids list of integers and strings,
+ * where strings for static channels, e.g. 'mailbox_inbox'.
+ * @param {string} resModel
+ * @param {integer} resID
+ * @param {string} viewID
+ */
+ _redirectToDocument: function (resModel, resID, viewID) {
+ this.do_action({
+ type: 'ir.actions.act_window',
+ view_type: 'form',
+ view_mode: 'form',
+ res_model: resModel,
+ views: [[viewID || false, 'form']],
+ res_id: resID,
+ });
+ },
+ /**
+ * @private
+ * @param {string} resModel 'res.partner'
+ * @param {integer} resID
+ * @param {function} dmRedirection a function callback that has
+ * threadID as input
+ */
+ _redirectPartner: function (resModel, resID, dmRedirection) {
+ var self = this;
+ var domain = [['partner_id', '=', resID]];
+ this._rpc({
+ model: 'res.users',
+ method: 'search',
+ args: [domain],
+ })
+ .then(function (userIDs) {
+ if (
+ userIDs.length &&
+ userIDs[0] !== session.uid &&
+ dmRedirection
+ ) {
+ self.createChannel(resID, 'dm').then(dmRedirection);
+ } else {
+ self._redirectToDocument(resModel, resID);
+ }
+ });
+ },
+ /**
+ * Remove channel
+ *
+ * This is only called by the mail_notification_manager
+ *
+ * @private
+ * @param {mail.model.Channel} [channel]
+ */
+ _removeChannel: function (channel) {
+ if (!channel) { return; }
+ if (channel.getType() === 'dm') {
+ var index = this._pinnedDmPartners.indexOf(channel.getDirectPartnerID());
+ if (index > -1) {
+ this._pinnedDmPartners.splice(index, 1);
+ this._busBus.update_option(
+ 'bus_presence_partner_ids',
+ this._pinnedDmPartners
+ );
+ }
+ }
+ this._threads = _.without(this._threads, channel);
+ },
+ /**
+ * Removes a message from a thread.
+ *
+ * @private
+ * @param {string} threadID
+ * @param {mail.model.Message} message
+ */
+ _removeMessageFromThread: function (threadID, message) {
+ message.removeThread(threadID);
+ var thread = _.find(this._threads, function (thread) {
+ return thread.getID() === threadID;
+ });
+ thread.removeMessage(message.getID());
+ },
+ /**
+ * @private
+ */
+ _resetOutOfFocusUnreadMessageCounter: function () {
+ this._outOfFocusUnreadMessageCounter = 0;
+ },
+ /**
+ * Extend the research to all users
+ *
+ * @private
+ * @param {string} searchVal
+ * @param {integer} limit
+ * @returns {$.Promise<Object[]>} fetched partners matching 'searchVal'
+ */
+ _searchPartnerFetch: function (searchVal, limit) {
+ return this._rpc({
+ model: 'res.partner',
+ method: 'im_search',
+ args: [searchVal, limit || 20],
+ }, { shadow: true });
+ },
+ /**
+ * Search among prefetched partners
+ *
+ * @private
+ * @param {string} searchVal
+ * @param {integer} limit
+ * @returns {string[]} partner suggestions that match searchVal
+ * (max limit, exclude session partner)
+ */
+ _searchPartnerPrefetch: function (searchVal, limit) {
+ var values = [];
+ var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(searchVal)), 'i');
+ _.each(this._mentionPartnerSuggestions, function (partners) {
+ if (values.length < limit) {
+ values = values.concat(_.filter(partners, function (partner) {
+ return (session.partner_id !== partner.id) &&
+ searchRegexp.test(partner.name);
+ })).splice(0, limit);
+ }
+ });
+ return values;
+ },
+ /**
+ * Sort previews
+ *
+ * 1. unread,
+ * 2. chat,
+ * 3. date,
+ *
+ * @private
+ * @param {Object[]} previews
+ * @returns {Object[]} sorted list of previews
+ */
+ _sortPreviews: function (previews) {
+ var res = previews.sort(function (p1, p2) {
+ var unreadDiff = Math.min(1, p2.unreadCounter) - Math.min(1, p1.unreadCounter);
+ var isChatDiff = p2.isChat - p1.isChat;
+ var dateDiff = (!!p2.date - !!p1.date) ||
+ (
+ p2.date &&
+ p2.date.diff(p1.date)
+ );
+ return unreadDiff || isChatDiff || dateDiff;
+ });
+ return res;
+ },
+ /**
+ * Sort threads
+ *
+ * In case of mailboxes (Inbox, Starred), the name is translated
+ * thanks to _lt (lazy translate). In this case, channel.getName() is an
+ * object, not a string.
+ *
+ * @private
+ */
+ _sortThreads: function () {
+ this._threads = _.sortBy(this._threads, function (thread) {
+ var name = thread.getName();
+ return _.isString(name) ? name.toLowerCase() : '';
+ });
+ },
+ /**
+ * Update internal state from server data (mail/init_messaging rpc result)
+ *
+ * @private
+ * @param {Object} result data from server on mail/init_messaging rpc
+ * @param {Object[]} result.mention_partner_suggestions list of suggestions
+ * with all the employees
+ * @param {integer} result.menu_id the menu ID of discuss app
+ */
+ _updateFromServer: function (result) {
+ // commands are needed for channel instantiation
+ this._initializeCommands(result);
+ this._initializeChannels(result);
+ this._initializeModerationSettings(result);
+ this._initializeMailboxes(result);
+ this._initializeMailFailures(result);
+ this._initializeCannedResponses(result);
+
+ this._mentionPartnerSuggestions = result.mention_partner_suggestions;
+ this._discussMenuID = result.menu_id;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {boolean} open
+ */
+ _onDiscussOpen: function (open) {
+ this._discussOpen = open;
+ },
+ /**
+ * Reset out of focus unread message counter + tab title
+ *
+ * @private
+ */
+ _onWindowFocus: function () {
+ this._resetOutOfFocusUnreadMessageCounter();
+ this.trigger_up('set_title_part', { part: '_chat' });
+ },
+});
+
+return MailManager;
+
+});
diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js
new file mode 100644
index 00000000000..cd0ff9223a0
--- /dev/null
+++ b/addons/mail/static/src/js/services/mail_notification_manager.js
@@ -0,0 +1,484 @@
+odoo.define('mail.Manager.Notification', function (require) {
+"use strict";
+
+/**
+ * Mail Notification Manager
+ *
+ * This part of the mail manager is responsible for receiving notifications on
+ * the longpoll bus, which are data received from the server.
+ */
+var MailManager = require('mail.Manager');
+var MailFailure = require('mail.model.MailFailure');
+
+var core = require('web.core');
+
+var _t = core._t;
+
+MailManager.include({
+
+ start: function () {
+ this._super.apply(this, arguments);
+ this._busBus.on('notification', this, this._onNotification);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Remove channel notifications if there is an unsubscribe notification
+ * on this channel.
+ *
+ * @private
+ * @param {Object[]} notifications
+ * @returns {Object[]} notifications filtered of channel notifications
+ * matching unsubscribe notifs
+ */
+ _filterNotificationsOnUnsubscribe: function (notifications) {
+ var unsubscribedNotif = _.find(notifications, function (notif) {
+ return notif[1].info === 'unsubscribe';
+ });
+ if (unsubscribedNotif) {
+ notifications = _.reject(notifications, function (notif) {
+ return notif[0][1] === 'mail.channel' &&
+ notif[0][2] === unsubscribedNotif[1].id;
+ });
+ }
+ return notifications;
+ },
+ /**
+ * Called when a new or updated message is received on a channel
+ *
+ * @private
+ * @param {Object} messageData
+ * @param {Array} messageData.channel_ids list of integers and strings,
+ * where strings for static channels, e.g. 'mailbox_inbox'.
+ */
+ _handleChannelNotification: function (messageData) {
+ var self = this;
+ var def;
+ var channelAlreadyInCache = true;
+ if (messageData.channel_ids.length === 1) {
+ channelAlreadyInCache = !!this.getChannel(messageData.channel_ids[0]);
+ def = this.joinChannel(messageData.channel_ids[0], { autoswitch: false });
+ } else {
+ def = $.when();
+ }
+ def.then(function () {
+ // don't increment unread if channel wasn't in cache yet as
+ // its unread counter has just been fetched
+ self.addMessage(messageData, {
+ showNotification: true,
+ incrementUnread: channelAlreadyInCache
+ });
+ });
+ },
+ /**
+ * On message becoming a need action (pinned to inbox)
+ *
+ * @private
+ * @param {Object} messageData
+ * @param {integer[]} messageData.channel_ids
+ */
+ _handleNeedactionNotification: function (messageData) {
+ var self = this;
+ var inbox = this.getMailbox('inbox');
+ var message = this.addMessage(messageData, {
+ incrementUnread: true,
+ showNotification: true,
+ });
+ inbox.incrementMailboxCounter();
+ _.each(message.getThreadIDs(), function (threadID) {
+ var channel = self.getChannel(threadID);
+ if (channel) {
+ channel.incrementNeedactionCounter();
+ }
+ });
+ this._mailBus.trigger('update_needaction', inbox.getMailboxCounter());
+ },
+ /**
+ * Called when an activity record has been updated on the server
+ *
+ * @private
+ * @param {Object} data key, value to decide activity created or deleted
+ */
+ _handlePartnerActivityUpdateNotification: function (data) {
+ this._mailBus.trigger('activity_updated', data);
+ },
+ /**
+ * Called when a channel has been seen, and the server responses with the
+ * last message seen. Useful in order to track last message seen.
+ *
+ * @private
+ * @param {Object} data
+ * @param {integer} data.id ID of a channel
+ * @param {integer} [data.last_message_id] mandatory if 'id' refers to an
+ * existing channel.
+ */
+ _handlePartnerChannelSeenNotification: function (data) {
+ var channel = this.getChannel(data.id);
+ if (channel) {
+ channel.setLastSeenMessageID(data.last_message_id);
+ if (channel.hasUnreadMessages()) {
+ channel.resetUnreadCounter();
+ }
+ }
+ },
+ /**
+ * Called when receiving a channel state as a partner notification:
+ *
+ * - if it is a new channel, it means we have been invited to this channel
+ * - if it is an existing channel, it means the window state of the channel
+ * may have changed (as this state is stored server-side)
+ *
+ * @private
+ * @param {Object} channelData
+ * @param {integer} channelData.id
+ * @param {boolean} channelData.is_minimized
+ * @param {string} channelData.state
+ */
+ _handlePartnerChannelNotification: function (channelData) {
+ if (
+ (channelData.channel_type === 'channel') &&
+ (channelData.state === 'open')
+ ) {
+ // invited to a new channel
+ this._addChannel(channelData, { autoswitch: false });
+ if (
+ !channelData.is_minimized &&
+ channelData.info !== 'creation'
+ ) {
+ this.do_notify(
+ _t("Invitation"),
+ _t("You have been invited to: ") + channelData.name);
+ }
+ }
+ var channel = this.getChannel(channelData.id);
+ if (channel) {
+ channel.updateWindowState({
+ folded: channelData.state === 'folded' ? true : false,
+ detached: channelData.is_minimized,
+ });
+ }
+ },
+ /**
+ * Add or remove failure when receiving a failure update message
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} data.elements list of mail failure data
+ * @param {string} data.elements[].message_id ID of related message that
+ * has a mail failure.
+ * @param {Array} data.elements[].notifications list of notifications
+ * that is related to a mail failure.
+ * @param {string} data.elements[].notifications[0] sending state of a mail
+ * failure (e.g. 'exception').
+ */
+ _handlePartnerMailFailureNotification: function (datas) {
+ var self = this;
+ _.each(datas.elements, function (data) {
+ var isNewFailure = _.some(data.notifications, function (notif) {
+ return notif[0] === 'exception' || notif[0] === 'bounce';
+ });
+ var matchedFailure = _.find(self._mailFailures, function (failure) {
+ return failure.getMessageID() === data.message_id;
+ });
+ if (matchedFailure) {
+ var index = _.findIndex(self._mailFailures, matchedFailure);
+ if (isNewFailure) {
+ self._mailFailures[index] = new MailFailure(self, data);
+ } else {
+ self._mailFailures.splice(index, 1);
+ }
+ } else if (isNewFailure) {
+ self._mailFailures.push(new MailFailure(self, data));
+ }
+ var message = _.find(self._messages, function (msg) {
+ return msg.getID() === data.message_id;
+ });
+ if (message) {
+ if (isNewFailure) {
+ message.updateCustomerEmailStatus('exception');
+ } else {
+ message.updateCustomerEmailStatus('sent');
+ }
+ self._updateMessageNotificationStatus(data, message);
+ self._mailBus.trigger('update_message', message);
+ }
+ });
+ this._mailBus.trigger('update_needaction', this.needactionCounter);
+ },
+ /**
+ * Updates mailbox_inbox when a message has marked as read.
+ *
+ * @private
+ * @param {Object} data
+ * @param {integer[]} [data.channel_ids]
+ * @param {integer[]} [data.message_ids]
+ * @param {string} [data.type]
+ */
+ _handlePartnerMarkAsReadNotification: function (data) {
+ var self = this;
+ _.each(data.message_ids, function (messageID) {
+ var message = _.find(self._messages, function (msg) {
+ return msg.getID() === messageID;
+ });
+ if (message) {
+ self._removeMessageFromThread('mailbox_inbox', message);
+ self._mailBus.trigger('update_message', message, data.type);
+ }
+ });
+ if (data.channel_ids) {
+ _.each(data.channel_ids, function (channelID) {
+ var channel = self.getChannel(channelID);
+ if (channel) {
+ channel.decrementNeedactionCounter(data.message_ids.length, 0);
+ }
+ });
+ } else {
+ // if no channel_ids specified, this is a 'mark all read' in inbox
+ _.each(this._threads, function (thread) {
+ thread.resetNeedactionCounter();
+ });
+ }
+ var inbox = this.getMailbox('inbox');
+ inbox.decrementMailboxCounter(data.message_ids.length);
+ this._mailBus.trigger('update_needaction', inbox.getMailboxCounter());
+ },
+ /**
+ * On receiving a message made by the current user, on a moderated channel,
+ * which is pending moderation.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object} data.message server-side data of the message
+ */
+ _handlePartnerMessageAuthorNotification: function (data) {
+ this.addMessage(data.message);
+ },
+ /**
+ * Notification to delete several messages locally
+ * Useful when a pending moderation message has been rejected, so that
+ * this message should not be displayed anymore.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} [data.message_ids] IDs of messages to delete locally.
+ */
+ _handlePartnerMessageDeletionNotification: function (data) {
+ var self = this;
+ _.each(data.message_ids, function (messageID) {
+ var message = self.getMessage(messageID);
+ if (message) {
+ message.setModerationStatus('rejected');
+ }
+ });
+ },
+ /**
+ * On receiving a message pending moderation, and current user is moderator
+ * of such message.
+ *
+ * @param {Object} data notification data
+ * @param {Object} data.message data of message
+ */
+ _handlePartnerMessageModeratorNotification: function (data) {
+ this.addMessage(data.message);
+ this._mailBus.trigger('update_moderation_counter');
+ },
+ /**
+ * On receiving a notification that is specific to a user
+ *
+ * @private
+ * @param {Object} data structure depending on the type
+ * @param {integer} data.id
+ */
+ _handlePartnerNotification: function (data) {
+ if (data.info === 'unsubscribe') {
+ this._handlePartnerUnsubscribeNotification(data);
+ } else if (data.type === 'toggle_star') {
+ this._handlePartnerToggleStarNotification(data);
+ } else if (data.type === 'mark_as_read') {
+ this._handlePartnerMarkAsReadNotification(data);
+ } else if (data.type === 'moderator') {
+ this._handlePartnerMessageModeratorNotification(data);
+ } else if (data.type === 'author') {
+ this._handlePartnerMessageAuthorNotification(data);
+ } else if (data.type === 'deletion') {
+ this._handlePartnerMessageDeletionNotification(data);
+ } else if (data.info === 'channel_seen') {
+ this._handlePartnerChannelSeenNotification(data);
+ } else if (data.info === 'transient_message') {
+ this._handlePartnerTransientMessageNotification(data);
+ } else if (data.type === 'activity_updated') {
+ this._handlePartnerActivityUpdateNotification(data);
+ } else if (data.type === 'mail_failure') {
+ this._handlePartnerMailFailureNotification(data);
+ } else {
+ this._handlePartnerChannelNotification(data);
+ }
+ },
+ /**
+ * On toggling on or off the star status of one or several messages.
+ * As the information is stored server-side, the web client must adapt
+ * itself from server's data on the messages.
+ *
+ * @private
+ * @param {Object} data
+ * @param {integer[]} data.message_ids IDs of messages that have a change
+ * of their starred status.
+ * @param {boolean} data.starred states whether the messages with id in
+ * `data.message_ids` have become either starred or unstarred
+ */
+ _handlePartnerToggleStarNotification: function (data) {
+ var self = this;
+ var starred = this.getMailbox('starred');
+ _.each(data.message_ids, function (messageID) {
+ var message = _.find(self._messages, function (msg) {
+ return msg.getID() === messageID;
+ });
+ if (message) {
+ message.setStarred(data.starred);
+ if (!message.isStarred()) {
+ self._removeMessageFromThread('mailbox_starred', message);
+ } else {
+ self._addMessageToThreads(message, []);
+ var channelStarred = self.getMailbox('starred');
+ channelStarred.invalidateCaches();
+ }
+ self._mailBus.trigger('update_message', message);
+ }
+ });
+
+ if (data.starred) {
+ // increase starred counter if message is marked as star
+ starred.incrementMailboxCounter(data.message_ids.length);
+ } else {
+ // decrease starred counter if message is remove from starred
+ // if unstar_all then it will set to 0.
+ starred.decrementMailboxCounter(data.message_ids.length);
+ }
+
+ this._mailBus.trigger('update_starred', starred.getMailboxCounter());
+ },
+ /**
+ * On receiving a transient message, i.e. a message which does not come from
+ * a member of the channel. Usually a log message, such as one generated
+ * from a command with ('/').
+ *
+ * @private
+ * @param {Object} data
+ * @param {string} data.author_id
+ */
+ _handlePartnerTransientMessageNotification: function (data) {
+ var lastMessage = _.last(this._messages);
+ data.id = (lastMessage ? lastMessage.getID() : 0) + 0.01;
+ data.author_id = data.author_id || this.getOdoobotID();
+ this.addMessage(data);
+ },
+ /**
+ * On receiving a unsubscribe from channel notification, confirm
+ * unsubscription from channel and adapt screen accordingly.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object} data.id ID of the unsubscribed channel
+ */
+ _handlePartnerUnsubscribeNotification: function (data) {
+ var channel = this.getChannel(data.id);
+ if (channel) {
+ var message;
+ if (_.contains(['public', 'private'], channel.getType())) {
+ message = _.str.sprintf(
+ _t("You unsubscribed from <b>%s</b>."),
+ channel.getName()
+ );
+ } else {
+ message = _.str.sprintf(
+ _t("You unpinned your conversation with <b>%s</b>."),
+ channel.getName()
+ );
+ }
+ this._removeChannel(channel);
+ this._mailBus.trigger('unsubscribe_from_channel', data.id);
+ this.do_notify(_("Unsubscribed"), message);
+ }
+ },
+ /**
+ * On receiving an update on user status (e.g. becoming 'online', 'offline',
+ * 'idle', etc.).
+ *
+ * @private
+ * @param {Object} data partner infos
+ * @param {integer} data.id
+ * @param {string} data.im_status
+ */
+ _handlePresenceNotification: function (data) {
+ var dm = this.getDmFromPartnerID(data.id);
+ if (dm) {
+ dm.setStatus(data.im_status);
+ this._mailBus.trigger('update_dm_presence', dm);
+ }
+ },
+ /**
+ * Update the message notification status of message based on update_message
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} data.notifications
+ * @param {mail.model.Message} message
+ */
+ _updateMessageNotificationStatus: function (data, message) {
+ _.each(data.notifications, function (notif, id) {
+ var partnerName = notif[1];
+ var notifStatus = notif[0];
+ var res = _.find(message.getCustomerEmailData(), function (entry) {
+ return entry[0] === parseInt(id);
+ });
+ if (res) {
+ res[2] = notifStatus;
+ } else {
+ message.addCustomerEmailData([parseInt(id), partnerName, notifStatus]);
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Notification handlers
+ * Sometimes, the web client receives unsubscribe notification and an extra
+ * notification on that channel. This is then followed by an attempt to
+ * rejoin the channel that we just left. The next few lines remove the
+ * extra notification to prevent that situation to occur.
+ *
+ * @private
+ * @param {Array} notifs
+ */
+ _onNotification: function (notifs) {
+ var self = this;
+ notifs = this._filterNotificationsOnUnsubscribe(notifs);
+ _.each(notifs, function (notif) {
+ var model = notif[0][1];
+ if (model === 'ir.needaction') {
+ // new message in the inbox
+ self._handleNeedactionNotification(notif[1]);
+ } else if (model === 'mail.channel') {
+ // new message in a channel
+ self._handleChannelNotification(notif[1]);
+ } else if (model === 'res.partner') {
+ // channel joined/left, message marked as read/(un)starred,
+ // chat open/closed, moderation specific, etc.
+ self._handlePartnerNotification(notif[1]);
+ } else if (model === 'bus.presence') {
+ // update presence of users
+ self._handlePresenceNotification(notif[1]);
+ }
+ });
+ },
+});
+
+return MailManager;
+
+});
diff --git a/addons/mail/static/src/js/services/mail_service.js b/addons/mail/static/src/js/services/mail_service.js
new file mode 100644
index 00000000000..9815b23b6de
--- /dev/null
+++ b/addons/mail/static/src/js/services/mail_service.js
@@ -0,0 +1,15 @@
+odoo.define('mail.Service', function (require) {
+"use strict";
+
+var core = require('web.core');
+
+var MailManager = require('mail.Manager');
+
+require('mail.Manager.Notification');
+require('mail.Manager.Window');
+
+core.serviceRegistry.add('mail_service', MailManager);
+
+return MailManager;
+
+});
diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js
new file mode 100644
index 00000000000..c72d6418f08
--- /dev/null
+++ b/addons/mail/static/src/js/services/mail_window_manager.js
@@ -0,0 +1,684 @@
+odoo.define('mail.Manager.Window', function (require) {
+"use strict";
+
+var MailManager = require('mail.Manager');
+var ThreadWindow = require('mail.ThreadWindow');
+
+var config = require('web.config');
+var core = require('web.core');
+var utils = require('web.utils');
+
+var QWeb = core.qweb;
+
+var THREAD_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows
+
+/**
+ * Mail Window Manager
+ *
+ * This part of the mail manager is responsible for the management of thread
+ * windows.
+ */
+MailManager.include({
+
+ // tell where to append thread window
+ THREAD_WINDOW_APPENDTO: 'body',
+
+ start: function () {
+ this._super.apply(this, arguments);
+
+ this._availableSlotsForThreadWindows = 0;
+ this._hiddenThreadWindows = [];
+ // used to keep dropdown open when closing thread windows
+ this._keepHiddenThreadWindowsDropdownOpen = false;
+ this._spaceLeftForThreadWindows = 0;
+ this._threadWindows = [];
+ // jQuery element for the dropdown of hidden thread windows
+ // see _renderHiddenThreadWindowsDropdown
+ this._$hiddenThreadWindowsDropdown = null;
+
+ this._mailBus
+ .on('update_message', this, this._onUpdateMessage)
+ .on('new_message', this, this._onNewMessage)
+ .on('new_channel', this, this._onNewChannel)
+ .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible)
+ .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel)
+ .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter)
+ .on('update_dm_presence', this, this._onUpdateDmPresence);
+
+ core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Open the blank thread window (i.e. the thread window without any thread
+ * linked to it). Make it if there is no blank thread window yet.
+ */
+ openBlankThreadWindow: function () {
+ var blankThreadWindow = this._getBlankThreadWindow();
+ if (!blankThreadWindow) {
+ blankThreadWindow = new ThreadWindow(this, null);
+ this._addThreadWindow(blankThreadWindow);
+ blankThreadWindow.appendTo(this.THREAD_WINDOW_APPENDTO)
+ .then(this._repositionThreadWindows.bind(this));
+ } else {
+ if (blankThreadWindow.isHidden()) {
+ this._makeThreadWindowVisible(blankThreadWindow);
+ } else if (blankThreadWindow.isFolded()) {
+ blankThreadWindow.toggleFold(false);
+ }
+ }
+ },
+ /**
+ * Open a DM in a thread window. This is useful when selecting a DM in the
+ * blank thread window, so that it replaces it with the DM window.
+ *
+ * @param {integer} partnerID
+ */
+ openDmWindow: function (partnerID) {
+ var dm = this.getDmFromPartnerID(partnerID);
+ if (!dm) {
+ this._openAndDetachDm(partnerID);
+ } else {
+ this.openThreadWindow(dm.getID());
+ }
+ },
+ /**
+ * Open the thread window if discuss is not opened
+ *
+ * @override
+ * @param {integer|string} threadID
+ */
+ openThread: function (threadID) {
+ if (!this._isDiscussOpen()) {
+ var thread = this.getThread(threadID);
+ if (thread) {
+ thread.detach();
+ }
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * Open a thread in a thread window
+ *
+ * @param {integer} threadID a valid thread ID
+ * @param {Object} [options]
+ * @param {boolean} [options.passively] if set to true, open the thread
+ * window without focusing the input and marking messages as read if it
+ * is not open yet, and do nothing otherwise.
+ * @param {boolean} [options.keepFoldState=false] if set to true, keep the
+ * fold state of the thread
+ */
+ openThreadWindow: function (threadID, options) {
+ var self = this;
+ options = options || {};
+ // valid threadID, therefore no check
+ var thread = this.getThread(threadID);
+ var threadWindow = this._getThreadWindow(threadID);
+ if (!threadWindow) {
+ threadWindow = this._makeNewThreadWindow(thread, options);
+ this._placeNewThreadWindow(threadWindow, options.passively);
+
+ threadWindow.appendTo($(this.THREAD_WINDOW_APPENDTO))
+ .then(function () {
+ self._repositionThreadWindows();
+ return thread.getMessages();
+ }).then(function (messages) {
+ threadWindow.render(messages);
+ threadWindow.threadWidget.scrollToBottom();
+ // setTimeout to prevent to execute handler on first
+ // scrollTo, which is asynchronous
+ setTimeout(function () {
+ threadWindow.threadWidget.$el.on('scroll', null, _.debounce(function () {
+ if (
+ !threadWindow.isPassive() &&
+ threadWindow.threadWidget.isAtBottom()
+ ) {
+ thread.markAsRead();
+ }
+ }, 100));
+ }, 0);
+ if (options.passively) {
+ // mark first unread messages as seen when focusing the
+ // window, then on scroll to bottom as usual
+ threadWindow.$('.o_mail_thread, .o_thread_composer')
+ .one('click', function () {
+ thread.markAsRead();
+ });
+ } else if (
+ !self._areThreadWindowsHidden() &&
+ !thread.isFolded()
+ ) {
+ thread.markAsRead();
+ }
+ });
+ } else if (!options.passively) {
+ if (threadWindow.isHidden()) {
+ this._makeThreadWindowVisible(threadWindow);
+ }
+ }
+ threadWindow.updateVisualFoldState();
+ },
+ /**
+ * Called when a thread has its window state that has been changed, so its
+ * thread window view should be changed to match the model.
+ *
+ * @param {integer|string} threadID
+ * @param {Object} options option to be applied on opening thread window, if
+ * the thread is detached
+ */
+ updateThreadWindow: function (threadID, options) {
+ var thread = this.getThread(threadID);
+ if (thread) {
+ if (thread.isDetached()) {
+ _.extend(options, { keepFoldState: true });
+ this.openThreadWindow(threadID, options);
+ } else {
+ this._closeThreadWindow(threadID);
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add the thread window such that it will be the left-most visible window
+ *
+ * @private
+ * @param {mail.ThreadWindow} threadWindow
+ */
+ _addThreadWindow: function (threadWindow) {
+ this._computeAvailableSlotsForThreadWindows(this._threadWindows.length+1);
+ this._threadWindows.splice(this._availableSlotsForThreadWindows-1, 0, threadWindow);
+ },
+ /**
+ * States whether the thread windows are hidden or not.
+ * When discuss is open, the thread windows are hidden.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _areThreadWindowsHidden: function () {
+ return this._isDiscussOpen();
+ },
+ /**
+ * Close the thread window linked to the thread with ID `threadID`.
+ * If there is no window linked to this thread, do nothing.
+ *
+ * @private
+ * @param {integer|string} threadID
+ */
+ _closeThreadWindow: function (threadID) {
+ var threadWindow = _.find(this._threadWindows, function (threadWindow) {
+ return threadWindow.getID() === threadID;
+ });
+ if (threadWindow) {
+ this._threadWindows = _.without(this._threadWindows, threadWindow);
+ this._repositionThreadWindows();
+ threadWindow.destroy();
+ }
+ },
+ /**
+ * Compute the number of available slots to display thread windows on the
+ * screen. This is based on the width of the screen, and the width of a
+ * single thread window.
+ *
+ * The available slots attributes are updated as a consequence of this
+ * method call.
+ *
+ * @private
+ * @param {integer} nbWindows
+ */
+ _computeAvailableSlotsForThreadWindows: function (nbWindows) {
+ if (config.device.isMobile) {
+ // one thread window full screen in mobile
+ this._availableSlotsForThreadWindows = 1;
+ return;
+ }
+ var width = window.innerWidth;
+ var availableSlots = Math.floor(width/THREAD_WINDOW_WIDTH);
+ var spaceLeft = width - (Math.min(availableSlots, nbWindows)*THREAD_WINDOW_WIDTH);
+ if (availableSlots < nbWindows && spaceLeft < 50) {
+ // leave at least 50px for the hidden windows dropdown button
+ availableSlots--;
+ spaceLeft += THREAD_WINDOW_WIDTH;
+ }
+ this._availableSlotsForThreadWindows = availableSlots;
+ this._spaceLeftForThreadWindows = spaceLeft;
+ },
+ /**
+ * Get the blank thread window, which is the special thread window that has
+ * no thread linked to it.
+ *
+ * This is useful in case a DM window may replace the blank thread window,
+ * when we want to open a DM from the blank thread window.
+ *
+ * @private
+ * @returns {mail.ThreadWindow|undefined} the "blank thread" window,
+ * if it exists, otherwise undefined
+ */
+ _getBlankThreadWindow: function () {
+ return _.find(this._threadWindows, function (threadWindow) {
+ return threadWindow.getID() === '_blank';
+ });
+ },
+ /**
+ * Get thread window in the hidden windows matching ID `threadID`.
+ *
+ * Note: hidden windows are open windows that cannot be displayed
+ * due to having more thread windows open than available slots for thread
+ * windows on the screen. These thread windows are displayed in the hidden
+ * thread window dropdown menu.
+ *
+ * @private
+ * @param {integer|string} threadID
+ * @returns {mail.ThreadWindow|undefined} the hidden thread window,
+ * if exists
+ */
+ _getHiddenThreadWindow: function (threadID) {
+ return _.find(this._hiddenThreadWindows, function (threadWindow) {
+ return threadWindow.getID() === threadID;
+ });
+ },
+ /**
+ * Get thread window matching ID `threadID`
+ *
+ * @private
+ * @param {integer} threadID
+ * @returns {mail.ThreadWindow|undefined} the thread window, if exists
+ */
+ _getThreadWindow: function (threadID) {
+ return _.find(this._threadWindows, function (threadWindow) {
+ return threadWindow.getID() === threadID;
+ });
+ },
+ /**
+ * Make the hidden thread window dropdown menu, that is render it and set
+ * event listener on this dropdown menu DOM element.
+ *
+ * @private
+ */
+ _makeHiddenThreadWindowsDropdown: function () {
+ var self = this;
+ if (this._$hiddenThreadWindowsDropdown) {
+ this._$hiddenThreadWindowsDropdown.remove();
+ }
+ if (this._hiddenThreadWindows.length) {
+ this._$hiddenThreadWindowsDropdown = this._renderHiddenThreadWindowsDropdown();
+ var $hiddenWindowsDropdown = this._$hiddenThreadWindowsDropdown;
+ $hiddenWindowsDropdown.css({right: THREAD_WINDOW_WIDTH * this._availableSlotsForThreadWindows, bottom: 0 })
+ .appendTo(this.THREAD_WINDOW_APPENDTO);
+ this._repositionHiddenWindowsDropdown();
+ this._keepHiddenThreadWindowsDropdownOpen = false;
+
+ $hiddenWindowsDropdown
+ .on('click', '.o_thread_window_header', function (ev) {
+ var threadID = $(ev.currentTarget).data('thread-id');
+ var threadWindow = self._getHiddenThreadWindow(threadID);
+ if (threadWindow) {
+ self._makeThreadWindowVisible(threadWindow);
+ }
+ })
+ .on('click', '.o_thread_window_close', function (ev) {
+ var threadID = $(ev.currentTarget).closest('.o_thread_window_header')
+ .data('thread-id');
+ var threadWindow = self._getHiddenThreadWindow(threadID);
+ if (threadWindow) {
+ threadWindow.close();
+ // keep the dropdown open
+ self._keepHiddenThreadWindowsDropdownOpen = true;
+ }
+ });
+ }
+ },
+ /**
+ * Make a new thread window linked to a thread.
+ *
+ * @private
+ * @param {mail.model.Thread} thread
+ * @param {Object} options
+ * @param {boolean} [options.passively=false]
+ */
+ _makeNewThreadWindow: function (thread, options) {
+ return new ThreadWindow(this, thread, _.extend(options, {
+ autofocus: !options.passively,
+ }));
+ },
+ /**
+ * Make an open thread window fully visible on screen.
+ *
+ * This method assumes that the thread window is hidden (i.e. in the hidden
+ * dropdown menu). To make it visible, it swap the position of this thread
+ * window with the last thread window that is visible (i.e. the left-most
+ * visible thread window).
+ *
+ * @private
+ * @param {mail.ThreadWindow} threadWindow
+ */
+ _makeThreadWindowVisible: function (threadWindow) {
+ utils.swap(
+ this._threadWindows,
+ threadWindow,
+ this._threadWindows[this._availableSlotsForThreadWindows-1]
+ );
+ this._repositionThreadWindows();
+ threadWindow.toggleFold(false);
+ },
+ /**
+ * Open and detach the DM in a thread window.
+ *
+ * This method assumes that no such DM exists locally, so it is kind of a
+ * "create DM and open DM window" operation
+ *
+ * @private
+ * @param {integer} partnerID
+ * @returns {$.Promise<integer>} resolved with ID of the dm channel
+ */
+ _openAndDetachDm: function (partnerID) {
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_get_and_minimize',
+ args: [[partnerID]],
+ })
+ .then(this._addChannel.bind(this));
+ },
+ /**
+ * On opening a new thread window, place it with other thread windows:
+ *
+ * - if it has been open with the blank thread window, replace the blank
+ * thread window with this one
+ * - if it has been open passively, simply but it after all windows
+ * - otherwise, make it the left-most visible thread window
+ *
+ * @param {mail.ThreadWindow} threadWindow a thread window that is linked
+ * to a thread (this must not be the blank thread window)
+ * @param {boolean} [passively=false] if set, if the thread window does not
+ * replace the blank thread window, it is add at the tail of the list of
+ * thread windows, which might be put in the thread window hidden dropdown
+ * menu if there are not enough space on the screen.
+ */
+ _placeNewThreadWindow: function (threadWindow, passively) {
+ var thread = this.getThread(threadWindow.getID());
+ // replace the blank thread window?
+ // the thread window should be a DM
+ var blankThreadWindow = this._getBlankThreadWindow();
+ if (
+ blankThreadWindow &&
+ thread.getType() === 'dm' &&
+ thread.getDirectPartnerID() === blankThreadWindow.directPartnerID
+ ) {
+ // the window takes the place of the 'blank' thread window
+ var index = _.indexOf(this._threadWindows, blankThreadWindow);
+ this._threadWindows[index] = threadWindow;
+ blankThreadWindow.destroy();
+ } else if (passively) {
+ // simply insert the window to the left
+ this._threadWindows.push(threadWindow);
+ } else {
+ // add window such that it is visible
+ this._addThreadWindow(threadWindow);
+ }
+ },
+ /**
+ * Unfold dropdown to the left if there is enough space on the screen.
+ *
+ * @private
+ */
+ _repositionHiddenWindowsDropdown: function () {
+ var $dropdownUL = this._$hiddenThreadWindowsDropdown.children('ul');
+ if (this._spaceLeftForThreadWindows > $dropdownUL.width() + 10) {
+ $dropdownUL.addClass('dropdown-menu-right');
+ }
+ },
+ /**
+ * Load the template of the hidden thread window dropdown
+ *
+ * @private
+ * @returns {jQuery.Element}
+ */
+ _renderHiddenThreadWindowsDropdown: function () {
+ var $dropdown = $(QWeb.render('mail.HiddenThreadWindowsDropdown', {
+ threadWindows: this._hiddenThreadWindows,
+ open: this._keepHiddenThreadWindowsDropdownOpen,
+ unreadCounter: this._hiddenThreadWindowsUnreadCounter,
+ widget: {
+ isMobile: function () {
+ return config.device.isMobile;
+ },
+ },
+ }));
+ return $dropdown;
+ },
+ /**
+ * Reposition the thread windows that should be hidden on the screen.
+ * Thread windows that have an index equal or greater than `index` in the
+ * attribute `threadWindows` should be hidden. Those thread windows are put
+ * in the hidden thread window dropdown menu.
+ *
+ * @private
+ * @param {integer} startIndex the index of the first thread window to hide,
+ * in increasing order of the thread windows in the `threadWindows`
+ * attribute
+ */
+ _repositionHiddenThreadWindows: function (startIndex) {
+ var hiddenWindows = [];
+ var hiddenUnreadCounter = 0;
+ var index = startIndex;
+ while (index < this._threadWindows.length) {
+ var threadWindow = this._threadWindows[index];
+ hiddenWindows.push(threadWindow);
+ hiddenUnreadCounter += threadWindow.getUnreadCounter();
+ threadWindow.do_hide();
+ index++;
+ }
+ this._hiddenThreadWindows = hiddenWindows;
+ this._hiddenThreadWindowsUnreadCounter = hiddenUnreadCounter;
+
+ this._makeHiddenThreadWindowsDropdown();
+ },
+ /**
+ * Reposition the thread windows, based on the size of the screen:
+ *
+ * - display thread windows by increasing order of index in
+ * `_threadWindows` attribute, from right to left on the screen
+ * - if there is no enough space to show all windows at once, display
+ * a dropdown menu for hidden windows.
+ *
+ * This method should be called whenever there is a change of state of a
+ * thread in a window. Also, when this method is called, all the windows
+ * are visible and stacked in the top-left corner of the screen.
+ *
+ * @private
+ */
+ _repositionThreadWindows: function () {
+ if (this._areThreadWindowsHidden()) {
+ return;
+ }
+ this._computeAvailableSlotsForThreadWindows(this._threadWindows.length);
+ var availableSlots = this._availableSlotsForThreadWindows;
+
+ this._repositionVisibleThreadWindows(availableSlots-1);
+ this._repositionHiddenThreadWindows(availableSlots);
+ },
+ /**
+ * Reposition the thread windows that should be visible on the screen.
+ *
+ * @private
+ * @param {integer} count how many thread windows can should be visible,
+ * which are picked in the attribute `_threadWindows` in increasing index
+ * order of appearance in the array. constraint:
+ * 0 <= count < this._threadWindows.length
+ */
+ _repositionVisibleThreadWindows: function (count) {
+ var index = 0;
+ while (index < count && index < this._threadWindows.length) {
+ var threadWindow = this._threadWindows[index];
+ threadWindow.$el.css({ right: THREAD_WINDOW_WIDTH*index, bottom: 0 });
+ threadWindow.do_show();
+ index++;
+ }
+ },
+ /**
+ * Update thread windows state of threads that have `message`.
+ * This is either a new message or an updated message.
+ *
+ * @private
+ * @param {mail.model.Message} message
+ * @param {boolean} [scrollBottom=false] if set, thread windows with this
+ * message should scroll to the bottom if the message is visible
+ */
+ _updateThreadWindows: function (message, scrollBottom) {
+ var self = this;
+ _.each(this._threadWindows, function (threadWindow) {
+ if (_.contains(message.getThreadIDs(), threadWindow.getID())) {
+ var thread = self.getThread(threadWindow.getID());
+ var messageVisible = !self._areThreadWindowsHidden() &&
+ !threadWindow.isFolded() &&
+ !threadWindow.isHidden() &&
+ threadWindow.threadWidget.isAtBottom();
+ if (messageVisible && !threadWindow.isPassive()) {
+ thread.markAsRead();
+ }
+ thread.getMessages()
+ .then(function (messages) {
+ threadWindow.render(messages);
+ if (scrollBottom && messageVisible) {
+ threadWindow.threadWidget.scrollToBottom();
+ }
+ });
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {boolean} open
+ */
+ _onDiscussOpen: function (open) {
+ this._super.apply(this, arguments);
+
+ if (open) {
+ $(this.THREAD_WINDOW_APPENDTO).addClass('o_no_thread_window');
+ } else {
+ $(this.THREAD_WINDOW_APPENDTO).removeClass('o_no_thread_window');
+ this._repositionThreadWindows();
+ }
+ },
+ /**
+ * Called when someone asks window manager whether the bottom of `thread` is
+ * visible or not. An object `query` is provided in order to reponse on the
+ * key `isDisplayed`.
+ *
+ * @private
+ * @param {mail.model.Thread} thread
+ * @param {Object} query
+ * @param {boolean} query.isBottomVisible write on it
+ */
+ _onIsThreadBottomVisible: function (thread, query) {
+ _.each(this._threadWindows, function (threadWindow) {
+ if (
+ thread.getID() === threadWindow.getID() &&
+ threadWindow.threadWidget.isAtBottom() &&
+ !threadWindow.isHidden()
+ ) {
+ query.isBottomVisible = true;
+ }
+ });
+ },
+ /**
+ * Show or hide window of this channel when a new channel is added.
+ *
+ * @private
+ * @param {mail.model.Channel} channel
+ */
+ _onNewChannel: function (channel) {
+ if (channel.isDetached()) {
+ this.openThreadWindow(channel.getID(), { keepFoldState: true });
+ } else {
+ this._closeThreadWindow(channel.getID());
+ }
+ },
+ /**
+ * Update thread window containing this message when a new message is added.
+ *
+ * @private
+ * @param {Object} message
+ */
+ _onNewMessage: function (message) {
+ this._updateThreadWindows(message, true);
+ },
+ /**
+ * Close the thread window when unsusbscribe from a channel.
+ *
+ * @private
+ * @param {integer} channelID
+ */
+ _onUnsubscribeFromChannel: function (channelID) {
+ this._closeThreadWindow(channelID);
+ },
+ /**
+ * Called when a thread has its unread counter that has changed.
+ * The unread counter on the thread windows should be updated.
+ *
+ * @private
+ * @param {mail.model.Thread} thread
+ */
+ _onUpdateThreadUnreadCounter: function (thread) {
+ var self = this;
+ this._hiddenThreadWindowsUnreadCounter = 0;
+ _.each(this._threadWindows, function (threadWindow) {
+ if (thread.getID() === threadWindow.getID()) {
+ threadWindow.updateHeader();
+ if (thread.getUnreadCounter() === 0) {
+ threadWindow.removePassive();
+ }
+ }
+ if (threadWindow.isHidden()) {
+ self._hiddenThreadWindowsUnreadCounter += threadWindow.getUnreadCounter();
+ }
+ });
+ if (this._$hiddenThreadWindowsDropdown) {
+ this._$hiddenThreadWindowsDropdown.html(
+ this._renderHiddenThreadWindowsDropdown().html());
+ this._repositionHiddenWindowsDropdown();
+ }
+ },
+ /**
+ * Called when there is a change of the im status of the user linked to
+ * DMs. The header of the thread window should be updated accordingly,
+ * in order to display the correct new im status of this users.
+ *
+ * @private
+ * @param {mail.model.Thread} thread
+ */
+ _onUpdateDmPresence: function (thread) {
+ _.each(this._threadWindows, function (threadWindow) {
+ if (thread.getID() === threadWindow.getID()) {
+ threadWindow.updateHeader();
+ }
+ });
+ },
+ /**
+ * Called when a message has been updated.
+ *
+ * @private
+ * @param {Object} message
+ */
+ _onUpdateMessage: function (message) {
+ this._updateThreadWindows(message, false);
+ },
+
+});
+
+return MailManager;
+
+});
diff --git a/addons/mail/static/src/js/systray.js b/addons/mail/static/src/js/systray.js
deleted file mode 100644
index 18e9511cf25..00000000000
--- a/addons/mail/static/src/js/systray.js
+++ /dev/null
@@ -1,353 +0,0 @@
-odoo.define('mail.systray', function (require) {
-"use strict";
-
-var config = require('web.config');
-var core = require('web.core');
-var session = require('web.session');
-var SystrayMenu = require('web.SystrayMenu');
-var time = require('web.time');
-var Widget = require('web.Widget');
-var QWeb = core.qweb;
-
-/**
- * Menu item appended in the systray part of the navbar
- *
- * The menu item indicates the counter of needactions + unread messages in chat channels. When
- * clicking on it, it toggles a dropdown containing a preview of each pinned channels (except
- * static and mass mailing channels) with a quick link to open them in chat windows. It also
- * contains a direct link to the Inbox in Discuss.
- **/
-var MessagingMenu = Widget.extend({
- template:'mail.chat.MessagingMenu',
- events: {
- "click": "on_click",
- "click .o_filter_button": "on_click_filter_button",
- "click .o_new_message": "on_click_new_message",
- "click .o_mail_channel_preview": "_onClickChannel",
- "click .o_mail_channel_mark_read":"_onClickMarkRead",
- },
- init: function () {
- this._super.apply(this, arguments);
- this.isMobile = config.device.isMobile; // used by the template
- },
- start: function () {
- this.$filter_buttons = this.$('.o_filter_button');
- this.$channels_preview = this.$('.o_mail_navbar_dropdown_channels');
- this.filter = false;
- var self = this;
- this.call('chat_manager', 'isReady').then(function () {
- self.update_counter();
- var chatBus = self.call('chat_manager', 'getChatBus');
- chatBus.on('update_needaction', self, self.update_counter);
- chatBus.on('update_channel_unread_counter', self, self.update_counter);
- });
- return this._super();
- },
- is_open: function () {
- return this.$el.hasClass('open');
- },
- update_counter: function () {
- var messageFailures = this.call('chat_manager', 'getMessageFailures');
- var needactionCounter = this.call('chat_manager', 'getNeedactionCounter');
- var unreadConversationCounter = this.call('chat_manager', 'getUnreadConversationCounter');
- var counter = needactionCounter + unreadConversationCounter + messageFailures.length;
- this.$('.o_notification_counter').text(counter);
- this.$el.toggleClass('o_no_notification', !counter);
- if (this.is_open()) {
- this.update_channels_preview();
- }
- },
- update_channels_preview: function () {
- var self = this;
- // Display spinner while waiting for channels preview
- this.$channels_preview.html(QWeb.render('Spinner'));
- this.call('chat_manager', 'isReady').then(function () {
- var allChannels = self.call('chat_manager', 'getChannels');
- var channels = _.filter(allChannels, function (channel) {
- if (self.filter === 'chat') {
- return channel.is_chat;
- } else if (self.filter === 'channels') {
- return !channel.is_chat && channel.type !== 'static';
- } else {
- return channel.type !== 'static';
- }
- });
- var formatedFailures = [];
- var messageFailures = self.call('chat_manager', 'getMessageFailures');
- _.each(messageFailures, function (messageFailure) {
- var model = messageFailure.model;
- var existingFailure = _.findWhere(formatedFailures, { model: model });
- if (existingFailure) {
- if (existingFailure.res_id !== messageFailure.res_id) {
- existingFailure.res_id = null;
- existingFailure.record_name = messageFailure.model_name;//for display, will be used as subject
- }
- existingFailure.unread_counter ++;
- } else {
- messageFailure = _.clone(messageFailure);
- messageFailure.unread_counter = 1;
- messageFailure.id = "mail_failure";
- messageFailure.body = "An error occured sending an email";
- messageFailure.date = moment(time.str_to_datetime(messageFailure.last_message_date));
- messageFailure.displayed_author = "";
- formatedFailures.push(messageFailure);
- }
- });
- channels = _.union(channels, formatedFailures);
- self.call('chat_manager', 'getMessages', {channelID: 'channel_inbox'})
- .then(function (messages) {
- var res = [];
- _.each(messages, function (message) {
- message.unread_counter = 1;
- var duplicatedMessage = _.findWhere(res, {
- model: message.model,
- 'res_id': message.res_id
- });
- if (message.model && message.res_id && duplicatedMessage) {
- message.unread_counter = duplicatedMessage.unread_counter + 1;
- res[_.findIndex(res, duplicatedMessage)] = message;
- } else {
- res.push(message);
- }
- });
- if (self.filter === 'channel_inbox' || !self.filter) {
- channels = _.union(channels, res);
- }
- self.call('chat_manager', 'getChannelsPreview', channels)
- .then(self._render_channels_preview.bind(self));
- });
- });
- },
- _render_channels_preview: function (channels_preview) {
- this.$channels_preview.html(QWeb.render('mail.chat.ChannelsPreview', {
- channels: channels_preview,
- }));
- },
- on_click: function () {
- if (!this.is_open()) {
- this.update_channels_preview(); // we are opening the dropdown so update its content
- }
- },
- on_click_filter_button: function (event) {
- event.stopPropagation();
- this.$filter_buttons.removeClass('active');
- var $target = $(event.currentTarget);
- $target.addClass('active');
- this.filter = $target.data('filter');
- this.update_channels_preview();
- },
- on_click_new_message: function () {
- this.call('chat_window_manager', 'openChat');
- },
-
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
- /**
- * When a channel is clicked on, we want to open chat/channel window
- *
- * @private
- * @param {MouseEvent} event
- */
- _onClickChannel: function (event) {
- var self = this;
- var channelID = $(event.currentTarget).data('channel_id');
- if (channelID === 'channel_inbox' || channelID === 'mail_failure') {
- var resID = $(event.currentTarget).data('res_id');
- var resModel = $(event.currentTarget).data('res_model');
- if (resModel && resModel !== 'mail.channel' && resID) {
- this.do_action({
- type: 'ir.actions.act_window',
- res_model: resModel,
- views: [[false, 'form']],
- res_id: resID
- });
- } else if (resModel && resModel !== 'mail.channel' && !resID) {
- this.do_action({
- name: "Mail failures",
- type: 'ir.actions.act_window',
- view_mode: 'kanban,list,form',
- views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
- target: 'current',
- res_model: resModel,
- domain: [['message_has_error', '=', true]],
- });
- } else {
- var clientChatOptions = {clear_breadcrumbs: true};
- if (resModel && resModel === 'mail.channel' && resID) {
- clientChatOptions.active_id = resID;
- }
- this.do_action('mail.mail_channel_action_client_chat', clientChatOptions)
- .then(function () {
- self.trigger_up('hide_home_menu'); // we cannot 'go back to previous page' otherwise
- core.bus.trigger('change_menu_section', self.call('chat_manager', 'getDiscussMenuID'));
- });
- }
- } else {
- var channel = this.call('chat_manager', 'getChannel', channelID);
- if (channel) {
- this.call('chat_manager', 'openChannel', channel);
- }
- }
- },
- /**
- * When a channel Mark As Read button is clicked on, we want mark message as read
- *
- * @private
- * @param {MouseEvent} event
- */
- _onClickMarkRead: function (ev) {
- ev.stopPropagation();
- var channelID = $(ev.currentTarget).data('channel_id'),
- channel = this.call('chat_manager', 'getChannel', channelID);
- //Mark only static channel's messages as read and clear notification
- if (channelID === 'channel_inbox') {
- var resID = $(ev.currentTarget).data('res_id');
- var model = $(ev.currentTarget).data('model');
- var domain = [['model', '=', model], ['res_id', '=', resID]];
- this.call('chat_manager', 'markAllAsRead', channel, domain);
- } else {
- this.call('chat_manager', 'markChannelAsSeen', channel);
- }
- },
-});
-
-/**
- * Menu item appended in the systray part of the navbar, redirects to the next activities of all app
- */
-var ActivityMenu = Widget.extend({
- template:'mail.chat.ActivityMenu',
- events: {
- "click": "_onActivityMenuClick",
- "click .o_mail_channel_preview": "_onActivityFilterClick",
- },
- start: function () {
- this.$activities_preview = this.$('.o_mail_navbar_dropdown_channels');
- this.call('chat_manager', 'getChatBus').on("activity_updated", this, this._updateCounter);
- this.call('chat_manager', 'isReady').then(this._updateCounter.bind(this));
- this._updateActivityPreview();
- return this._super();
- },
- //--------------------------------------------------
- // Private
- //--------------------------------------------------
- /**
- * Make RPC and get current user's activity details
- * @private
- */
- _getActivityData: function () {
- var self = this;
-
- return self._rpc({
- model: 'res.users',
- method: 'systray_get_activities',
- args: [],
- kwargs: {context: session.user_context},
- }).then(function (data) {
- self.activities = data;
- self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0);
- self.$('.o_notification_counter').text(self.activityCounter);
- self.$el.toggleClass('o_no_notification', !self.activityCounter);
- });
- },
- /**
- * Get particular model view to redirect on click of activity scheduled on that model.
- * @private
- * @param {string} model
- */
- _getActivityModelViewID: function (model) {
- return this._rpc({
- model: model,
- method: 'get_activity_view_id'
- });
- },
- /**
- * Check wether activity systray dropdown is open or not
- * @private
- * @returns {boolean}
- */
- _isOpen: function () {
- return this.$el.hasClass('open');
- },
- /**
- * Update(render) activity system tray view on activity updation.
- * @private
- */
- _updateActivityPreview: function () {
- var self = this;
- self._getActivityData().then(function (){
- self.$activities_preview.html(QWeb.render('mail.chat.ActivityMenuPreview', {
- activities : self.activities
- }));
- });
- },
- /**
- * update counter based on activity status(created or Done)
- * @private
- * @param {Object} [data] key, value to decide activity created or deleted
- * @param {String} [data.type] notification type
- * @param {Boolean} [data.activity_deleted] when activity deleted
- * @param {Boolean} [data.activity_created] when activity created
- */
- _updateCounter: function (data) {
- if (data) {
- if (data.activity_created) {
- this.activityCounter ++;
- }
- if (data.activity_deleted && this.activityCounter > 0) {
- this.activityCounter --;
- }
- this.$('.o_notification_counter').text(this.activityCounter);
- this.$el.toggleClass('o_no_notification', !this.activityCounter);
- }
- },
- //------------------------------------------------------------
- // Handlers
- //-----------------------------------------------------------
- /**
- * Redirect to particular model view
- * @private
- * @param {MouseEvent} event
- */
- _onActivityFilterClick: function (event) {
- // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_channel_preview).
- var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data());
- var context = {};
- if (data.filter === 'my') {
- context['search_default_activities_overdue'] = 1;
- context['search_default_activities_today'] = 1;
- } else {
- context['search_default_activities_' + data.filter] = 1;
- }
- this.do_action({
- type: 'ir.actions.act_window',
- name: data.model_name,
- res_model: data.res_model,
- views: [[false, 'kanban'], [false, 'form']],
- search_view_id: [false],
- domain: [['activity_user_id', '=', session.uid]],
- context:context,
- });
- },
- /**
- * When menu clicked update activity preview if counter updated
- * @private
- * @param {MouseEvent} event
- */
- _onActivityMenuClick: function () {
- if (!this._isOpen()) {
- this._updateActivityPreview();
- }
- },
-});
-
-SystrayMenu.Items.push(MessagingMenu);
-SystrayMenu.Items.push(ActivityMenu);
-
-// to test activity and messaging menus in qunit test cases we need it
-return {
- ActivityMenu: ActivityMenu,
- MessagingMenu: MessagingMenu,
-};
-});
diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js
new file mode 100644
index 00000000000..d65bb7ff5db
--- /dev/null
+++ b/addons/mail/static/src/js/systray/systray_activity_menu.js
@@ -0,0 +1,150 @@
+odoo.define('mail.systray.ActivityMenu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var session = require('web.session');
+var SystrayMenu = require('web.SystrayMenu');
+var Widget = require('web.Widget');
+var QWeb = core.qweb;
+
+/**
+ * Menu item appended in the systray part of the navbar, redirects to the next
+ * activities of all app
+ */
+var ActivityMenu = Widget.extend({
+ name: 'activity_menu',
+ template:'mail.systray.ActivityMenu',
+ events: {
+ 'click': '_onActivityMenuClick',
+ 'click .o_mail_preview': '_onActivityFilterClick',
+ },
+ willStart: function () {
+ return $.when(this.call('mail_service', 'isReady'));
+ },
+ start: function () {
+ this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items');
+ this.call('mail_service', 'getMailBus').on('activity_updated', this, this._updateCounter);
+ this._updateCounter();
+ this._updateActivityPreview();
+ return this._super();
+ },
+ //--------------------------------------------------
+ // Private
+ //--------------------------------------------------
+ /**
+ * Make RPC and get current user's activity details
+ * @private
+ */
+ _getActivityData: function () {
+ var self = this;
+
+ return self._rpc({
+ model: 'res.users',
+ method: 'systray_get_activities',
+ args: [],
+ kwargs: {context: session.user_context},
+ }).then(function (data) {
+ self._activities = data;
+ self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0);
+ self.$('.o_notification_counter').text(self.activityCounter);
+ self.$el.toggleClass('o_no_notification', !self.activityCounter);
+ });
+ },
+ /**
+ * Get particular model view to redirect on click of activity scheduled on that model.
+ * @private
+ * @param {string} model
+ */
+ _getActivityModelViewID: function (model) {
+ return this._rpc({
+ model: model,
+ method: 'get_activity_view_id'
+ });
+ },
+ /**
+ * Check wether activity systray dropdown is open or not
+ * @private
+ * @returns {boolean}
+ */
+ _isOpen: function () {
+ return this.$el.hasClass('open');
+ },
+ /**
+ * Update(render) activity system tray view on activity updation.
+ * @private
+ */
+ _updateActivityPreview: function () {
+ var self = this;
+ self._getActivityData().then(function (){
+ self._$activitiesPreview.html(QWeb.render('mail.systray.ActivityMenu.Previews', {
+ activities : self._activities
+ }));
+ });
+ },
+ /**
+ * update counter based on activity status(created or Done)
+ * @private
+ * @param {Object} [data] key, value to decide activity created or deleted
+ * @param {String} [data.type] notification type
+ * @param {Boolean} [data.activity_deleted] when activity deleted
+ * @param {Boolean} [data.activity_created] when activity created
+ */
+ _updateCounter: function (data) {
+ if (data) {
+ if (data.activity_created) {
+ this.activityCounter ++;
+ }
+ if (data.activity_deleted && this.activityCounter > 0) {
+ this.activityCounter --;
+ }
+ this.$('.o_notification_counter').text(this.activityCounter);
+ this.$el.toggleClass('o_no_notification', !this.activityCounter);
+ }
+ },
+
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
+
+ /**
+ * Redirect to particular model view
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onActivityFilterClick: function (event) {
+ // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_preview).
+ var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data());
+ var context = {};
+ if (data.filter === 'my') {
+ context['search_default_activities_overdue'] = 1;
+ context['search_default_activities_today'] = 1;
+ } else {
+ context['search_default_activities_' + data.filter] = 1;
+ }
+ this.do_action({
+ type: 'ir.actions.act_window',
+ name: data.model_name,
+ res_model: data.res_model,
+ views: [[false, 'kanban'], [false, 'form']],
+ search_view_id: [false],
+ domain: [['activity_user_id', '=', session.uid]],
+ context:context,
+ });
+ },
+ /**
+ * When menu clicked update activity preview if counter updated
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onActivityMenuClick: function () {
+ if (!this._isOpen()) {
+ this._updateActivityPreview();
+ }
+ },
+});
+
+SystrayMenu.Items.push(ActivityMenu);
+
+return ActivityMenu;
+
+});
diff --git a/addons/mail/static/src/js/systray/systray_messaging_menu.js b/addons/mail/static/src/js/systray/systray_messaging_menu.js
new file mode 100644
index 00000000000..7f8a565b6c7
--- /dev/null
+++ b/addons/mail/static/src/js/systray/systray_messaging_menu.js
@@ -0,0 +1,329 @@
+odoo.define('mail.systray.MessagingMenu', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var SystrayMenu = require('web.SystrayMenu');
+var Widget = require('web.Widget');
+var QWeb = core.qweb;
+
+/**
+ * Menu item appended in the systray part of the navbar
+ *
+ * The menu item indicates the counter of needactions + unread messages in chat
+ * channels. When clicking on it, it toggles a dropdown containing a preview of
+ * each pinned channels (except mailbox and mass mailing channels) with a quick
+ * link to open them in chat windows. It also contains a direct link to the
+ * Inbox in Discuss.
+ **/
+var MessagingMenu = Widget.extend({
+ name: 'messaging_menu',
+ template:'mail.systray.MessagingMenu',
+ events: {
+ 'click': '_onClick',
+ 'click .o_mail_preview': '_onClickPreview',
+ 'click .o_filter_button': '_onClickFilterButton',
+ 'click .o_new_message': '_onClickNewMessage',
+ 'click .o_mail_preview_mark_as_read': '_onClickPreviewMarkAsRead',
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ return $.when(this._super.apply(this, arguments), this.call('mail_service', 'isReady'));
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._$filterButtons = this.$('.o_filter_button');
+ this._$previews = this.$('.o_mail_systray_dropdown_items');
+ this._filter = false;
+ this._updateCounter();
+ var mailBus = this.call('mail_service', 'getMailBus');
+ mailBus.on('update_needaction', this, this._updateCounter);
+ mailBus.on('new_channel', this, this._updateCounter);
+ mailBus.on('update_thread_unread_counter', this, this._updateCounter);
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * States whether the widget is in mobile mode or not.
+ * This is used by the template.
+ *
+ * @returns {boolean}
+ */
+ isMobile: function () {
+ return config.device.isMobile;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when clicking on a preview related to a mail failure
+ *
+ * @private
+ * @param {$.Element} $target DOM of preview element clicked
+ */
+ _clickMailFailurePreview: function ($target) {
+ var documentID = $target.data('document-id');
+ var documentModel = $target.data('document-model');
+ if (documentModel && documentID) {
+ this._openDocument(documentModel, documentID);
+ } else if (documentModel !== 'mail.channel') {
+ // preview of mail failures grouped to different document of same model
+ this.do_action({
+ name: "Mail failures",
+ type: 'ir.actions.act_window',
+ view_mode: 'kanban,list,form',
+ views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
+ target: 'current',
+ res_model: documentModel,
+ domain: [['message_has_error', '=', true]],
+ });
+ }
+ },
+ /**
+ * @private
+ * @return {boolean} whether the messaging menu is open or not.
+ */
+ _isOpen: function () {
+ return this.$el.hasClass('open');
+ },
+ /**
+ * Open discuss
+ *
+ * @private
+ * @param {integer} [channelID] if set, auto-select this channel when
+ * opening the discuss app.
+ */
+ _openDiscuss: function (channelID) {
+ var self = this;
+ var discussOptions = { clear_breadcrumbs: true };
+
+ if (channelID) {
+ discussOptions.active_id = channelID;
+ }
+
+ this.do_action('mail.mail_channel_action_client_chat', discussOptions)
+ .then(function () {
+ // we cannot 'go back to previous page' otherwise
+ self.trigger_up('hide_home_menu');
+ core.bus.trigger('change_menu_section',
+ self.call('mail_service', 'getDiscussMenuID'));
+ });
+ },
+ /**
+ * Open the document
+ *
+ * @private
+ * @param {string} documentModel the model of the document
+ * @param {integer} documentID
+ */
+ _openDocument: function (documentModel, documentID) {
+ if (documentModel === 'mail.channel') {
+ this._openDiscuss(documentID);
+ } else {
+ this.do_action({
+ type: 'ir.actions.act_window',
+ res_model: documentModel,
+ views: [[false, 'form']],
+ res_id: documentID
+ });
+ }
+ },
+ /**
+ * Render the list of conversation previews
+ *
+ * @private
+ * @param {Object} previews list of valid objects for preview rendering
+ * (see mail.Preview template)
+ */
+ _renderPreviews: function (previews) {
+ this._$previews.html(QWeb.render('mail.systray.MessagingMenu.Previews', {
+ previews: previews,
+ }));
+ },
+ /**
+ * Get and render list of previews, based on the selected filter
+ *
+ * preview shows the last message of a channel with inline format.
+ * There is a hack where filter "All" also shows preview of chatter
+ * messages (which are not channels).
+ *
+ * List of filters:
+ *
+ * 1. All
+ * - filter: undefined
+ * - previews: last messages of all non-mailbox channels, in addition
+ * to last messages of chatter (get from inbox)
+ *
+ * 2. Channel
+ * - filter: "Channels"
+ * - previews: last messages of all non-mailbox and non-DM channels
+ *
+ * 3. Chat
+ * - filter: "Chat"
+ * - previews: last messages of all DM channels
+ *
+ * @private
+ */
+ _updatePreviews: function () {
+ // Display spinner while waiting for conversations preview
+ this._$previews.html(QWeb.render('Spinner'));
+ this.call('mail_service', 'getSystrayPreviews', this._filter)
+ .then(this._renderPreviews.bind(this));
+ },
+ /**
+ * Update the counter on the systray messaging menu icon.
+ * The counter display the number of unread messages in channels (DM included), the number of
+ * messages in Inbox mailbox, and the number of mail failures.
+ * Also updates the previews if the messaging menu is open.
+ *
+ * Note that the number of unread messages in document thread are ignored, because they are
+ * already considered in the number of messages in Inbox with the current design.
+ * Also, some unread messages in channel can also be in inbox, so they are considered twice in
+ * the counter. This is intended, as the number of needaction messages in a channel are
+ * separately considered in the messaging menu from the unread messages, even though a message
+ * can be both unread and needaction (such a message increments the counter twice).
+ *
+ * The global counter of the messaging menu should match the counter next to each of the preview
+ * item when the messaging menu is open.
+ *
+ * @private
+ */
+ _updateCounter: function () {
+ var counter;
+
+ var channels = this.call('mail_service', 'getChannels');
+ var channelUnreadCounters = _.map(channels, function (channel) {
+ return channel.getUnreadCounter();
+ });
+ var unreadChannelCounter = _.reduce(channelUnreadCounters, function (c1, c2) {
+ return c1 + c2;
+ }, 0);
+ var inboxCounter = this.call('mail_service', 'getMailbox', 'inbox').getMailboxCounter();
+ var mailFailureCounter = this.call('mail_service', 'getMailFailures').length;
+
+ counter = unreadChannelCounter + inboxCounter + mailFailureCounter;
+ this.$('.o_notification_counter').text(counter);
+ this.$el.toggleClass('o_no_notification', !counter);
+ if (this._isOpen()) {
+ this._updatePreviews();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClick: function () {
+ if (!this._isOpen()) {
+ // we are opening the dropdown so update its content
+ this._updatePreviews();
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFilterButton: function (ev) {
+ ev.stopPropagation();
+ this._$filterButtons.removeClass('active');
+ var $target = $(ev.currentTarget);
+ $target.addClass('active');
+ this._filter = $target.data('filter');
+ this._updatePreviews();
+ },
+ /**
+ * @private
+ */
+ _onClickNewMessage: function () {
+ this.call('mail_service', 'openBlankThreadWindow');
+ },
+ /**
+ * When a preview is clicked on, we want to open the related object
+ * (thread, mail failure, etc.)
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickPreview: function (ev) {
+ var $target = $(ev.currentTarget);
+ var previewID = $target.data('preview-id');
+
+ if (previewID === 'mail_failure') {
+ this._clickMailFailurePreview($target);
+ } else if (previewID === 'mailbox_inbox') {
+ // inbox preview for non-document thread,
+ // e.g. needaction message of channel
+ var documentID = $target.data('document-id');
+ var documentModel = $target.data('document-model');
+ this._openDocument(documentModel, documentID);
+ } else {
+ // preview of thread
+ this.call('mail_service', 'openThread', previewID);
+ }
+ },
+ /**
+ * When a preview "Mark as read" button is clicked on, we want mark message
+ * as read
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClickPreviewMarkAsRead: function (ev) {
+ ev.stopPropagation();
+ var thread;
+ var $preview = $(ev.currentTarget).closest('.o_mail_preview');
+ var previewID = $preview.data('preview-id');
+ if (previewID === 'mailbox_inbox') {
+ var documentModel = $preview.data('document-model');
+ var documentID = $preview.data('document-id');
+ var inbox = this.call('mail_service', 'getMailbox', 'inbox');
+ var messages = inbox.getLocalMessages({
+ documentModel: documentModel,
+ documentID: documentID,
+ });
+ var messageIDs = _.map(messages, function (message) {
+ return message.getID();
+ });
+ this.call('mail_service', 'markMessagesAsRead', messageIDs);
+ } else if (previewID === 'mail_failure') {
+ // AKU: ignored for the moment, this should be fixed when task
+ // 1860054 is merged
+ } else {
+ // this is mark as read on a thread
+ thread = this.call('mail_service', 'getThread', previewID);
+ if (thread) {
+ thread.markAsRead();
+ }
+ }
+ },
+});
+
+// Systray menu items display order matches order in the list
+// lower index comes first, and display is from right to left.
+// For messagin menu, it should come before activity menu, if any
+// otherwise, it is the next systray item.
+var activityMenuIndex = _.findIndex(SystrayMenu.Items, function (SystrayMenuItem) {
+ return SystrayMenuItem.prototype.name === 'activity_menu';
+});
+if (activityMenuIndex > 0) {
+ SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu);
+} else {
+ SystrayMenu.Items.push(MessagingMenu);
+}
+
+return MessagingMenu;
+
+});
diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js
index 54a36e169e4..7d4833e81cd 100644
--- a/addons/mail/static/src/js/thread.js
+++ b/addons/mail/static/src/js/thread.js
@@ -1,9 +1,11 @@
-odoo.define('mail.ChatThread', function (require) {
+odoo.define('mail.widget.Thread', function (require) {
"use strict";
+var DocumentViewer = require('mail.DocumentViewer');
+var mailUtils = require('mail.utils');
+
var core = require('web.core');
var time = require('web.time');
-var DocumentViewer = require('mail.DocumentViewer');
var Widget = require('web.Widget');
var QWeb = core.qweb;
@@ -14,60 +16,31 @@ var ORDER = {
DESC: -1,
};
-var read_more = _t('read more');
-var read_less = _t('read less');
-
-function time_from_now(date) {
- if (moment().diff(date, 'seconds') < 45) {
- return _t("now");
- }
- return date.fromNow();
-}
+var READ_MORE = _t("read more");
+var READ_LESS = _t("read less");
-var Thread = Widget.extend({
+/**
+ * This is a generic widget to render a chat thread
+ */
+var ThreadWidget = Widget.extend({
className: 'o_mail_thread',
events: {
- "click a": "on_click_redirect",
- "click img": "on_click_redirect",
- "click strong": "on_click_redirect",
- "click .o_thread_show_more": "on_click_show_more",
- "click .o_attachment_download": "_onAttachmentDownload",
- "click .o_attachment_view": "_onAttachmentView",
- "click .o_thread_message_needaction": function (event) {
- var messageId = $(event.currentTarget).data('message-id');
- this.trigger("mark_as_read", messageId);
- },
- "click .o_thread_message_moderation": "_onClickMessageModeration",
- "change .moderation_checkbox": "_onChangeModerationCheckbox",
- "click .o_thread_message_star": function (event) {
- var messageId = $(event.currentTarget).data('message-id');
- this.trigger("toggle_star_status", messageId);
- },
- "click .o_thread_message_email_exception": "_onClickMailException",
- "click .o_thread_message_reply": function (event) {
- this.selected_id = $(event.currentTarget).data('message-id');
- this.$('.o_thread_message').removeClass('o_thread_selected_message');
- this.$('.o_thread_message[data-message-id="' + this.selected_id + '"]')
- .addClass('o_thread_selected_message');
- this.trigger('select_message', this.selected_id);
- event.stopPropagation();
- },
- "click .oe_mail_expand": function (event) {
- event.preventDefault();
- var $message = $(event.currentTarget).parents('.o_thread_message');
- $message.addClass('o_message_expanded');
- this.expanded_msg_ids.push($message.data('message-id'));
- },
- "click .o_thread_message": function (event) {
- $(event.currentTarget).toggleClass('o_thread_selected_message');
- },
- "click": function () {
- if (this.selected_id) {
- this.unselect();
- this.trigger('unselect_message');
- }
- },
+ 'click a': '_onClickRedirect',
+ 'click img': '_onClickRedirect',
+ 'click strong': '_onClickRedirect',
+ 'click .o_thread_show_more': '_onClickShowMore',
+ 'click .o_attachment_download': '_onAttachmentDownload',
+ 'click .o_attachment_view': '_onAttachmentView',
+ 'click .o_thread_message_needaction': '_onClickMessageNeedaction',
+ 'click .o_thread_message_star': '_onClickMessageStar',
+ 'click .o_thread_message_reply': '_onClickMessageReply',
+ 'click .oe_mail_expand': '_onClickMailExpand',
+ 'click .o_thread_message': '_onClickMessage',
+ 'click': '_onClick',
+ 'click .o_thread_message_email_exception': '_onClickEmailException',
+ 'click .o_thread_message_moderation': '_onClickMessageModeration',
+ 'change .moderation_checkbox': '_onChangeModerationCheckbox',
},
/**
@@ -77,253 +50,315 @@ var Thread = Widget.extend({
*/
init: function (parent, options) {
this._super.apply(this, arguments);
- this.enabledOptions = _.defaults(options || {}, {
- display_order: ORDER.ASC,
- display_needactions: true,
- display_stars: true,
- display_document_link: true,
- display_avatar: true,
- squash_close_messages: true,
- display_email_icon: true,
- display_reply_icon: false,
+ // options when the thread is enabled (e.g. can send message,
+ // interact on messages, etc.)
+ this._enabledOptions = _.defaults(options || {}, {
+ displayOrder: ORDER.ASC,
+ displayMarkAsRead: true,
+ displayStars: true,
+ displayDocumentLinks: true,
+ displayAvatars: true,
+ squashCloseMessages: true,
+ displayEmailIcons: true,
+ displayReplyIcons: false,
loadMoreOnScroll: false,
});
- this.disabledOptions = {
- display_order: this.enabledOptions.display_order,
- display_needactions: false,
- display_stars: false,
- display_document_link: false,
- display_avatar: this.enabledOptions.display_avatar,
- squash_close_messages: false,
- display_email_icon: false,
- display_reply_icon: false,
- loadMoreOnScroll: this.enabledOptions.loadMoreOnScroll,
+ // options when the thread is disabled
+ this._disabledOptions = {
+ displayOrder: this._enabledOptions.displayOrder,
+ displayMarkAsRead: false,
+ displayStars: false,
+ displayDocumentLinks: false,
+ displayAvatars: this._enabledOptions.displayAvatars,
+ squashCloseMessages: false,
+ displayEmailIcons: false,
+ displayReplyIcons: false,
+ loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll,
};
- this.expanded_msg_ids = [];
- this.selected_id = null;
+ this._selectedMessageID = null;
},
/**
- * @param {Object[]} messages
+ * @override
+ */
+ destroy: function () {
+ clearInterval(this._updateTimestampsInterval);
+ },
+
+ /**
+ * @param {mail.model.AbstractMessage[]} messages list of messages of the
+ * thread, ordered by increasing IDs (a higher ID means a more recent
+ * message).
* @param {Object} [options]
- * @param {integer} [options.display_order=ORDER.ASC]
- * @param {boolean} [options.display_load_more]
+ * @param {integer} [options.displayOrder=ORDER.ASC] order of displaying
+ * messages in the thread:
+ * - ORDER.ASC: last message is at the bottom of the thread
+ * - ORDER.DESC: last message is at the top of the thread
+ * @param {boolean} [options.displayLoadMore]
* @param {boolean} [options.isCreateMode]
- * @param {boolean} [options.squash_close_messages]
+ * @param {boolean} [options.squashCloseMessages]
*/
render: function (messages, options) {
var self = this;
- var msgs = _.map(messages, this._preprocessMessage.bind(this));
- var modeOptions = options.isCreateMode ? this.disabledOptions : this.enabledOptions;
- if (modeOptions.display_order === ORDER.DESC) {
- msgs.reverse();
+ // copy so that reverse do not alter order in the thread object
+ messages = _.clone(messages);
+
+ var modeOptions = options.isCreateMode ? this._disabledOptions :
+ this._enabledOptions;
+
+ // attachments ordered by messages order (increasing ID)
+ this.attachments = _.uniq(_.flatten(_.map(messages, function (message) {
+ return message.getAttachments();
+ })));
+
+ if (modeOptions.displayOrder === ORDER.DESC) {
+ messages.reverse();
}
- options = _.extend({}, modeOptions, options);
+ options = _.extend({}, modeOptions, options, {
+ selectedMessageID: this._selectedMessageID,
+ });
// Hide avatar and info of a message if that message and the previous
// one are both comments wrote by the same author at the same minute
// and in the same document (users can now post message in documents
// directly from a channel that follows it)
- var prev_msg;
- _.each(msgs, function (msg) {
- if (!prev_msg || (Math.abs(msg.date.diff(prev_msg.date)) > 60000) ||
- prev_msg.message_type !== 'comment' || msg.message_type !== 'comment' ||
- (prev_msg.author_id[0] !== msg.author_id[0]) || prev_msg.model !== msg.model ||
- prev_msg.res_id !== msg.res_id) {
- msg.display_author = true;
+ var prevMessage;
+ _.each(messages, function (message) {
+ if (
+ // is first message of thread
+ !prevMessage ||
+ // more than 1 min. elasped
+ (Math.abs(message.getDate().diff(prevMessage.getDate())) > 60000) ||
+ prevMessage.getType() !== 'comment' ||
+ message.getType() !== 'comment' ||
+ // from a different author
+ (prevMessage.getAuthorID() !== message.getAuthorID()) ||
+ (
+ // messages are linked to a document thread
+ (
+ prevMessage.isLinkedToDocumentThread() &&
+ message.isLinkedToDocumentThread()
+ ) &&
+ (
+ // are from different documents
+ prevMessage.getDocumentModel() !== message.getDocumentModel() ||
+ prevMessage.getDocumentID() !== message.getDocumentID()
+ )
+ )
+ ) {
+ message.displayAuthor(true);
} else {
- msg.display_author = !options.squash_close_messages;
- }
- if (msg.attachment_ids) {
- var sortedAttachments = _.partition(msg.attachment_ids, function (att) {
- return att.mimetype && att.mimetype.split('/')[0] === 'image';
- });
- msg.images = sortedAttachments[0];
- msg.others = sortedAttachments[1];
+ message.displayAuthor(!options.squashCloseMessages);
}
- prev_msg = msg;
+ prevMessage = message;
});
- this.$el.html(QWeb.render('mail.ChatThread', {
- messages: msgs,
+ this.$el.html(QWeb.render('mail.widget.Thread', {
+ messages: messages,
options: options,
ORDER: ORDER,
- date_format: time.getLangDatetimeFormat(),
+ dateFormat: time.getLangDatetimeFormat(),
}));
- this.attachments = _.uniq(_.flatten(_.map(messages, 'attachment_ids')));
-
- _.each(msgs, function (msg) {
- var $msg = self.$('.o_thread_message[data-message-id="'+ msg.id +'"]');
- $msg.find('.o_mail_timestamp').data('date', msg.date);
+ _.each(messages, function (message) {
+ var $message = self.$('.o_thread_message[data-message-id="'+ message.getID() +'"]');
+ $message.find('.o_mail_timestamp').data('date', message.getDate());
- self.insert_read_more($msg);
+ self._insertReadMore($message);
});
- if (!this.update_timestamps_interval) {
- this.update_timestamps_interval = setInterval(function () {
- self.update_timestamps();
+ if (!this._updateTimestampsInterval) {
+ this.updateTimestampsInterval = setInterval(function () {
+ self._updateTimestamps();
}, 1000*60);
}
},
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ getScrolltop: function () {
+ return this.$el.scrollTop();
+ },
+ /**
+ * State whether the bottom of the thread is visible or not,
+ * with a tolerance of 5 pixels
+ *
+ * @return {boolean}
+ */
+ isAtBottom: function () {
+ var fullHeight = this.el.scrollHeight;
+ var topHiddenHeight = this.$el.scrollTop();
+ var visibleHeight = this.$el.outerHeight();
+ var bottomHiddenHeight = fullHeight - topHiddenHeight - visibleHeight;
+ return bottomHiddenHeight < 5;
+ },
+ /**
+ * Removes a message and re-renders the thread
+ *
+ * @param {integer} [messageID] the id of the removed message
+ * @param {mail.model.AbstractMessage[]} [messages] the list of messages to
+ * display, without the removed one
+ * @param {Object} [options] options for the thread rendering
+ */
+ removeMessageAndRender: function (messageID, messages, options) {
+ var self = this;
+ var done = $.Deferred();
+ this.$('.o_thread_message[data-message-id="' + messageID + '"]')
+ .fadeOut({
+ done: function () {
+ self.render(messages, options);
+ done.resolve();
+ },
+ duration: 200,
+ });
+ return done;
+ },
+ /**
+ * Scroll to the bottom of the thread
+ */
+ scrollToBottom: function () {
+ this.$el.scrollTop(this.el.scrollHeight);
+ },
+ /**
+ * Scrolls the thread to a given message
+ *
+ * @param {integer} options.messageID the ID of the message to scroll to
+ * @param {integer} [options.duration]
+ * @param {boolean} [options.onlyIfNecessary]
+ */
+ scrollToMessage: function (options) {
+ var $target = this.$('.o_thread_message[data-message-id="' + options.messageID + '"]');
+ if (options.onlyIfNecessary) {
+ var delta = $target.parent().height() - $target.height();
+ var offset = delta < 0 ?
+ 0 :
+ delta - ($target.offset().top - $target.offsetParent().offset().top);
+ offset = - Math.min(offset, 0);
+ this.$el.scrollTo("+=" + offset + "px", options.duration);
+ } else if ($target.length) {
+ this.$el.scrollTo($target);
+ }
+ },
+ /**
+ * Scroll to the specific position in pixel
+ *
+ * If no position is provided, scroll to the bottom of the thread
+ *
+ * @param {integer} [position] distance from top to position in pixels.
+ * If not provided, scroll to the bottom.
+ */
+ scrollToPosition: function (position) {
+ if (position) {
+ this.$el.scrollTop(position);
+ } else {
+ this.scrollToBottom();
+ }
+ },
+ /**
+ * Toggle all the moderation checkboxes in the thread
+ *
+ * @param {boolean} checked if true, check the boxes,
+ * otherwise uncheck them.
+ */
+ toggleModerationCheckboxes: function (checked) {
+ this.$('.moderation_checkbox').prop('checked', checked);
+ },
+ /**
+ * Unselect the selected message
+ */
+ unselectMessage: function () {
+ this.$('.o_thread_message').removeClass('o_thread_selected_message');
+ this._selectedMessageID = null;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
/**
* Modifies $element to add the 'read more/read less' functionality
- * All element nodes with "data-o-mail-quote" attribute are concerned.
+ * All element nodes with 'data-o-mail-quote' attribute are concerned.
* All text nodes after a ``#stopSpelling`` element are concerned.
* Those text nodes need to be wrapped in a span (toggle functionality).
* All consecutive elements are joined in one 'read more/read less'.
+ *
+ * @private
+ * @param {jQuery} $element
*/
- insert_read_more: function ($element) {
+ _insertReadMore: function ($element) {
var self = this;
var groups = [];
- var read_more_nodes;
+ var readMoreNodes;
// nodeType 1: element_node
// nodeType 3: text_node
var $children = $element.contents()
.filter(function () {
- return this.nodeType === 1 || this.nodeType === 3 && this.nodeValue.trim();
+ return this.nodeType === 1 ||
+ this.nodeType === 3 &&
+ this.nodeValue.trim();
});
_.each($children, function (child) {
var $child = $(child);
// Hide Text nodes if "stopSpelling"
- if (child.nodeType === 3 && $child.prevAll("[id*='stopSpelling']").length > 0) {
+ if (
+ child.nodeType === 3 &&
+ $child.prevAll('[id*="stopSpelling"]').length > 0
+ ) {
// Convert Text nodes to Element nodes
$child = $('<span>', {
text: child.textContent,
- "data-o-mail-quote": "1",
+ 'data-o-mail-quote': '1',
});
child.parentNode.replaceChild($child[0], child);
}
- // Create array for each "read more" with nodes to toggle
- if ($child.attr('data-o-mail-quote') || ($child.get(0).nodeName === 'BR' && $child.prev("[data-o-mail-quote='1']").length > 0)) {
- if (!read_more_nodes) {
- read_more_nodes = [];
- groups.push(read_more_nodes);
+ // Create array for each 'read more' with nodes to toggle
+ if (
+ $child.attr('data-o-mail-quote') ||
+ (
+ $child.get(0).nodeName === 'BR' &&
+ $child.prev('[data-o-mail-quote="1"]').length > 0
+ )
+ ) {
+ if (!readMoreNodes) {
+ readMoreNodes = [];
+ groups.push(readMoreNodes);
}
$child.hide();
- read_more_nodes.push($child);
+ readMoreNodes.push($child);
} else {
- read_more_nodes = undefined;
- self.insert_read_more($child);
+ readMoreNodes = undefined;
+ self._insertReadMore($child);
}
});
_.each(groups, function (group) {
// Insert link just before the first node
- var $read_more = $('<a>', {
- class: "o_mail_read_more",
- href: "#",
- text: read_more,
+ var $readMore = $('<a>', {
+ class: 'o_mail_read_more',
+ href: '#',
+ text: READ_MORE,
}).insertBefore(group[0]);
// Toggle All next nodes
- var is_read_more = true;
- $read_more.click(function (e) {
+ var isReadMore = true;
+ $readMore.click(function (e) {
e.preventDefault();
- is_read_more = !is_read_more;
+ isReadMore = !isReadMore;
_.each(group, function ($child) {
$child.hide();
- $child.toggle(!is_read_more);
+ $child.toggle(!isReadMore);
});
- $read_more.text(is_read_more ? read_more : read_less);
+ $readMore.text(isReadMore ? READ_MORE : READ_LESS);
});
});
},
- update_timestamps: function () {
- var isAtBottom = this.is_at_bottom();
- this.$('.o_mail_timestamp').each(function () {
- var date = $(this).data('date');
- $(this).html(time_from_now(date));
- });
- if (isAtBottom && !this.is_at_bottom()) {
- this.scroll_to();
- }
- },
- on_click_redirect: function (event) {
- // ignore inherited branding
- if ($(event.target).data('oe-field') !== undefined) {
- return;
- }
- var id = $(event.target).data('oe-id');
- if (id) {
- event.preventDefault();
- var model = $(event.target).data('oe-model');
- var options = model && (model !== 'mail.channel') ? {model: model, id: id} : {channel_id: id};
- this._redirect(options);
- }
- },
-
- on_click_show_more: function () {
- this.trigger('load_more_messages');
- },
-
- /**
- * Removes a message and re-renders the thread
- * @param {int} [message_id] the id of the removed message
- * @param {array} [messages] the list of messages to display, without the removed one
- * @param {object} [options] options for the thread rendering
- */
- remove_message_and_render: function (message_id, messages, options) {
- var self = this;
- var done = $.Deferred();
- this.$('.o_thread_message[data-message-id="' + message_id + '"]').fadeOut({
- done: function () { self.render(messages, options); done.resolve();},
- duration: 200,
- });
- return done;
- },
-
- /**
- * Scrolls the thread to a given message or offset if any, to bottom otherwise
- * @param {int} [options.id] optional: the id of the message to scroll to
- * @param {int} [options.offset] optional: the number of pixels to scroll
- */
- scroll_to: function (options) {
- options = options || {};
- if (options.id !== undefined) {
- var $target = this.$('.o_thread_message[data-message-id="' + options.id + '"]');
- if (options.only_if_necessary) {
- var delta = $target.parent().height() - $target.height();
- var offset = delta < 0 ? 0 : delta - ($target.offset().top - $target.offsetParent().offset().top);
- offset = - Math.min(offset, 0);
- this.$el.scrollTo("+=" + offset + "px", options);
- } else if ($target.length) {
- this.$el.scrollTo($target);
- }
- } else if (options.offset !== undefined) {
- this.$el.scrollTop(options.offset);
- } else {
- this.$el.scrollTop(this.el.scrollHeight);
- }
- },
- get_scrolltop: function () {
- return this.$el.scrollTop();
- },
- is_at_bottom: function () {
- return this.el.scrollHeight - this.$el.scrollTop() - this.$el.outerHeight() < 5;
- },
- unselect: function () {
- this.$('.o_thread_message').removeClass('o_thread_selected_message');
- this.selected_id = null;
- },
- destroy: function () {
- clearInterval(this.update_timestamps_interval);
- },
- /**
- * Toggle all the moderation checkboxes in the thread
- *
- * @param {boolean} checked if true, check the boxes,
- * otherwise uncheck them.
- */
- toggleModerationCheckboxes: function (checked) {
- this.$('.moderation_checkbox').prop('checked', checked);
- },
//--------------------------------------------------------------------------
// Private
@@ -331,49 +366,31 @@ var Thread = Widget.extend({
/**
* @private
- * @param {Object} message
- * @param {integer} [message.id]
- * @param {string} [message.model]
- * @param {string} [message.subject]
- * @param {string} [message.message_type]
- */
- _preprocessMessage: function (message) {
- var msg = _.extend({}, message);
-
- msg.date = moment.min(msg.date, moment());
- msg.hour = time_from_now(msg.date);
-
- var date = msg.date.format('YYYY-MM-DD');
- if (date === moment().format('YYYY-MM-DD')) {
- msg.day = _t("Today");
- } else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) {
- msg.day = _t("Yesterday");
- } else {
- msg.day = msg.date.format('LL');
- }
-
- if (_.contains(this.expanded_msg_ids, message.id)) {
- msg.expanded = true;
- }
-
- msg.display_subject = message.subject && message.message_type !== 'notification' && !(message.model && (message.model !== 'mail.channel'));
- msg.is_selected = msg.id === this.selected_id;
- return msg;
- },
- /**
- * @private
* @param {Object} options
- * @param {integer} [options.channel_id]
+ * @param {integer} [options.channelID]
* @param {string} options.model
* @param {integer} options.id
*/
_redirect: _.debounce(function (options) {
- if ('channel_id' in options) {
- this.trigger('redirect_to_channel', options.channel_id);
+ if ('channelID' in options) {
+ this.trigger('redirect_to_channel', options.channelID);
} else {
this.trigger('redirect', options.model, options.id);
}
}, 500, true),
+ /**
+ * @private
+ */
+ _updateTimestamps: function () {
+ var isAtBottom = this.isAtBottom();
+ this.$('.o_mail_timestamp').each(function () {
+ var date = $(this).data('date');
+ $(this).html(mailUtils.timeFromNow(date));
+ });
+ if (isAtBottom && !this.isAtBottom()) {
+ this.scrollToBottom();
+ }
+ },
//--------------------------------------------------------------------------
// Handlers
@@ -407,30 +424,115 @@ var Thread = Widget.extend({
},
/**
* @private
+ */
+ _onClick: function () {
+ if (this._selectedMessageID) {
+ this.unselectMessage();
+ this.trigger('unselect_message');
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickEmailException: function (ev) {
+ var messageID = $(ev.currentTarget).data('message-id');
+ this.do_action('mail.mail_resend_message_action', {
+ additional_context: {
+ mail_message_to_resend: messageID
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMailExpand: function (ev) {
+ ev.preventDefault();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessage: function (ev) {
+ $(ev.currentTarget).toggleClass('o_thread_selected_message');
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessageNeedaction: function (ev) {
+ var messageID = $(ev.currentTarget).data('message-id');
+ this.trigger('mark_as_read', messageID);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessageReply: function (ev) {
+ this._selectedMessageID = $(ev.currentTarget).data('message-id');
+ this.$('.o_thread_message').removeClass('o_thread_selected_message');
+ this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]')
+ .addClass('o_thread_selected_message');
+ this.trigger('select_message', this._selectedMessageID);
+ ev.stopPropagation();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessageStar: function (ev) {
+ var messageID = $(ev.currentTarget).data('message-id');
+ this.trigger('toggle_star_status', messageID);
+ },
+ /**
+ * @private
* @param {MouseEvent} ev
*/
- _onClickMessageModeration: function (ev) {
+ _onClickMessageModeration: function (ev) {
var $button = $(ev.currentTarget);
var messageID = $button.data('message-id');
var decision = $button.data('decision');
- this.trigger_up('message_moderation', { messageID: messageID, decision: decision });
+ this.trigger_up('message_moderation', {
+ messageID: messageID,
+ decision: decision,
+ });
},
/**
* @private
- * @param {MouseEvent} event
+ * @param {MouseEvent} ev
*/
- _onClickMailException: function (event) {
- var messageId = $(event.currentTarget).data('message-id');
- this.do_action('mail.mail_resend_message_action', {
- additional_context: {
- mail_message_to_resend: messageId
+ _onClickRedirect: function (ev) {
+ // ignore inherited branding
+ if ($(ev.target).data('oe-field') !== undefined) {
+ return;
+ }
+ var id = $(ev.target).data('oe-id');
+ if (id) {
+ ev.preventDefault();
+ var model = $(ev.target).data('oe-model');
+ var options;
+ if (model && (model !== 'mail.channel')) {
+ options = {
+ model: model,
+ id: id
+ };
+ } else {
+ options = { channelID: id };
}
- });
+ this._redirect(options);
+ }
+ },
+ /**
+ * @private
+ */
+ _onClickShowMore: function () {
+ this.trigger('load_more_messages');
},
});
-Thread.ORDER = ORDER;
+ThreadWidget.ORDER = ORDER;
-return Thread;
+return ThreadWidget;
});
diff --git a/addons/mail/static/src/js/thread_field.js b/addons/mail/static/src/js/thread_field.js
index 6d036208eb2..d3021ca1ce5 100644
--- a/addons/mail/static/src/js/thread_field.js
+++ b/addons/mail/static/src/js/thread_field.js
@@ -1,7 +1,8 @@
odoo.define('mail.ThreadField', function (require) {
"use strict";
-var ChatThread = require('mail.ChatThread');
+var Message = require('mail.model.Message');
+var ThreadWidget = require('mail.widget.Thread');
var AbstractField = require('web.AbstractField');
var core = require('web.core');
@@ -11,85 +12,84 @@ var session = require('web.session');
var _t = core._t;
-// -----------------------------------------------------------------------------
-// 'mail_thread' widget: displays the thread of messages
-// -----------------------------------------------------------------------------
+/**
+ * 'mail_thread' widget: displays the thread of messages
+ */
var ThreadField = AbstractField.extend({
- // inherited
+ /**
+ * @override
+ */
init: function () {
this._super.apply(this, arguments);
- this.msgIDs = this.value.res_ids;
+ this._setDocumentThread();
},
+ /**
+ * @override
+ */
willStart: function () {
- return this.alive(this.call('chat_manager', 'isReady'));
+ return this.alive(this.call('mail_service', 'isReady'));
},
+ /**
+ * @override
+ */
start: function () {
var self = this;
this.dp = new concurrency.DropPrevious();
- this.thread = new ChatThread(this, {
- display_order: ChatThread.ORDER.DESC,
- display_document_link: false,
- display_needactions: false,
- squash_close_messages: false,
+ this._threadWidget = new ThreadWidget(this, {
+ displayOrder: ThreadWidget.ORDER.DESC,
+ displayDocumentLinks: false,
+ displayMarkAsRead: false,
+ squashCloseMessages: false,
});
- this.thread.on('load_more_messages', this, this._onLoadMoreMessages);
- this.thread.on('redirect', this, this._onRedirect);
- this.thread.on('redirect_to_channel', this, this._onRedirectToChannel);
- this.thread.on('toggle_star_status', this, function (messageID) {
- self.call('chat_manager', 'toggleStarStatus', messageID);
+ this._threadWidget.on('load_more_messages', this, this._onLoadMoreMessages);
+ this._threadWidget.on('redirect', this, this._onRedirect);
+ this._threadWidget.on('redirect_to_channel', this, this._onRedirectToChannel);
+ this._threadWidget.on('toggle_star_status', this, function (messageID) {
+ var message = self.call('mail_service', 'getMessage', messageID);
+ message.toggleStarStatus();
});
- var def1 = this.thread.appendTo(this.$el);
+ var def1 = this._threadWidget.appendTo(this.$el);
var def2 = this._super.apply(this, arguments);
return this.alive($.when(def1, def2)).then(function () {
// unwrap the thread to remove an unnecessary level on div
- self.setElement(self.thread.$el);
- var chatBus = self.call('chat_manager', 'getChatBus');
- chatBus.on('new_message', self, self._onNewMessage);
- chatBus.on('update_message', self, self._onUpdateMessage);
+ self.setElement(self._threadWidget.$el);
+ var mailBus = self.call('mail_service', 'getMailBus');
+ mailBus.on('new_message', self, self._onNewMessage);
+ mailBus.on('update_message', self, self._onUpdateMessage);
});
},
- _render: function () {
- return this._fetchAndRenderThread(this.msgIDs);
- },
- isSet: function () {
- return true;
- },
- destroy: function () {
- this.call('chat_manager', 'removeChatterMessages', this.model);
- this._super.apply(this, arguments);
- },
- _reset: function (record) {
- this._super.apply(this, arguments);
- this.msgIDs = this.value.res_ids;
- // the mail widgets being persistent, one need to update the res_id on reset
- this.res_id = record.res_id;
- },
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
+ * @override
+ * @return {boolean}
+ */
+ isSet: function () {
+ return true;
+ },
+ /**
* @param {Object} message
* @param {integer[]} message.partner_ids
* @return {$.Promise}
*/
postMessage: function (message) {
var self = this;
- var options = {model: this.model, res_id: this.res_id};
- return this.call('chat_manager', 'postMessage', message, options)
+ return this._documentThread.postMessage(message)
.then(function () {
if (message.partner_ids.length) {
- self.trigger_up('reload_mail_fields', {followers: true});
+ self.trigger_up('reload_mail_fields', { followers: true });
}
})
.fail(function () {
- self.do_notify(_t('Sending Error'), _t('Your message has not been sent.'));
+ self.do_notify(_t("Sending Error"), _t("Your message has not been sent."));
});
},
@@ -99,43 +99,80 @@ var ThreadField = AbstractField.extend({
/**
* @private
- * @returns {Object[]} an array containing a single message 'Creating a new record...'
- */
- _forgeCreationModeMessages: function () {
- return [{
- id: 0,
- body: "<p>Creating a new record...</p>",
- date: moment(),
- author_id: [session.partner_id, session.partner_display_name],
- displayed_author: session.partner_display_name,
- avatar_src: "/web/image/res.partner/" + session.partner_id + "/image_small",
- attachment_ids: [],
- customer_email_data: [],
- tracking_value_ids: [],
- }];
- },
- /**
- * @private
- * @param {integer[]} ids
* @param {Object} [options]
+ * @param {boolean} [options.forceFetch]
+ * @return {$.Deferred}
*/
- _fetchAndRenderThread: function (ids, options) {
+ _fetchAndRenderThread: function (options) {
var self = this;
options = options || {};
- options.ids = ids;
- var fetch_def = this.dp.add(this.call('chat_manager', 'getMessages', options));
- return fetch_def.then(function (raw_messages) {
+ options.ids = this._messageIDs;
+ var fetchDef = this.dp.add(this._documentThread.getMessages(options));
+ return fetchDef.then(function (rawMessages) {
var isCreateMode = false;
if (!self.res_id) {
- raw_messages = self._forgeCreationModeMessages();
+ rawMessages = self._forgeCreateMessages();
isCreateMode = true;
}
- self.thread.render(raw_messages, {
- display_load_more: raw_messages.length < ids.length,
+ self._threadWidget.render(rawMessages, {
+ displayLoadMore: rawMessages.length < self._documentThread.getMessageIDs().length,
isCreateMode: isCreateMode,
});
+ return self._documentThread.markAsRead();
});
},
+ /**
+ * Make a fake list of messages to render in create mode.
+ * Instead of rendering no messages at all, it displays a single message
+ * with body content "Creating a new record".
+ *
+ * @private
+ * @returns {mail.model.Message[]} an array containing a single message 'Creating a new record...'
+ */
+ _forgeCreateMessages: function () {
+ var createMessage = new Message(this, {
+ id: 0,
+ body: _t("<p>Creating a new record...</p>"),
+ author_id: [session.partner_id, session.partner_display_name],
+ });
+ return [createMessage];
+ },
+ /**
+ * @override
+ * @private
+ * @returns {$.Deferred}
+ */
+ _render: function () {
+ return this._fetchAndRenderThread();
+ },
+ /**
+ * The mail widget being persistent, one needs to update the res_id and
+ * to set the correct DocumentThread on reset.
+ *
+ * @override
+ * @private
+ * @param {any} record
+ */
+ _reset: function (record) {
+ this._super.apply(this, arguments);
+ this.res_id = record.res_id;
+ this._setDocumentThread();
+ },
+ /**
+ * Sets this._documentThread, the DocumentThread associated with the current
+ * model and resID.
+ *
+ * @returns {mail.model.DocumentThread}
+ */
+ _setDocumentThread: function () {
+ var params = {
+ messageIDs: this.value.res_ids,
+ name: this.recordData.display_name,
+ resID: this.res_id,
+ resModel: this.model,
+ };
+ this._documentThread = this.call('mail_service', 'getOrAddDocumentThread', params);
+ },
//--------------------------------------------------------------------------
// Handlers
@@ -143,38 +180,62 @@ var ThreadField = AbstractField.extend({
/**
* When a new message arrives, fetch its data to render it
- * @param {Number} message_id : the identifier of the new message
- * @returns {Deferred}
+ *
+ * @private
*/
_onLoadMoreMessages: function () {
- this._fetchAndRenderThread(this.msgIDs, {forceFetch: true});
+ this._fetchAndRenderThread({ forceFetch: true });
},
+ /**
+ * @private
+ * @param {mail.model.Message}
+ */
_onNewMessage: function (message) {
- if (message.model === this.model && message.res_id === this.res_id) {
- this.msgIDs.unshift(message.id);
+ if (
+ message.isLinkedToDocumentThread() &&
+ message.getDocumentModel() === this.model &&
+ message.getDocumentID() === this.res_id
+ ) {
this.trigger_up('new_message', {
id: this.value.id,
- msgIDs: this.msgIDs,
+ messageIDs: this._documentThread.getMessageIDs(),
});
- this._fetchAndRenderThread(this.msgIDs);
+ this._fetchAndRenderThread();
}
},
+ /**
+ * @private
+ * @param {integer} channelID
+ */
_onRedirectToChannel: function (channelID) {
var self = this;
- this.call('chat_manager', 'joinChannel', channelID).then(function () {
- // Execute Discuss client action with 'channel' as default channel
- self.do_action('mail.mail_channel_action_client_chat', {active_id: channelID});
+ this.call('mail_service', 'joinChannel', channelID).then(function () {
+ // Execute Discuss with 'channel' as default channel
+ self.do_action('mail.mail_channel_action_client_chat', { active_id: channelID });
});
},
- _onRedirect: function (res_model, res_id) {
+ /**
+ * @private
+ * @param {string} resModel
+ * @param {integer} resID
+ */
+ _onRedirect: function (resModel, resID) {
this.trigger_up('redirect', {
- res_id: res_id,
- res_model: res_model,
+ res_id: resID,
+ res_model: resModel,
});
},
+ /**
+ * @private
+ * @param {mail.model.Message}
+ */
_onUpdateMessage: function (message) {
- if (message.model === this.model && message.res_id === this.res_id) {
- this._fetchAndRenderThread(this.msgIDs);
+ if (
+ message.isLinkedToDocumentThread() &&
+ message.getDocumentModel() === this.model &&
+ message.getDocumentID() === this.res_id
+ ) {
+ this._fetchAndRenderThread();
}
},
});
diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js
new file mode 100644
index 00000000000..1d49d89eeb7
--- /dev/null
+++ b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js
@@ -0,0 +1,390 @@
+odoo.define('mail.AbstractThreadWindow', function (require) {
+"use strict";
+
+var ThreadWidget = require('mail.widget.Thread');
+
+var config = require('web.config');
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var HEIGHT_OPEN = '400px';
+var HEIGHT_FOLDED = '34px';
+
+/**
+ * This is an abstract widget for rendering thread windows.
+ *
+ * It contains logic that are shared between mail.ThreadWindow and
+ * mail.LivechatWindow.
+ *
+ * The reason for having two different implementation of thread windows is
+ * that mail.ThreadWindow makes use of mail.Manager, which is used in the
+ * backend, while mail.LivechatWindow must work without this mail service.
+ */
+var AbstractThreadWindow = Widget.extend({
+ template: 'mail.AbstractThreadWindow',
+ custom_events: {
+ escape_pressed: '_onEscapePressed',
+ document_viewer_closed: '_onDocumentViewerClose',
+ },
+ events: {
+ 'click .o_thread_window_close': '_onClickClose',
+ 'click .o_thread_window_title': '_onClickFold',
+ 'click .o_thread_composer': '_onComposerClick',
+ 'click .o_mail_thread': '_onThreadWindowClicked',
+ 'keydown .o_thread_composer': '_onKeydown',
+ 'keypress .o_thread_composer': '_onKeypress',
+ },
+ /**
+ * Children of this class must make use of `thread`, which is an object that
+ * represent the thread that is linked to this thread window.
+ *
+ * If no thread is provided, this will represent the "blank" thread window.
+ *
+ * @abstract
+ * @param {Widget} parent
+ * @param {Object} thread
+ * @param {Object} [options={}]
+ */
+ init: function (parent, thread, options) {
+ this._super(parent);
+
+ this.options = _.defaults(options || {}, {
+ autofocus: true,
+ displayStars: true,
+ displayReplyIcons: false,
+ displayEmailIcons: false,
+ placeholder: _t("Say something"),
+ });
+
+ this._hidden = false;
+ },
+ start: function () {
+ this.$input = this.$('.o_composer_text_field');
+ this.$header = this.$('.o_thread_window_header');
+
+ this.threadWidget = new ThreadWidget(this, {
+ threadID: this._getThreadID(),
+ displayDocumentLinks: false,
+ displayMarkAsRead: false,
+ displayStars: this.options.displayStars,
+ });
+
+ if (this.isFolded()) {
+ this.$el.css('height', HEIGHT_FOLDED);
+ } else if (this.options.autofocus) {
+ this._focusInput();
+ }
+ if (!config.device.isMobile) {
+ this.$el.css('margin-right', $.position.scrollbarWidth());
+ }
+ var def = this.threadWidget.replace(this.$('.o_chat_content'));
+ return $.when(this._super(), def);
+ },
+ /**
+ * @override
+ */
+ do_hide: function () {
+ this._hidden = true;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ do_show: function () {
+ this._hidden = false;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ do_toggle: function (display) {
+ this._hidden = _.isBoolean(display) ? !display : !this._hidden;
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close this window
+ *
+ * @abstract
+ */
+ close: function () {},
+ /**
+ * Get the status of the thread, such as the im status of a DM.
+ *
+ * @abstract
+ * @returns {string}
+ */
+ getStatus: function () {},
+ /**
+ * Get the title of the thread window, which usually contains the name of
+ * the thread.
+ *
+ * @abstract
+ * @returns {string}
+ */
+ getTitle: function () {},
+ /**
+ * Get the unread counter of the thread linked to this thread window.
+ *
+ * @abstract
+ * @returns {integer}
+ */
+ getUnreadCounter: function () {},
+ /**
+ * States whether this thread window is related to a thread or not.
+ *
+ * This is useful in order to provide specific behaviour for thread windows
+ * without any thread, e.g. let them open a thread from this "blank" thread
+ * window.
+ *
+ * @abstract
+ * @returns {boolean}
+ */
+ hasThread: function () {},
+ /**
+ * States whether this thread is folded or not. This information is stored
+ * on the thread model.
+ *
+ * @abstract
+ * @returns {boolean}
+ */
+ isFolded: function () {},
+ /**
+ * States whether the current environment is in mobile or not. This is
+ * useful in order to customize the template rendering for mobile view.
+ *
+ * @returns {boolean}
+ */
+ isMobile: function () {
+ return config.device.isMobile;
+ },
+ /**
+ * States whether the thread window is hidden or not.
+ *
+ * @returns {boolean}
+ */
+ isHidden: function () {
+ return this._hidden;
+ },
+ /**
+ * Render the thread window, using the provided list of messages
+ *
+ * TODO: use messages in thread, instead of providing list of messages
+ *
+ * @param {mail.model.AbstractMessage[]} messages
+ */
+ render: function (messages) {
+ this._renderHeader();
+ this.threadWidget.render(messages, { displayLoadMore: false });
+ },
+ /**
+ * Toggle the fold state of this thread window. Also update the fold state
+ * of the thread model.
+ *
+ * @param {boolean} folded
+ */
+ toggleFold: function (folded) {
+ if (!_.isBoolean(folded)) {
+ folded = !this.isFolded();
+ }
+ this._updateThreadFoldState(folded);
+ },
+ /**
+ * Update the visual state of the window so that it matched the internal
+ * fold state. This is useful in case the related thread has its fold state
+ * that has been changed.
+ */
+ updateVisualFoldState: function () {
+ if (!this.isFolded()) {
+ this.threadWidget.scrollToBottom();
+ this._focusInput();
+ }
+ this._animateFold();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when there is a change of the fold state of the thread window.
+ * This method animates the change of fold state of this thread window.
+ *
+ * @private
+ */
+ _animateFold: function () {
+ this.$el.animate({
+ height: this.isFolded() ? HEIGHT_FOLDED : HEIGHT_OPEN
+ }, 200);
+ },
+ /**
+ * Set the focus on the composer of the thread window. This operation is
+ * ignored in mobile context.
+ *
+ * @private
+ * Set the focus on the input of the window
+ */
+ _focusInput: function () {
+ if (
+ config.device.touch &&
+ config.device.size_class <= config.device.SIZES.SM
+ ) {
+ return;
+ }
+ this.$input.focus();
+ },
+ /**
+ * Returns the options used by the rendering of the window's header
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getHeaderRenderingOptions: function () {
+ return {
+ status: this.getStatus(),
+ title: this.getTitle(),
+ unreadCounter: this.getUnreadCounter(),
+ widget: this,
+ };
+ },
+ /**
+ * Get the ID of the related thread.
+ *
+ * @abstract
+ * @private
+ * @return {integer}
+ */
+ _getThreadID: function () {},
+ /**
+ * Post a message on this thread window
+ *
+ * @abstract
+ * @private
+ * @param {Object} messageData
+ */
+ _postMessage: function (messageData) {},
+ /**
+ * Update the fold state of the related thread.
+ * This function is called when toggling the fold state of this window
+ *
+ * @abstract
+ * @private
+ * @param {boolean} folded
+ */
+ _updateThreadFoldState: function (folded) {},
+ /**
+ * Render the header of this thread window
+ *
+ * @private
+ */
+ _renderHeader: function () {
+ var options = this._getHeaderRenderingOptions();
+ this.$header.html(
+ QWeb.render('mail.AbstractThreadWindowHeaderContent', options));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close the thread
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickClose: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.close();
+ },
+ /**
+ * Fold/unfold the thread window
+ *
+ * @private
+ */
+ _onClickFold: function () {
+ if (!config.device.isMobile) {
+ this.toggleFold();
+ }
+ },
+ /**
+ * Called when the composer is clicked -> forces focus on input even if
+ * jquery's blockUI is enabled.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onComposerClick: function (ev) {
+ if ($(ev.target).closest('a, button').length) {
+ return;
+ }
+ this._focusInput();
+ },
+ /**
+ * @private
+ */
+ _onDocumentViewerClose: function () {
+ this._focusInput();
+ },
+ /**
+ * @private
+ */
+ _onEscapePressed: function () {
+ if (!this.isFolded()) {
+ this.close();
+ }
+ },
+ /**
+ * Called when typing something on the composer of this thread window.
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown: function (ev) {
+ ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
+ // ENTER key (avoid requiring jquery ui for external livechat)
+ if (ev.which === 13) {
+ var content = _.str.trim(this.$input.val());
+ var messageData = {
+ content: content,
+ attachment_ids: [],
+ partner_ids: [],
+ };
+ this.$input.val('');
+ if (content) {
+ this._postMessage(messageData);
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeypress: function (ev) {
+ ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
+ },
+ /**
+ * When a thread window is clicked on, we want to give the focus to the main
+ * input. An exception is made when the user is selecting something.
+ *
+ * @private
+ */
+ _onThreadWindowClicked: function () {
+ var selectObj = window.getSelection();
+ if (selectObj.anchorOffset === selectObj.focusOffset) {
+ this.$input.focus();
+ }
+ },
+});
+
+return AbstractThreadWindow;
+
+});
diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js
new file mode 100644
index 00000000000..c0d7a22ea33
--- /dev/null
+++ b/addons/mail/static/src/js/thread_windows/thread_window.js
@@ -0,0 +1,428 @@
+odoo.define('mail.ThreadWindow', function (require) {
+"use strict";
+
+var AbstractThreadWindow = require('mail.AbstractThreadWindow');
+var BasicComposer = require('mail.composer.Basic');
+
+var core = require('web.core');
+
+var _t = core._t;
+
+/**
+ * This is the main widget for rendering small windows for mail.model.Thread.
+ * Almost all instances of this class are linked to a thread. The sole
+ * exception is the "blank" thread window. This window let us open another
+ * thread window, using this "blank" thread window.
+ */
+var ThreadWindow = AbstractThreadWindow.extend({
+ template: 'mail.ThreadWindow',
+ events: _.extend({}, AbstractThreadWindow.prototype.events, {
+ 'click .o_thread_window_expand': '_onClickExpand',
+ }),
+ /**
+ * Version of thread window that supports {mail.model.Thread}
+ *
+ * @override
+ * @param {mail.Manager} parent
+ * @param {mail.model.Thread} [thread = null] if not set, this is a "blank"
+ * thread window. It lets us open a DM by providing the name of a chat.
+ * @param {Object} [options={}]
+ * @param {boolean} [options.passively=false]
+ */
+ init: function (parent, thread, options) {
+ this._super.apply(this, arguments);
+
+ // don't automatically mark unread messages as seen when at the bottom
+ // of the thread
+ this._passive = this.options.passively;
+ this._thread = thread || null;
+
+ if (!this.hasThread()) {
+ // internal fold state of thread window without any thread
+ this._folded = false;
+ // remembered partner ID of "blank" thread window in order to be
+ // replaced with newly opened DM window
+ this.directPartnerID = null;
+ }
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ var superDef = this._super().then(this._listenThreadWidget.bind(this));
+
+ var composerDef;
+ if (!this.hasThread()) {
+ this._startWithoutThread();
+ } else if (this._needsComposer()) {
+ var basicComposer = new BasicComposer(this, {
+ mentionPartnersRestricted: this._thread.getType() !== 'document_thread',
+ isMini: true
+ });
+ basicComposer.on('post_message', this, this._postMessage);
+ basicComposer.once('input_focused', this, function () {
+ var commands = this._thread.getCommands();
+ var partners = this._thread.getMentionPartnerSuggestions();
+ basicComposer.mentionSetCommands(commands);
+ basicComposer.mentionSetPrefetchedPartners(partners);
+ });
+ composerDef = basicComposer.replace(this.$('.o_thread_composer'));
+ composerDef.then(function () {
+ self.$input = self.$('.o_composer_text_field');
+ });
+ }
+
+ return $.when(superDef, composerDef);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ close: function () {
+ if (this._hasThread()) {
+ this._thread.close();
+ }
+ },
+ /**
+ * Set the focus on the input of this thread window
+ */
+ focusInput: function () {
+ this._focusInput();
+ },
+ /**
+ * Get the ID of the thread window, which is equivalent to the ID of the
+ * thread related to this window
+ *
+ * @returns {integer|string}
+ */
+ getID: function () {
+ return this._getThreadID();
+ },
+ /**
+ * Get the status of the thread. If this window has no thread, returns
+ * 'undefined'
+ *
+ * @override
+ * @returns {string|undefined}
+ */
+ getStatus: function () {
+ if (!this.hasThread()) {
+ return undefined;
+ }
+ return this._thread.getStatus();
+ },
+ /**
+ * Get the title of the thread window, which is equivalent to the name of
+ * related thread, prefixed with "#" if this is a chat (i.e. DM, backend
+ * livechat).
+ *
+ * If there is no thread linked to this thread window, display title of
+ * "blank" thread window.
+ *
+ * @override
+ * @returns {string}
+ */
+ getTitle: function () {
+ if (!this.hasThread()) {
+ return _t("New message");
+ }
+ return this._thread.getTitle();
+ },
+ /**
+ * Get the unread counter of the related thread. If there are no thread
+ * linked to this window, returns 0.
+ *
+ * @override
+ * @returns {integer}
+ */
+ getUnreadCounter: function () {
+ if (!this.hasThread()) {
+ return 0;
+ }
+ return this._thread.getUnreadCounter();
+ },
+ /**
+ * @override
+ * @returns {boolean}
+ */
+ hasThread: function () {
+ return !!this._thread;
+ },
+ /**
+ * State whether the related thread is folded or not. If there are no
+ * thread related to this window, it means this is the "blank" thread
+ * window, therefore we use the internal folded state.
+ *
+ * @override
+ * @returns {boolean}
+ */
+ isFolded: function () {
+ if (!this.hasThread()) {
+ return this._folded;
+ }
+ return this._thread.isFolded();
+ },
+ /**
+ * Tell whether the thread window is passive or not. A passive thread window
+ * does not auto-mark the thread as read when scrolling at the bottom.
+ *
+ * @returns {boolean}
+ */
+ isPassive: function () {
+ return this._passive;
+ },
+ /**
+ * Turn the thread window in active mode, so that when the bottom of the
+ * thread is visible, it is automatically marked as read.
+ */
+ removePassive: function () {
+ this._passive = false;
+ },
+ /**
+ * Update the header of this thread window
+ * This is useful when some information on the header have be updated
+ * such as the status or the title of the thread that have changed.
+ */
+ updateHeader: function () {
+ this._renderHeader();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the ID of the thread window, which is equivalent to the ID of the
+ * related thread.
+ *
+ * If this window is not related to a thread, it means this is the "blank"
+ * thread window, therefore it returns "_blank" as its ID.
+ *
+ * @override
+ * @private
+ * @returns {integer|string} the threadID, or '_blank' for the window that
+ * is not related to any thread.
+ */
+ _getThreadID: function () {
+ if (!this.hasThread()) {
+ return '_blank';
+ }
+ return this._thread.getID();
+ },
+ /**
+ * @override
+ * @private
+ */
+ _getHeaderRenderingOptions: function () {
+ var options = this._super.apply(this, arguments);
+ if (this._thread.getType() === 'document_thread') {
+ options.expandTitle = _t("Open document");
+ } else {
+ options.expandTitle = _t("Open in Discuss");
+ }
+ return options;
+ },
+ /**
+ * States whether the window is linked to a thread or not
+ *
+ * Useful to detect whether this is the "blank" thread window or not.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _hasThread: function () {
+ return !!this._thread;
+ },
+ /**
+ * Listen on thread widget events
+ *
+ * @private
+ */
+ _listenThreadWidget: function () {
+ this.threadWidget
+ .on('redirect', this, this._onRedirect)
+ .on('redirect_to_channel', this, this._onRedirectToChannel)
+ .on('toggle_star_status', this, this._onToggleStarStatus);
+ },
+ /**
+ * States whether the input of the thread window should be displayed or not.
+ * This is based on the type of the thread:
+ *
+ * Do not display the input in the following cases:
+ *
+ * - no thread related to this window
+ * - window of a mailbox (temp: let us have mailboxes in window mode)
+ * - window of a thread with mass mailing
+ *
+ * Any other threads show the input in the window.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _needsComposer: function () {
+ return this.hasThread() &&
+ (this._thread.getType() !== 'mailbox') &&
+ !this._thread.isMassMailing();
+ },
+ /**
+ * Open this thread window.
+ * This private method exists only for the purpose of providing a callback
+ * function on redirect to a partner.
+ *
+ * @private
+ */
+ _open: function () {
+ this.call('mail_service', 'openThreadWindow', this.getID());
+ },
+ /**
+ * Post a message in the thread related to this window.
+ * If this window has no thread, do nothing
+ *
+ * @override
+ * @private
+ * @param {Object} messageData
+ */
+ _postMessage: function (messageData) {
+ var self = this;
+ if (!this._hasThread()) {
+ return;
+ }
+ this._thread.postMessage(messageData)
+ .then(function () {
+ self.threadWidget.scrollToBottom();
+ });
+ },
+ /**
+ * @private
+ */
+ _startWithoutThread: function () {
+ var self = this;
+ this.$el.addClass('o_thread_less');
+ this.$('.o_thread_search_input input')
+ .autocomplete({
+ source: function (request, response) {
+ self.call('mail_service', 'searchPartner', request.term, 10)
+ .done(response);
+ },
+ select: function (event, ui) {
+ // remember partner ID so that we can replace this window
+ // with new DM window
+ var partnerID = ui.item.id;
+ self.directPartnerID = partnerID;
+ self.call('mail_service', 'openDmWindow', partnerID);
+ },
+ })
+ .focus();
+ },
+ /**
+ * Update the fold state of the thread
+ * If there is no thread linked to this window, it means this is the
+ * "blank" thread window, therefore we use the internal state 'folded'
+ *
+ * @override
+ * @private
+ * @param {boolean} folded
+ */
+ _updateThreadFoldState: function (folded) {
+ if (this.hasThread()) {
+ this._thread.fold(folded);
+ } else {
+ this._folded = folded;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Expand the thread, which will open the thread in discuss.
+ *
+ * If there is no thread linked to this window, it means this is the "blank"
+ * thread window, therefore it opens discuss with the default thread (which
+ * should be the mailbox 'inbox')
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickExpand: _.debounce(function (ev) {
+ var self = this;
+ ev.preventDefault();
+ if (this._thread.getType() === 'document_thread') {
+ this.do_action({
+ type: 'ir.actions.act_window',
+ res_model: this._thread.getDocumentModel(),
+ views: [[false, 'form']],
+ res_id: this._thread.getDocumentID(),
+ });
+ } else {
+ this.do_action('mail.mail_channel_action_client_chat', {
+ clear_breadcrumbs: false,
+ active_id: this.hasThread() ? this._getThreadID() : undefined,
+ on_reverse_breadcrumb: function () {
+ self.call('mail_service', 'getMailBus')
+ .trigger('discuss_open', false);
+ },
+ });
+ }
+ }, 1000, true),
+ /**
+ * @override
+ * @private
+ * @param {KeyboardEvent} ev
+ *
+ * Override _onKeydown to only prevent jquery's blockUI to cancel event,
+ * but without sending the message on ENTER keydown as this is handled by
+ * the BasicComposer
+ */
+ _onKeydown: function (ev) {
+ ev.stopPropagation();
+ },
+ /**
+ * callback function receives the threadID linked to resModel and resID
+ * Note: this callback function is only called for 'res.partner'
+ *
+ * @private
+ * @param {string} resModel
+ * @param {integer} resID
+ */
+ _onRedirect: function (resModel, resID) {
+ var callback = this._open.bind(this);
+ this.call('mail_service', 'redirect', resModel, resID, callback);
+ },
+ /**
+ * @private
+ * @param {integer} channelID
+ */
+ _onRedirectToChannel: function (channelID) {
+ var thread = this.call('mail_service', 'getThread', channelID);
+ if (!thread) {
+ this.call('mail_service', 'joinChannel', channelID)
+ .then(function (channel) {
+ channel.detach();
+ });
+ } else {
+ this.toggleFold(false);
+ }
+ },
+ /**
+ * @private
+ * @param {integer} messageID
+ */
+ _onToggleStarStatus: function (messageID) {
+ var message = this.call('mail_service', 'getMessage', messageID);
+ message.toggleStarStatus();
+ },
+
+});
+
+return ThreadWindow;
+
+});
diff --git a/addons/mail/static/src/js/tour.js b/addons/mail/static/src/js/tour.js
index f8fd387eb9f..ffbe69de525 100644
--- a/addons/mail/static/src/js/tour.js
+++ b/addons/mail/static/src/js/tour.js
@@ -1,4 +1,4 @@
-odoo.define('mail.tour', function(require) {
+odoo.define('mail.tour', function (require) {
"use strict";
var core = require('web.core');
@@ -7,24 +7,24 @@ var tour = require('web_tour.tour');
var _t = core._t;
tour.register('mail_tour', [{
- trigger: '.o_mail_chat .o_mail_chat_sidebar .o_add[data-type="public"]',
+ trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="public"]',
content: _t("<p>Channels make it easy to organize information across different topics and groups.</p> <p>Try to <b>create your first channel</b> (e.g. sales, marketing, product XYZ, after work party, etc).</p>"),
position: 'bottom',
}, {
- trigger: '.o_mail_chat .o_composer_text_field',
- content: _t("<p><b>Write a message</b> to the members of the channel here.</p> <p>You can notify someone with <i>\'@\'</i> or link another channel with <i>\'#\'</i>. Start your message with <i>\'/\'</i> to get the list of possible commands.</p>"),
+ trigger: '.o_mail_discuss .o_composer_text_field',
+ content: _t("<p><b>Write a message</b> to the members of the channel here.</p> <p>You can notify someone with <i>'@'</i> or link another channel with <i>'#'</i>. Start your message with <i>'/'</i> to get the list of possible commands.</p>"),
position: "top",
width: 350,
}, {
- trigger: ".o_mail_chat .o_mail_thread .o_thread_message_star",
+ trigger: ".o_mail_discuss .o_mail_thread .o_thread_message_star",
content: _t("Messages can be <b>starred</b> to remind you to check back later."),
position: "right",
}, {
- trigger: '.o_mail_chat .o_mail_chat_channel_item[data-channel-id="channel_starred"]',
+ trigger: '.o_mail_discuss .o_mail_item_item[data-thread-id="mailbox_starred"]',
content: _t("Once a message has been starred, you can come back and review it at any time here."),
position: "bottom",
}, {
- trigger: '.o_mail_chat .o_mail_chat_sidebar .o_add[data-type="dm"]',
+ trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="dm"]',
content: _t("<p><b>Chat with coworkers</b> in real-time using direct messages.</p><p><i>You might need to invite users from the Settings app first.</i></p>"),
position: 'bottom',
}]);
diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js
index 16507035982..4749ba11b20 100644
--- a/addons/mail/static/src/js/utils.js
+++ b/addons/mail/static/src/js/utils.js
@@ -1,24 +1,28 @@
odoo.define('mail.utils', function (require) {
"use strict";
-function parse_and_transform(html_string, transform_function) {
- var open_token = "OPEN" + Date.now();
- var string = html_string.replace(/&lt;/g, open_token);
+var core = require('web.core');
+
+var _t = core._t;
+
+function parseAndTransform(htmlString, transformFunction) {
+ var openToken = "OPEN" + Date.now();
+ var string = htmlString.replace(/&lt;/g, openToken);
var children = $('<div>').html(string).contents();
- return _parse_and_transform(children, transform_function)
- .replace(new RegExp(open_token, "g"), "&lt;");
+ return _parseAndTransform(children, transformFunction)
+ .replace(new RegExp(openToken, "g"), "&lt;");
}
-function _parse_and_transform(nodes, transform_function) {
+function _parseAndTransform(nodes, transformFunction) {
return _.map(nodes, function (node) {
- return transform_function(node, function () {
- return _parse_and_transform(node.childNodes, transform_function);
+ return transformFunction(node, function () {
+ return _parseAndTransform(node.childNodes, transformFunction);
});
}).join("");
}
// Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
// Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match.
-var url_regexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_\+.~#?&'$//=]*)/gi;
+var urlRegexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_+.~#?&'$//=]*)/gi;
function linkify(text, attrs) {
attrs = attrs || {};
if (attrs.target === undefined) {
@@ -27,28 +31,28 @@ function linkify(text, attrs) {
attrs = _.map(attrs, function (value, key) {
return key + '="' + _.escape(value) + '"';
}).join(' ');
- return text.replace(url_regexp, function (url) {
+ return text.replace(urlRegexp, function (url) {
var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url;
return '<a ' + attrs + ' href="' + href + '">' + url + '</a>';
});
}
-function add_link (node, transform_children) {
+function addLink(node, transformChildren) {
if (node.nodeType === 3) { // text node
return linkify(node.data);
}
if (node.tagName === "A") return node.outerHTML;
- node.innerHTML = transform_children();
+ node.innerHTML = transformChildren();
return node.outerHTML;
}
-function strip_html (node, transform_children) {
+function stripHTML(node, transformChildren) {
if (node.nodeType === 3) return node.data; // text node
if (node.tagName === "BR") return "\n";
- return transform_children();
+ return transformChildren();
}
-function inline (node, transform_children) {
+function inline(node, transform_children) {
if (node.nodeType === 3) return node.data;
if (node.nodeType === 8) return "";
if (node.tagName === "BR") return " ";
@@ -58,7 +62,7 @@ function inline (node, transform_children) {
}
// Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False
-function parse_email (text) {
+function parseEmail(text) {
if (text){
var result = text.match(/(.*)<(.*@.*)>/);
if (result) {
@@ -74,13 +78,13 @@ function parse_email (text) {
// Replaces textarea text into html text (add <p>, <a>)
// TDE note : should be done server-side, in Python -> use mail.compose.message ?
-function get_text2html (text) {
+function getTextToHTML(text) {
return text
.replace(/((?:https?|ftp):\/\/[\S]+)/g,'<a href="$1">$1</a> ')
.replace(/[\n\r]/g,'<br/>');
}
-var accented_letters_mapping = {
+var accentedLettersMapping = {
'a': '[àáâãäå]',
'ae': 'æ',
'c': 'ç',
@@ -92,21 +96,29 @@ var accented_letters_mapping = {
'u': '[ùúûűü]',
'y': '[ýÿ]',
};
-function unaccent (str) {
- _.each(accented_letters_mapping, function (value, key) {
+function unaccent(str) {
+ _.each(accentedLettersMapping, function (value, key) {
str = str.replace(new RegExp(value, 'g'), key);
});
return str;
}
+function timeFromNow(date) {
+ if (moment().diff(date, 'seconds') < 45) {
+ return _t("now");
+ }
+ return date.fromNow();
+}
+
return {
- parse_and_transform: parse_and_transform,
- add_link: add_link,
- linkify: linkify,
- strip_html: strip_html,
+ addLink: addLink,
+ getTextToHTML: getTextToHTML,
inline: inline,
- parse_email: parse_email,
- get_text2html: get_text2html,
+ linkify: linkify,
+ parseAndTransform: parseAndTransform,
+ parseEmail: parseEmail,
+ stripHTML: stripHTML,
+ timeFromNow: timeFromNow,
unaccent: unaccent,
};