summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/js')
-rw-r--r--addons/mail/static/src/js/activity.js868
-rw-r--r--addons/mail/static/src/js/basic_view.js68
-rw-r--r--addons/mail/static/src/js/core/translation.js28
-rw-r--r--addons/mail/static/src/js/custom_filter_item.js21
-rw-r--r--addons/mail/static/src/js/document_viewer.js396
-rw-r--r--addons/mail/static/src/js/emojis.js155
-rw-r--r--addons/mail/static/src/js/emojis_mixin.js91
-rw-r--r--addons/mail/static/src/js/field_char.js56
-rw-r--r--addons/mail/static/src/js/field_char_emojis.js18
-rw-r--r--addons/mail/static/src/js/field_emojis_common.js136
-rw-r--r--addons/mail/static/src/js/field_text_emojis.js18
-rw-r--r--addons/mail/static/src/js/main.js126
-rw-r--r--addons/mail/static/src/js/many2many_tags_email.js135
-rw-r--r--addons/mail/static/src/js/many2one_avatar_user.js68
-rw-r--r--addons/mail/static/src/js/systray/systray_activity_menu.js202
-rw-r--r--addons/mail/static/src/js/tools/debug_manager.js33
-rw-r--r--addons/mail/static/src/js/tours/mail.js59
-rw-r--r--addons/mail/static/src/js/utils.js187
-rw-r--r--addons/mail/static/src/js/views/activity/activity_cell.js42
-rw-r--r--addons/mail/static/src/js/views/activity/activity_controller.js124
-rw-r--r--addons/mail/static/src/js/views/activity/activity_model.js124
-rw-r--r--addons/mail/static/src/js/views/activity/activity_record.js62
-rw-r--r--addons/mail/static/src/js/views/activity/activity_renderer.js210
-rw-r--r--addons/mail/static/src/js/views/activity/activity_view.js53
24 files changed, 3280 insertions, 0 deletions
diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js
new file mode 100644
index 00000000..ae9d914f
--- /dev/null
+++ b/addons/mail/static/src/js/activity.js
@@ -0,0 +1,868 @@
+odoo.define('mail.Activity', function (require) {
+"use strict";
+
+var mailUtils = require('mail.utils');
+
+var AbstractField = require('web.AbstractField');
+var BasicModel = require('web.BasicModel');
+var config = require('web.config');
+var core = require('web.core');
+var field_registry = require('web.field_registry');
+var session = require('web.session');
+var framework = require('web.framework');
+var time = require('web.time');
+
+var QWeb = core.qweb;
+var _t = core._t;
+const _lt = core._lt;
+
+/**
+ * Fetches activities and postprocesses them.
+ *
+ * This standalone function performs an RPC, but to do so, it needs an instance
+ * of a widget that implements the _rpc() function.
+ *
+ * @todo i'm not very proud of the widget instance given in arguments, we should
+ * probably try to do it a better way in the future.
+ *
+ * @param {Widget} self a widget instance that can perform RPCs
+ * @param {Array} ids the ids of activities to read
+ * @return {Promise<Array>} resolved with the activities
+ */
+function _readActivities(self, ids) {
+ if (!ids.length) {
+ return Promise.resolve([]);
+ }
+ var context = self.getSession().user_context;
+ if (self.record && !_.isEmpty(self.record.getContext())) {
+ context = self.record.getContext();
+ }
+ return self._rpc({
+ model: 'mail.activity',
+ method: 'activity_format',
+ args: [ids],
+ context: context,
+ }).then(function (activities) {
+ // convert create_date and date_deadline to moments
+ _.each(activities, function (activity) {
+ activity.create_date = moment(time.auto_str_to_date(activity.create_date));
+ activity.date_deadline = moment(time.auto_str_to_date(activity.date_deadline));
+ });
+ // sort activities by due date
+ activities = _.sortBy(activities, 'date_deadline');
+ return activities;
+ });
+}
+
+BasicModel.include({
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Fetches the activities displayed by the activity field widget in form
+ * views.
+ *
+ * @private
+ * @param {Object} record - an element from the localData
+ * @param {string} fieldName
+ * @return {Promise<Array>} resolved with the activities
+ */
+ _fetchSpecialActivity: function (record, fieldName) {
+ var localID = (record._changes && fieldName in record._changes) ?
+ record._changes[fieldName] :
+ record.data[fieldName];
+ return _readActivities(this, this.localData[localID].res_ids);
+ },
+});
+
+/**
+ * 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 today = moment().startOf('day');
+ _.each(activities, function (activity) {
+ var toDisplay = '';
+ var diff = activity.date_deadline.diff(today, 'days', true); // true means no rounding
+ 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) {
+ toDisplay = _t("Tomorrow");
+ } else {
+ toDisplay = _.str.sprintf(_t("Due in %d days"), Math.abs(diff));
+ }
+ }
+ }
+ activity.label_delay = toDisplay;
+ });
+ return activities;
+};
+
+/**
+ * Set the file upload identifier for 'upload_file' type activities
+ *
+ * @param {Array} activities list of activity Object
+ * @return {Array} : list of modified activity Object
+ */
+var setFileUploadID = function (activities) {
+ _.each(activities, function (activity) {
+ if (activity.activity_category === 'upload_file') {
+ activity.fileuploadID = _.uniqueId('o_fileupload');
+ }
+ });
+ return activities;
+};
+
+var BasicActivity = AbstractField.extend({
+ events: {
+ 'click .o_edit_activity': '_onEditActivity',
+ 'change input.o_input_file': '_onFileChanged',
+ 'click .o_mark_as_done': '_onMarkActivityDone',
+ 'click .o_mark_as_done_upload_file': '_onMarkActivityDoneUploadFile',
+ 'click .o_activity_template_preview': '_onPreviewMailTemplate',
+ 'click .o_schedule_activity': '_onScheduleActivity',
+ 'click .o_activity_template_send': '_onSendMailTemplate',
+ 'click .o_unlink_activity': '_onUnlinkActivity',
+ },
+ init: function () {
+ this._super.apply(this, arguments);
+ this._draftFeedback = {};
+ },
+
+ //------------------------------------------------------------
+ // Public
+ //------------------------------------------------------------
+
+ /**
+ * @param {integer} previousActivityTypeID
+ * @return {Promise}
+ */
+ scheduleActivity: function () {
+ var callback = this._reload.bind(this, { activity: true, thread: true });
+ return this._openActivityForm(false, callback);
+ },
+
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+
+ /**
+ * Send a feedback and reload page in order to mark activity as done
+ *
+ * @private
+ * @param {Object} params
+ * @param {integer} params.activityID
+ * @param {integer[]} params.attachmentIds
+ * @param {string} params.feedback
+ *
+ * @return {$.Promise}
+ */
+ _markActivityDone: function (params) {
+ var activityID = params.activityID;
+ var feedback = params.feedback || false;
+ var attachmentIds = params.attachmentIds || [];
+
+ return this._sendActivityFeedback(activityID, feedback, attachmentIds)
+ .then(this._reload.bind(this, { activity: true, thread: true }));
+ },
+ /**
+ * Send a feedback and proposes to schedule next activity
+ * previousActivityTypeID will be given to new activity to propose activity
+ * type based on recommended next activity
+ *
+ * @private
+ * @param {Object} params
+ * @param {integer} params.activityID
+ * @param {string} params.feedback
+ */
+ _markActivityDoneAndScheduleNext: function (params) {
+ var activityID = params.activityID;
+ var feedback = params.feedback;
+ var self = this;
+ this._rpc({
+ model: 'mail.activity',
+ method: 'action_feedback_schedule_next',
+ args: [[activityID]],
+ kwargs: {feedback: feedback},
+ context: this.record.getContext(),
+ }).then(
+ function (rslt_action) {
+ if (rslt_action) {
+ self.do_action(rslt_action, {
+ on_close: function () {
+ self.trigger_up('reload', { keepChanges: true });
+ },
+ });
+ } else {
+ self.trigger_up('reload', { keepChanges: true });
+ }
+ }
+ );
+ },
+ /**
+ * @private
+ * @param {integer} id
+ * @param {function} callback
+ * @return {Promise}
+ */
+ _openActivityForm: function (id, callback) {
+ var action = {
+ type: 'ir.actions.act_window',
+ name: _t("Schedule Activity"),
+ res_model: 'mail.activity',
+ view_mode: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: this.res_id,
+ default_res_model: this.model,
+ },
+ res_id: id || false,
+ };
+ return this.do_action(action, { on_close: callback });
+ },
+ /**
+ * @private
+ * @param {integer} activityID
+ * @param {string} feedback
+ * @param {integer[]} attachmentIds
+ * @return {Promise}
+ */
+ _sendActivityFeedback: function (activityID, feedback, attachmentIds) {
+ return this._rpc({
+ model: 'mail.activity',
+ method: 'action_feedback',
+ args: [[activityID]],
+ kwargs: {
+ feedback: feedback,
+ attachment_ids: attachmentIds || [],
+ },
+ context: this.record.getContext(),
+ });
+ },
+
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object[]} activities
+ */
+ _bindOnUploadAction: function (activities) {
+ var self = this;
+ _.each(activities, function (activity) {
+ if (activity.fileuploadID) {
+ $(window).on(activity.fileuploadID, function () {
+ framework.unblockUI();
+ // find the button clicked and display the feedback popup on it
+ var files = Array.prototype.slice.call(arguments, 1);
+ self._markActivityDone({
+ activityID: activity.id,
+ attachmentIds: _.pluck(files, 'id')
+ }).then(function () {
+ self.trigger_up('reload', { keepChanges: true });
+ });
+ });
+ }
+ });
+ },
+ /** Binds a focusout handler on a bootstrap popover
+ * Useful to do some operations on the popover's HTML,
+ * like keeping the user's input for the feedback
+ * @param {JQuery} $popover_el: the element on which
+ * the popover() method has been called
+ */
+ _bindPopoverFocusout: function ($popover_el) {
+ var self = this;
+ // Retrieve the actual popover's HTML
+ var $popover = $($popover_el.data("bs.popover").tip);
+ var activityID = $popover_el.data('activity-id');
+ $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) {
+ self._draftFeedback[activityID] = $popover.find('#activity_feedback').val();
+ $popover.popover('hide');
+ }
+ });
+ },
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {Promise}
+ */
+ _onEditActivity: function (ev) {
+ ev.preventDefault();
+ var activityID = $(ev.currentTarget).data('activity-id');
+ return this._openActivityForm(activityID, this._reload.bind(this, { activity: true, thread: true }));
+ },
+ /**
+ * @private
+ * @param {FormEvent} ev
+ */
+ _onFileChanged: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var $form = $(ev.currentTarget).closest('form');
+ $form.submit();
+ framework.blockUI();
+ },
+ /**
+ * Called when marking an activity as done
+ *
+ * It lets the current user write a feedback in a popup menu.
+ * After writing the feedback and confirm mark as done
+ * is sent, it marks this activity as done for good with the feedback linked
+ * to it.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMarkActivityDone: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var self = this;
+ var $markDoneBtn = $(ev.currentTarget);
+ var activityID = $markDoneBtn.data('activity-id');
+ var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id') || false;
+ var forceNextActivity = $markDoneBtn.data('force-next-activity');
+
+ if ($markDoneBtn.data('toggle') === 'collapse') {
+ var $actLi = $markDoneBtn.parents('.o_log_activity');
+ var $panel = self.$('#o_activity_form_' + activityID);
+
+ if (!$panel.data('bs.collapse')) {
+ var $form = $(QWeb.render('mail.activity_feedback_form', {
+ previous_activity_type_id: previousActivityTypeID,
+ force_next: forceNextActivity
+ }));
+ $panel.append($form);
+ self._onMarkActivityDoneActions($markDoneBtn, $form, activityID);
+
+ // Close and reset any other open panels
+ _.each($panel.siblings('.o_activity_form'), function (el) {
+ if ($(el).data('bs.collapse')) {
+ $(el).empty().collapse('dispose').removeClass('show');
+ }
+ });
+
+ // Scroll to selected activity
+ $markDoneBtn.parents('.o_activity_log_container').scrollTo($actLi.position().top, 100);
+ }
+
+ // Empty and reset panel on close
+ $panel.on('hidden.bs.collapse', function () {
+ if ($panel.data('bs.collapse')) {
+ $actLi.removeClass('o_activity_selected');
+ $panel.collapse('dispose');
+ $panel.empty();
+ }
+ });
+
+ this.$('.o_activity_selected').removeClass('o_activity_selected');
+ $actLi.toggleClass('o_activity_selected');
+ $panel.collapse('toggle');
+
+ } else if (!$markDoneBtn.data('bs.popover')) {
+ $markDoneBtn.popover({
+ template: $(Popover.Default.template).addClass('o_mail_activity_feedback')[0].outerHTML, // Ugly but cannot find another way
+ container: $markDoneBtn,
+ title: _t("Feedback"),
+ html: true,
+ trigger: 'manual',
+ placement: 'right', // FIXME: this should work, maybe a bug in the popper lib
+ content: function () {
+ var $popover = $(QWeb.render('mail.activity_feedback_form', {
+ previous_activity_type_id: previousActivityTypeID,
+ force_next: forceNextActivity
+ }));
+ self._onMarkActivityDoneActions($markDoneBtn, $popover, activityID);
+ return $popover;
+ },
+ }).on('shown.bs.popover', function () {
+ var $popover = $($(this).data("bs.popover").tip);
+ $(".o_mail_activity_feedback.popover").not($popover).popover("hide");
+ $popover.addClass('o_mail_activity_feedback').attr('tabindex', 0);
+ $popover.find('#activity_feedback').focus();
+ self._bindPopoverFocusout($(this));
+ }).popover('show');
+ } else {
+ var popover = $markDoneBtn.data('bs.popover');
+ if ($('#' + popover.tip.id).length === 0) {
+ popover.show();
+ }
+ }
+ },
+ /**
+ * Bind all necessary actions to the 'mark as done' form
+ *
+ * @private
+ * @param {Object} $form
+ * @param {integer} activityID
+ */
+ _onMarkActivityDoneActions: function ($btn, $form, activityID) {
+ var self = this;
+ $form.find('#activity_feedback').val(self._draftFeedback[activityID]);
+ $form.on('click', '.o_activity_popover_done', function (ev) {
+ ev.stopPropagation();
+ self._markActivityDone({
+ activityID: activityID,
+ feedback: $form.find('#activity_feedback').val(),
+ });
+ });
+ $form.on('click', '.o_activity_popover_done_next', function (ev) {
+ ev.stopPropagation();
+ self._markActivityDoneAndScheduleNext({
+ activityID: activityID,
+ feedback: $form.find('#activity_feedback').val(),
+ });
+ });
+ $form.on('click', '.o_activity_popover_discard', function (ev) {
+ ev.stopPropagation();
+ if ($btn.data('bs.popover')) {
+ $btn.popover('hide');
+ } else if ($btn.data('toggle') === 'collapse') {
+ self.$('#o_activity_form_' + activityID).collapse('hide');
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMarkActivityDoneUploadFile: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var fileuploadID = $(ev.currentTarget).data('fileupload-id');
+ var $input = this.$("[target='" + fileuploadID + "'] > input.o_input_file");
+ $input.click();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {Promise}
+ */
+ _onPreviewMailTemplate: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var self = this;
+ var templateID = $(ev.currentTarget).data('template-id');
+ var action = {
+ name: _t('Compose Email'),
+ type: 'ir.actions.act_window',
+ res_model: 'mail.compose.message',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: this.res_id,
+ default_model: this.model,
+ default_use_template: true,
+ default_template_id: templateID,
+ force_email: true,
+ },
+ };
+ return this.do_action(action, { on_close: function () {
+ self.trigger_up('reload', { keepChanges: true });
+ } });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {Promise}
+ */
+ _onSendMailTemplate: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var templateID = $(ev.currentTarget).data('template-id');
+ return this._rpc({
+ model: this.model,
+ method: 'activity_send_mail',
+ args: [[this.res_id], templateID],
+ })
+ .then(this._reload.bind(this, {activity: true, thread: true, followers: true}));
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @returns {Promise}
+ */
+ _onScheduleActivity: function (ev) {
+ ev.preventDefault();
+ return this._openActivityForm(false, this._reload.bind(this));
+ },
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ * @param {Object} options
+ * @returns {Promise}
+ */
+ _onUnlinkActivity: function (ev, options) {
+ ev.preventDefault();
+ var activityID = $(ev.currentTarget).data('activity-id');
+ options = _.defaults(options || {}, {
+ model: 'mail.activity',
+ args: [[activityID]],
+ });
+ return this._rpc({
+ model: options.model,
+ method: 'unlink',
+ args: options.args,
+ })
+ .then(this._reload.bind(this, {activity: true}));
+ },
+ /**
+ * Unbind event triggered when a file is uploaded.
+ *
+ * @private
+ * @param {Array} activities: list of activity to unbind
+ */
+ _unbindOnUploadAction: function (activities) {
+ _.each(activities, function (activity) {
+ if (activity.fileuploadID) {
+ $(window).off(activity.fileuploadID);
+ }
+ });
+ },
+});
+
+// -----------------------------------------------------------------------------
+// Activities Widget for Form views ('mail_activity' widget)
+// -----------------------------------------------------------------------------
+// FIXME seems to still be needed in some cases like systray
+var Activity = BasicActivity.extend({
+ className: 'o_mail_activity',
+ events: _.extend({}, BasicActivity.prototype.events, {
+ 'click a': '_onClickRedirect',
+ }),
+ specialData: '_fetchSpecialActivity',
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._activities = this.record.specialData[this.name];
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._unbindOnUploadAction();
+ return this._super.apply(this, arguments);
+ },
+
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+ /**
+ * @private
+ * @param {Object} fieldsToReload
+ */
+ _reload: function (fieldsToReload) {
+ this.trigger_up('reload_mail_fields', fieldsToReload);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ _.each(this._activities, function (activity) {
+ var note = mailUtils.parseAndTransform(activity.note || '', mailUtils.inline);
+ var is_blank = (/^\s*$/).test(note);
+ if (!is_blank) {
+ activity.note = mailUtils.parseAndTransform(activity.note, mailUtils.addLink);
+ } else {
+ activity.note = '';
+ }
+ });
+ var activities = setFileUploadID(setDelayLabel(this._activities));
+ if (activities.length) {
+ var nbActivities = _.countBy(activities, 'state');
+ this.$el.html(QWeb.render('mail.activity_items', {
+ uid: session.uid,
+ activities: activities,
+ nbPlannedActivities: nbActivities.planned,
+ nbTodayActivities: nbActivities.today,
+ nbOverdueActivities: nbActivities.overdue,
+ dateFormat: time.getLangDateFormat(),
+ datetimeFormat: time.getLangDatetimeFormat(),
+ session: session,
+ widget: this,
+ }));
+ this._bindOnUploadAction(this._activities);
+ } else {
+ this._unbindOnUploadAction(this._activities);
+ this.$el.empty();
+ }
+ },
+ /**
+ * @override
+ * @private
+ * @param {Object} record
+ */
+ _reset: function (record) {
+ this._super.apply(this, arguments);
+ 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;
+ },
+
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickRedirect: function (ev) {
+ var id = $(ev.currentTarget).data('oe-id');
+ if (id) {
+ ev.preventDefault();
+ var model = $(ev.currentTarget).data('oe-model');
+ this.trigger_up('redirect', {
+ res_id: id,
+ res_model: model,
+ });
+ }
+ },
+
+});
+
+// -----------------------------------------------------------------------------
+// Activities Widget for Kanban views ('kanban_activity' widget)
+// -----------------------------------------------------------------------------
+var KanbanActivity = BasicActivity.extend({
+ template: 'mail.KanbanActivity',
+ events: _.extend({}, BasicActivity.prototype.events, {
+ 'show.bs.dropdown': '_onDropdownShow',
+ }),
+ fieldDependencies: _.extend({}, BasicActivity.prototype.fieldDependencies, {
+ activity_exception_decoration: {type: 'selection'},
+ activity_exception_icon: {type: 'char'},
+ activity_state: {type: 'selection'},
+ }),
+
+ /**
+ * @override
+ */
+ init: function (parent, name, record) {
+ this._super.apply(this, arguments);
+ var selection = {};
+ _.each(record.fields.activity_state.selection, function (value) {
+ selection[value[0]] = value[1];
+ });
+ this.selection = selection;
+ this._setState(record);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._unbindOnUploadAction();
+ return this._super.apply(this, arguments);
+ },
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _reload: function () {
+ this.trigger_up('reload', { db_id: this.record_id, keepChanges: true });
+ },
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ // span classes need to be updated manually because the template cannot
+ // be re-rendered eaasily (because of the dropdown state)
+ const spanClasses = ['fa', 'fa-lg', 'fa-fw'];
+ spanClasses.push('o_activity_color_' + (this.activityState || 'default'));
+ if (this.recordData.activity_exception_decoration) {
+ spanClasses.push('text-' + this.recordData.activity_exception_decoration);
+ spanClasses.push(this.recordData.activity_exception_icon);
+ } else {
+ spanClasses.push('fa-clock-o');
+ }
+ this.$('.o_activity_btn > span').removeClass().addClass(spanClasses.join(' '));
+
+ if (this.$el.hasClass('show')) {
+ // note: this part of the rendering might be asynchronous
+ this._renderDropdown();
+ }
+ },
+ /**
+ * @private
+ */
+ _renderDropdown: function () {
+ var self = this;
+ this.$('.o_activity')
+ .toggleClass('dropdown-menu-right', config.device.isMobile)
+ .html(QWeb.render('mail.KanbanActivityLoading'));
+ return _readActivities(this, this.value.res_ids).then(function (activities) {
+ activities = setFileUploadID(activities);
+ self.$('.o_activity').html(QWeb.render('mail.KanbanActivityDropdown', {
+ selection: self.selection,
+ records: _.groupBy(setDelayLabel(activities), 'state'),
+ session: session,
+ widget: self,
+ }));
+ self._bindOnUploadAction(activities);
+ });
+ },
+ /**
+ * @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.activityState = this.recordData.activity_state;
+ },
+
+ //------------------------------------------------------------
+ // Handlers
+ //------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onDropdownShow: function () {
+ this._renderDropdown();
+ },
+});
+
+// -----------------------------------------------------------------------------
+// Activities Widget for List views ('list_activity' widget)
+// -----------------------------------------------------------------------------
+const ListActivity = KanbanActivity.extend({
+ template: 'mail.ListActivity',
+ events: Object.assign({}, KanbanActivity.prototype.events, {
+ 'click .dropdown-menu.o_activity': '_onDropdownClicked',
+ }),
+ fieldDependencies: _.extend({}, KanbanActivity.prototype.fieldDependencies, {
+ activity_summary: {type: 'char'},
+ activity_type_id: {type: 'many2one', relation: 'mail.activity.type'},
+ activity_type_icon: {type: 'char'},
+ }),
+ label: _lt('Next Activity'),
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: async function () {
+ await this._super(...arguments);
+ // set the 'special_click' prop on the activity icon to prevent from
+ // opening the record when the user clicks on it (as it opens the
+ // activity dropdown instead)
+ this.$('.o_activity_btn > span').prop('special_click', true);
+ if (this.value.count) {
+ let text;
+ if (this.recordData.activity_exception_decoration) {
+ text = _t('Warning');
+ } else {
+ text = this.recordData.activity_summary ||
+ this.recordData.activity_type_id.data.display_name;
+ }
+ this.$('.o_activity_summary').text(text);
+ }
+ if (this.recordData.activity_type_icon) {
+ this.el.querySelector('.o_activity_btn > span').classList.replace('fa-clock-o', this.recordData.activity_type_icon);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * As we are in a list view, we don't want clicks inside the activity
+ * dropdown to open the record in a form view.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDropdownClicked: function (ev) {
+ ev.stopPropagation();
+ },
+});
+
+// -----------------------------------------------------------------------------
+// Activity Exception Widget to display Exception icon ('activity_exception' widget)
+// -----------------------------------------------------------------------------
+
+var ActivityException = AbstractField.extend({
+ noLabel: true,
+ fieldDependencies: _.extend({}, AbstractField.prototype.fieldDependencies, {
+ activity_exception_icon: {type: 'char'}
+ }),
+
+ //------------------------------------------------------------
+ // Private
+ //------------------------------------------------------------
+
+ /**
+ * There is no edit mode for this widget, the icon is always readonly.
+ *
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ return this._renderReadonly();
+ },
+
+ /**
+ * Displays the exception icon if there is one.
+ *
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.empty();
+ if (this.value) {
+ this.$el.attr({
+ title: _t('This record has an exception activity.'),
+ class: "pull-right mt-1 text-" + this.value + " fa " + this.recordData.activity_exception_icon
+ });
+ }
+ }
+});
+
+field_registry
+ .add('kanban_activity', KanbanActivity)
+ .add('list_activity', ListActivity)
+ .add('activity_exception', ActivityException);
+
+return Activity;
+
+});
diff --git a/addons/mail/static/src/js/basic_view.js b/addons/mail/static/src/js/basic_view.js
new file mode 100644
index 00000000..4c5c9a12
--- /dev/null
+++ b/addons/mail/static/src/js/basic_view.js
@@ -0,0 +1,68 @@
+odoo.define('mail.BasicView', function (require) {
+"use strict";
+
+const BasicView = require('web.BasicView');
+
+const mailWidgets = ['kanban_activity'];
+
+BasicView.include({
+ init: function () {
+ this._super.apply(this, arguments);
+ const post_refresh = this._getFieldOption('message_ids', 'post_refresh', false);
+ const followers_post_refresh = this._getFieldOption('message_follower_ids', 'post_refresh', false);
+ this.chatterFields = {
+ hasActivityIds: this._hasField('activity_ids'),
+ hasMessageFollowerIds: this._hasField('message_follower_ids'),
+ hasMessageIds: this._hasField('message_ids'),
+ hasRecordReloadOnAttachmentsChanged: post_refresh === 'always',
+ hasRecordReloadOnMessagePosted: !!post_refresh,
+ hasRecordReloadOnFollowersUpdate: !!followers_post_refresh,
+ isAttachmentBoxVisibleInitially: (
+ this._getFieldOption('message_ids', 'open_attachments', false) ||
+ this._getFieldOption('message_follower_ids', 'open_attachments', false)
+ ),
+ };
+ const fieldsInfo = this.fieldsInfo[this.viewType];
+ this.rendererParams.chatterFields = this.chatterFields;
+
+ // LEGACY for widget kanban_activity
+ this.mailFields = {};
+ for (const fieldName in fieldsInfo) {
+ const fieldInfo = fieldsInfo[fieldName];
+ if (_.contains(mailWidgets, fieldInfo.widget)) {
+ this.mailFields[fieldInfo.widget] = fieldName;
+ fieldInfo.__no_fetch = true;
+ }
+ }
+ this.rendererParams.activeActions = this.controllerParams.activeActions;
+ this.rendererParams.mailFields = this.mailFields;
+ },
+ /**
+ * Gets the option value of a field if present.
+ *
+ * @private
+ * @param {string} fieldName the desired field name
+ * @param {string} optionName the desired option name
+ * @param {*} defaultValue the default value if option or field is not found.
+ * @returns {*}
+ */
+ _getFieldOption(fieldName, optionName, defaultValue) {
+ const field = this.fieldsInfo[this.viewType][fieldName];
+ if (field && field.options && field.options[optionName] !== undefined) {
+ return field.options[optionName];
+ }
+ return defaultValue;
+ },
+ /**
+ * Checks whether the view has a given field.
+ *
+ * @private
+ * @param {string} fieldName the desired field name
+ * @returns {boolean}
+ */
+ _hasField(fieldName) {
+ return !!this.fieldsInfo[this.viewType][fieldName];
+ },
+});
+
+});
diff --git a/addons/mail/static/src/js/core/translation.js b/addons/mail/static/src/js/core/translation.js
new file mode 100644
index 00000000..faecafaf
--- /dev/null
+++ b/addons/mail/static/src/js/core/translation.js
@@ -0,0 +1,28 @@
+odoo.define('mail/static/src/js/core/translation.js', function (require) {
+'use strict';
+
+const { TranslationDataBase } = require('web.translation');
+
+const { Component } = owl;
+
+TranslationDataBase.include({
+ /**
+ * @override
+ */
+ set_bundle() {
+ const res = this._super(...arguments);
+ if (Component.env.messaging) {
+ // Update messaging locale whenever the translation bundle changes.
+ // In particular if messaging is created before the end of the
+ // `load_translations` RPC, the default values have to be
+ // updated by the received ones.
+ Component.env.messaging.locale.update({
+ language: this.parameters.code,
+ textDirection: this.parameters.direction,
+ });
+ }
+ return res;
+ },
+});
+
+});
diff --git a/addons/mail/static/src/js/custom_filter_item.js b/addons/mail/static/src/js/custom_filter_item.js
new file mode 100644
index 00000000..abe15eda
--- /dev/null
+++ b/addons/mail/static/src/js/custom_filter_item.js
@@ -0,0 +1,21 @@
+odoo.define('mail.CustomFilterItem', function (require) {
+ "use strict";
+
+ const CustomFilterItem = require('web.CustomFilterItem');
+
+ CustomFilterItem.patch('mail.CustomFilterItem', T => class extends T {
+
+ /**
+ * With the `mail` module installed, we want to filter out some of the
+ * available fields in 'Add custom filter' menu (@see CustomFilterItem).
+ * @override
+ */
+ _validateField(field) {
+ return super._validateField(...arguments) &&
+ field.relation !== 'mail.message' &&
+ field.name !== 'message_ids';
+ }
+ });
+
+ return CustomFilterItem;
+});
diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js
new file mode 100644
index 00000000..b46aea30
--- /dev/null
+++ b/addons/mail/static/src/js/document_viewer.js
@@ -0,0 +1,396 @@
+odoo.define('mail.DocumentViewer', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+var SCROLL_ZOOM_STEP = 0.1;
+var ZOOM_STEP = 0.5;
+
+/**
+ * This widget is deprecated, and should instead use AttachmentViewer component.
+ * @see `mail/static/src/components/attachment_viewer/attachment_viewer.js`
+ * TODO: remove this widget when it's not longer used
+ *
+ * @deprecated
+ */
+var DocumentViewer = Widget.extend({
+ template: "DocumentViewer",
+ events: {
+ 'click .o_download_btn': '_onDownload',
+ 'click .o_viewer_img': '_onImageClicked',
+ 'click .o_viewer_video': '_onVideoClicked',
+ 'click .move_next': '_onNext',
+ 'click .move_previous': '_onPrevious',
+ 'click .o_rotate': '_onRotate',
+ 'click .o_zoom_in': '_onZoomIn',
+ 'click .o_zoom_out': '_onZoomOut',
+ 'click .o_zoom_reset': '_onZoomReset',
+ 'click .o_close_btn, .o_viewer_img_wrapper': '_onClose',
+ 'click .o_print_btn': '_onPrint',
+ 'DOMMouseScroll .o_viewer_content': '_onScroll', // Firefox
+ 'mousewheel .o_viewer_content': '_onScroll', // Chrome, Safari, IE
+ 'keydown': '_onKeydown',
+ 'keyup': '_onKeyUp',
+ 'mousedown .o_viewer_img': '_onStartDrag',
+ 'mousemove .o_viewer_content': '_onDrag',
+ 'mouseup .o_viewer_content': '_onEndDrag'
+ },
+ /**
+ * The documentViewer takes an array of objects describing attachments in
+ * argument, and the ID of an active attachment (the one to display first).
+ * Documents that are not of type image or video are filtered out.
+ *
+ * @override
+ * @param {Array<Object>} attachments list of attachments
+ * @param {integer} activeAttachmentID
+ */
+ init: function (parent, attachments, activeAttachmentID) {
+ this._super.apply(this, arguments);
+ this.attachment = _.filter(attachments, function (attachment) {
+ var match = attachment.type === 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)");
+ if (match) {
+ attachment.fileType = match[1];
+ if (match[1].match("(.png|.jpg|.gif)")) {
+ attachment.fileType = 'image';
+ }
+ if (match[1] === 'youtu') {
+ var youtube_array = attachment.url.split('/');
+ var youtube_token = youtube_array[youtube_array.length-1];
+ if (youtube_token.indexOf('watch') !== -1) {
+ youtube_token = youtube_token.split('v=')[1];
+ var amp = youtube_token.indexOf('&')
+ if (amp !== -1){
+ youtube_token = youtube_token.substring(0, amp);
+ }
+ }
+ attachment.youtube = youtube_token;
+ }
+ return true;
+ }
+ });
+ this.activeAttachment = _.findWhere(attachments, {id: activeAttachmentID});
+ this.modelName = 'ir.attachment';
+ this._reset();
+ },
+ /**
+ * Open a modal displaying the active attachment
+ * @override
+ */
+ start: function () {
+ this.$el.modal('show');
+ this.$el.on('hidden.bs.modal', _.bind(this._onDestroy, this));
+ this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this));
+ this.$('[data-toggle="tooltip"]').tooltip({delay: 0});
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.isDestroyed()) {
+ return;
+ }
+ this.$el.modal('hide');
+ this.$el.remove();
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _next: function () {
+ var index = _.findIndex(this.attachment, this.activeAttachment);
+ index = (index + 1) % this.attachment.length;
+ this.activeAttachment = this.attachment[index];
+ this._updateContent();
+ },
+ /**
+ * @private
+ */
+ _previous: function () {
+ var index = _.findIndex(this.attachment, this.activeAttachment);
+ index = index === 0 ? this.attachment.length - 1 : index - 1;
+ this.activeAttachment = this.attachment[index];
+ this._updateContent();
+ },
+ /**
+ * @private
+ */
+ _reset: function () {
+ this.scale = 1;
+ this.dragStartX = this.dragstopX = 0;
+ this.dragStartY = this.dragstopY = 0;
+ },
+ /**
+ * Render the active attachment
+ *
+ * @private
+ */
+ _updateContent: function () {
+ this.$('.o_viewer_content').html(QWeb.render('DocumentViewer.Content', {
+ widget: this
+ }));
+ this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this));
+ this.$('[data-toggle="tooltip"]').tooltip({delay: 0});
+ this._reset();
+ },
+ /**
+ * Get CSS transform property based on scale and angle
+ *
+ * @private
+ * @param {float} scale
+ * @param {float} angle
+ */
+ _getTransform: function(scale, angle) {
+ return 'scale3d(' + scale + ', ' + scale + ', 1) rotate(' + angle + 'deg)';
+ },
+ /**
+ * Rotate image clockwise by provided angle
+ *
+ * @private
+ * @param {float} angle
+ */
+ _rotate: function (angle) {
+ this._reset();
+ var new_angle = (this.angle || 0) + angle;
+ this.$('.o_viewer_img').css('transform', this._getTransform(this.scale, new_angle));
+ this.$('.o_viewer_img').css('max-width', new_angle % 180 !== 0 ? $(document).height() : '100%');
+ this.$('.o_viewer_img').css('max-height', new_angle % 180 !== 0 ? $(document).width() : '100%');
+ this.angle = new_angle;
+ },
+ /**
+ * Zoom in/out image by provided scale
+ *
+ * @private
+ * @param {integer} scale
+ */
+ _zoom: function (scale) {
+ if (scale > 0.5) {
+ this.$('.o_viewer_img').css('transform', this._getTransform(scale, this.angle || 0));
+ this.scale = scale;
+ }
+ this.$('.o_zoom_reset').add('.o_zoom_out').toggleClass('disabled', scale === 1);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onClose: function (e) {
+ e.preventDefault();
+ this.destroy();
+ },
+ /**
+ * When popup close complete destroyed modal even DOM footprint too
+ *
+ * @private
+ */
+ _onDestroy: function () {
+ this.destroy();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onDownload: function (e) {
+ e.preventDefault();
+ window.location = '/web/content/' + this.modelName + '/' + this.activeAttachment.id + '/' + 'datas' + '?download=true';
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onDrag: function (e) {
+ e.preventDefault();
+ if (this.enableDrag) {
+ var $image = this.$('.o_viewer_img');
+ var $zoomer = this.$('.o_viewer_zoomer');
+ var top = $image.prop('offsetHeight') * this.scale > $zoomer.height() ? e.clientY - this.dragStartY : 0;
+ var left = $image.prop('offsetWidth') * this.scale > $zoomer.width() ? e.clientX - this.dragStartX : 0;
+ $zoomer.css("transform", "translate3d("+ left +"px, " + top + "px, 0)");
+ $image.css('cursor', 'move');
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onEndDrag: function (e) {
+ e.preventDefault();
+ if (this.enableDrag) {
+ this.enableDrag = false;
+ this.dragstopX = e.clientX - this.dragStartX;
+ this.dragstopY = e.clientY - this.dragStartY;
+ this.$('.o_viewer_img').css('cursor', '');
+ }
+ },
+ /**
+ * On click of image do not close modal so stop event propagation
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onImageClicked: function (e) {
+ e.stopPropagation();
+ },
+ /**
+ * Remove loading indicator when image loaded
+ * @private
+ */
+ _onImageLoaded: function () {
+ this.$('.o_loading_img').hide();
+ },
+ /**
+ * Move next previous attachment on keyboard right left key
+ *
+ * @private
+ * @param {KeyEvent} e
+ */
+ _onKeydown: function (e){
+ switch (e.which) {
+ case $.ui.keyCode.RIGHT:
+ e.preventDefault();
+ this._next();
+ break;
+ case $.ui.keyCode.LEFT:
+ e.preventDefault();
+ this._previous();
+ break;
+ }
+ },
+ /**
+ * Close popup on ESCAPE keyup
+ *
+ * @private
+ * @param {KeyEvent} e
+ */
+ _onKeyUp: function (e) {
+ switch (e.which) {
+ case $.ui.keyCode.ESCAPE:
+ e.preventDefault();
+ this._onClose(e);
+ break;
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onNext: function (e) {
+ e.preventDefault();
+ this._next();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onPrevious: function (e) {
+ e.preventDefault();
+ this._previous();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onPrint: function (e) {
+ e.preventDefault();
+ var src = this.$('.o_viewer_img').prop('src');
+ var script = QWeb.render('PrintImage', {
+ src: src
+ });
+ var printWindow = window.open('about:blank', "_new");
+ printWindow.document.open();
+ printWindow.document.write(script);
+ printWindow.document.close();
+ },
+ /**
+ * Zoom image on scroll
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onScroll: function (e) {
+ var scale;
+ if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) {
+ scale = this.scale + SCROLL_ZOOM_STEP;
+ this._zoom(scale);
+ } else {
+ scale = this.scale - SCROLL_ZOOM_STEP;
+ this._zoom(scale);
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onStartDrag: function (e) {
+ e.preventDefault();
+ this.enableDrag = true;
+ this.dragStartX = e.clientX - (this.dragstopX || 0);
+ this.dragStartY = e.clientY - (this.dragstopY || 0);
+ },
+ /**
+ * On click of video do not close modal so stop event propagation
+ * and provide play/pause the video instead of quitting it
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onVideoClicked: function (e) {
+ e.stopPropagation();
+ var videoElement = e.target;
+ if (videoElement.paused) {
+ videoElement.play();
+ } else {
+ videoElement.pause();
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onRotate: function (e) {
+ e.preventDefault();
+ this._rotate(90);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onZoomIn: function (e) {
+ e.preventDefault();
+ var scale = this.scale + ZOOM_STEP;
+ this._zoom(scale);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onZoomOut: function (e) {
+ e.preventDefault();
+ var scale = this.scale - ZOOM_STEP;
+ this._zoom(scale);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onZoomReset: function (e) {
+ e.preventDefault();
+ this.$('.o_viewer_zoomer').css("transform", "");
+ this._zoom(1);
+ },
+});
+return DocumentViewer;
+});
diff --git a/addons/mail/static/src/js/emojis.js b/addons/mail/static/src/js/emojis.js
new file mode 100644
index 00000000..139bcdae
--- /dev/null
+++ b/addons/mail/static/src/js/emojis.js
@@ -0,0 +1,155 @@
+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": "❤️",
+ "&lt;3": "❤️",
+ ":heart": "❤️", // alt.
+ "</3": "💔",
+ "&lt;/3": "💔",
+ ":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": "🎸",
+ ":run": "🏃",
+ ":bike": "🚲",
+ ":soccer": "⚽️",
+ ":football": "🏈",
+ ":8ball": "🎱",
+ ":clapper": "🎬",
+ ":microphone": "🎤",
+ ":cheese": "🧀",
+};
+
+// 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/emojis_mixin.js b/addons/mail/static/src/js/emojis_mixin.js
new file mode 100644
index 00000000..aa535d1d
--- /dev/null
+++ b/addons/mail/static/src/js/emojis_mixin.js
@@ -0,0 +1,91 @@
+odoo.define('mail.emoji_mixin', function (require) {
+"use strict";
+
+var emojis = require('mail.emojis');
+
+/**
+ * This mixin gathers a few methods that are used to handle emojis.
+ *
+ * It's used to:
+ *
+ * - handle the click on an emoji from a dropdown panel and add it to the related textarea/input
+ * - format text and wrap the emojis around <span class="o_mail_emoji"> to make them look nicer
+ *
+ * Methods are based on the collections of emojis available in mail.emojis
+ *
+ */
+return {
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method should be bound to a click event on an emoji.
+ * (used in text element's emojis dropdown list)
+ *
+ * It assumes that a ``_getTargetTextElement`` method is defined that will return the related
+ * textarea/input element in which the emoji will be inserted.
+ *
+ * @param {MouseEvent} ev
+ */
+ _onEmojiClick: function (ev) {
+ var unicode = ev.currentTarget.textContent.trim();
+ var textInput = this._getTargetTextElement($(ev.currentTarget))[0];
+ var selectionStart = textInput.selectionStart;
+
+ textInput.value = textInput.value.slice(0, selectionStart) + unicode + textInput.value.slice(selectionStart);
+ textInput.focus();
+ textInput.setSelectionRange(selectionStart + unicode.length, selectionStart + unicode.length);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * This method is used to wrap emojis in a text message with <span class="o_mail_emoji">
+ * As this returns html to be used in a 't-raw' argument, it first makes sure that the
+ * passed text message is html escaped for safety reasons.
+ *
+ * @param {String} message a text message to format
+ */
+ _formatText: function (message) {
+ message = this._htmlEscape(message);
+ message = this._wrapEmojis(message);
+ message = message.replace(/(?:\r\n|\r|\n)/g, '<br>');
+
+ return message;
+ },
+
+ /**
+ * Adapted from qweb2.js#html_escape to avoid formatting '&'
+ *
+ * @param {String} s
+ * @private
+ */
+ _htmlEscape: function (s) {
+ if (s == null) {
+ return '';
+ }
+ return String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;');
+ },
+
+ /**
+ * Will use the mail.emojis library to wrap emojis unicode around a span with a special font
+ * that will make them look nicer (colored, ...).
+ *
+ * @param {String} message
+ */
+ _wrapEmojis: function (message) {
+ emojis.forEach(function (emoji) {
+ message = message.replace(
+ new RegExp(emoji.unicode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
+ '<span class="o_mail_emoji">' + emoji.unicode + '</span>'
+ );
+ });
+
+ return message;
+ }
+};
+
+});
diff --git a/addons/mail/static/src/js/field_char.js b/addons/mail/static/src/js/field_char.js
new file mode 100644
index 00000000..1a1b90ec
--- /dev/null
+++ b/addons/mail/static/src/js/field_char.js
@@ -0,0 +1,56 @@
+odoo.define('sms.onchange_in_keyup', function (require) {
+"use strict";
+
+var FieldChar = require('web.basic_fields').FieldChar;
+FieldChar.include({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //-------------------------------------------------------------------------
+
+ /**
+ * Support a key-based onchange in text field. In order to avoid too much
+ * rpc to the server _triggerOnchange is throttled (once every second max)
+ *
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._triggerOnchange = _.throttle(this._triggerOnchange, 1000, {leading: false});
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Trigger the 'change' event at key down. It allows to trigger an onchange
+ * while typing which may be interesting in some cases. Otherwise onchange
+ * is triggered only on blur.
+ *
+ * @override
+ * @private
+ */
+ _onKeydown: function () {
+ this._super.apply(this, arguments);
+ if (this.nodeOptions.onchange_on_keydown) {
+ this._triggerOnchange();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Triggers the 'change' event to refresh the value. Throttled at init to
+ * avoid spaming server.
+ *
+ * @private
+ */
+ _triggerOnchange: function () {
+ this.$input.trigger('change');
+ },
+});
+
+});
diff --git a/addons/mail/static/src/js/field_char_emojis.js b/addons/mail/static/src/js/field_char_emojis.js
new file mode 100644
index 00000000..012a68c2
--- /dev/null
+++ b/addons/mail/static/src/js/field_char_emojis.js
@@ -0,0 +1,18 @@
+odoo.define('mail.field_char_emojis', function (require) {
+"use strict";
+
+var basicFields = require('web.basic_fields');
+var registry = require('web.field_registry');
+var FieldEmojiCommon = require('mail.field_emojis_common');
+var MailEmojisMixin = require('mail.emoji_mixin');
+
+/**
+ * Extension of the FieldChar that will add emojis support
+ */
+var FieldCharEmojis = basicFields.FieldChar.extend(MailEmojisMixin, FieldEmojiCommon);
+
+registry.add('char_emojis', FieldCharEmojis);
+
+return FieldCharEmojis;
+
+});
diff --git a/addons/mail/static/src/js/field_emojis_common.js b/addons/mail/static/src/js/field_emojis_common.js
new file mode 100644
index 00000000..79215ac7
--- /dev/null
+++ b/addons/mail/static/src/js/field_emojis_common.js
@@ -0,0 +1,136 @@
+odoo.define('mail.field_emojis_common', function (require) {
+"use strict";
+
+var basicFields = require('web.basic_fields');
+var core = require('web.core');
+var emojis = require('mail.emojis');
+var MailEmojisMixin = require('mail.emoji_mixin');
+var _onEmojiClickMixin = MailEmojisMixin._onEmojiClick;
+var QWeb = core.qweb;
+
+/*
+ * Common code for FieldTextEmojis and FieldCharEmojis
+ */
+var FieldEmojiCommon = {
+ /**
+ * @override
+ * @private
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._triggerOnchange = _.throttle(this._triggerOnchange, 1000, {leading: false});
+ this.emojis = emojis;
+ },
+
+ /**
+ * @override
+ */
+ on_attach_callback: function () {
+ this._attachEmojisDropdown();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ this._super.apply(this, arguments);
+
+ if (this.mode !== 'edit') {
+ this.$el.html(this._formatText(this.$el.text()));
+ }
+ },
+
+ /**
+ * Overridden because we need to add the Emoji to the input AND trigger
+ * the 'change' event to refresh the value.
+ *
+ * @override
+ * @private
+ */
+ _onEmojiClick: function () {
+ _onEmojiClickMixin.apply(this, arguments);
+ this._isDirty = true;
+ this.$input.trigger('change');
+ },
+
+ /**
+ *
+ * By default, the 'change' event is only triggered when the text element is blurred.
+ *
+ * We override this method because we want to update the value while
+ * the user is typing his message (and not only on blur).
+ *
+ * @override
+ * @private
+ */
+ _onKeydown: function () {
+ this._super.apply(this, arguments);
+ if (this.nodeOptions.onchange_on_keydown) {
+ this._triggerOnchange();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Used by MailEmojisMixin, check its document for more info.
+ *
+ * @private
+ */
+ _getTargetTextElement() {
+ return this.$el;
+ },
+
+ /**
+ * Triggers the 'change' event to refresh the value.
+ * This method is throttled to run at most once every second.
+ * (to avoid spamming the server while the user is typing his message)
+ *
+ * @private
+ */
+ _triggerOnchange: function () {
+ this.$input.trigger('change');
+ },
+
+ /**
+ * This will add an emoji button that shows the emojis selection dropdown.
+ *
+ * Should be used inside 'on_attach_callback' because we need the element to be attached to the form first.
+ * That's because the $emojisIcon element needs to be rendered outside of this $el
+ * (which is an text element, that can't 'contain' any other elements).
+ *
+ * @private
+ */
+ _attachEmojisDropdown: function () {
+ if (!this.$emojisIcon) {
+ this.$emojisIcon = $(QWeb.render('mail.EmojisDropdown', {widget: this}));
+ this.$emojisIcon.find('.o_mail_emoji').on('click', this._onEmojiClick.bind(this));
+
+ if (this.$el.filter('span.o_field_translate').length) {
+ // multi-languages activated, place the button on the left of the translation button
+ this.$emojisIcon.addClass('o_mail_emojis_dropdown_translation');
+ }
+ if (this.$el.filter('textarea').length) {
+ this.$emojisIcon.addClass('o_mail_emojis_dropdown_textarea');
+ }
+ this.$el.last().after(this.$emojisIcon);
+ }
+
+ if (this.mode === 'edit') {
+ this.$emojisIcon.show();
+ } else {
+ this.$emojisIcon.hide();
+ }
+ }
+};
+
+return FieldEmojiCommon;
+
+});
diff --git a/addons/mail/static/src/js/field_text_emojis.js b/addons/mail/static/src/js/field_text_emojis.js
new file mode 100644
index 00000000..8ed14ecf
--- /dev/null
+++ b/addons/mail/static/src/js/field_text_emojis.js
@@ -0,0 +1,18 @@
+odoo.define('mail.field_text_emojis', function (require) {
+"use strict";
+
+var basicFields = require('web.basic_fields');
+var registry = require('web.field_registry');
+var FieldEmojiCommon = require('mail.field_emojis_common');
+var MailEmojisMixin = require('mail.emoji_mixin');
+
+/**
+ * Extension of the FieldText that will add emojis support
+ */
+var FieldTextEmojis = basicFields.FieldText.extend(MailEmojisMixin, FieldEmojiCommon);
+
+registry.add('text_emojis', FieldTextEmojis);
+
+return FieldTextEmojis;
+
+});
diff --git a/addons/mail/static/src/js/main.js b/addons/mail/static/src/js/main.js
new file mode 100644
index 00000000..d19a8edb
--- /dev/null
+++ b/addons/mail/static/src/js/main.js
@@ -0,0 +1,126 @@
+odoo.define('mail/static/src/js/main.js', function (require) {
+'use strict';
+
+const ModelManager = require('mail/static/src/model/model_manager.js');
+
+const env = require('web.commonEnv');
+
+const { Store } = owl;
+const { EventBus } = owl.core;
+
+async function createMessaging() {
+ await new Promise(resolve => {
+ /**
+ * Called when all JS resources are loaded. This is useful in order
+ * to do some processing after other JS files have been parsed, for
+ * example new models or patched models that are coming from
+ * other modules, because some of those patches might need to be
+ * applied before messaging initialization.
+ */
+ window.addEventListener('load', resolve);
+ });
+ /**
+ * All JS resources are loaded, but not necessarily processed.
+ * We assume no messaging-related modules return any Promise,
+ * therefore they should be processed *at most* asynchronously at
+ * "Promise time".
+ */
+ await new Promise(resolve => setTimeout(resolve));
+ /**
+ * Some models require session data, like locale text direction (depends on
+ * fully loaded translation).
+ */
+ await env.session.is_bound;
+
+ env.modelManager.start();
+ /**
+ * Create the messaging singleton record.
+ */
+ env.messaging = env.models['mail.messaging'].create();
+}
+
+/**
+ * Messaging store
+ */
+const store = new Store({
+ env,
+ state: {
+ messagingRevNumber: 0,
+ },
+});
+
+/**
+ * Registry of models.
+ */
+env.models = {};
+/**
+ * Environment keys used in messaging.
+ */
+Object.assign(env, {
+ autofetchPartnerImStatus: true,
+ destroyMessaging() {
+ if (env.modelManager) {
+ env.modelManager.deleteAll();
+ env.messaging = undefined;
+ }
+ },
+ disableAnimation: false,
+ isMessagingInitialized() {
+ if (!this.messaging) {
+ return false;
+ }
+ return this.messaging.isInitialized;
+ },
+ /**
+ * States whether the environment is in QUnit test or not.
+ *
+ * Useful to prevent some behaviour in QUnit tests, like applying
+ * style of attachment that uses url.
+ */
+ isQUnitTest: false,
+ loadingBaseDelayDuration: 400,
+ messaging: undefined,
+ messagingBus: new EventBus(),
+ /**
+ * Promise which becomes resolved when messaging is created.
+ *
+ * Useful for discuss widget to know when messaging is created, because this
+ * is an essential condition to make it work.
+ */
+ messagingCreatedPromise: createMessaging(),
+ modelManager: new ModelManager(env),
+ store,
+});
+
+/**
+ * Components cannot use web.bus, because they cannot use
+ * EventDispatcherMixin, and webclient cannot easily access env.
+ * Communication between webclient and components by core.bus
+ * (usable by webclient) and messagingBus (usable by components), which
+ * the messaging service acts as mediator since it can easily use both
+ * kinds of buses.
+ */
+env.bus.on(
+ 'hide_home_menu',
+ null,
+ () => env.messagingBus.trigger('hide_home_menu')
+);
+env.bus.on(
+ 'show_home_menu',
+ null,
+ () => env.messagingBus.trigger('show_home_menu')
+);
+env.bus.on(
+ 'will_hide_home_menu',
+ null,
+ () => env.messagingBus.trigger('will_hide_home_menu')
+);
+env.bus.on(
+ 'will_show_home_menu',
+ null,
+ () => env.messagingBus.trigger('will_show_home_menu')
+);
+
+env.messagingCreatedPromise.then(() => env.messaging.start());
+
+});
diff --git a/addons/mail/static/src/js/many2many_tags_email.js b/addons/mail/static/src/js/many2many_tags_email.js
new file mode 100644
index 00000000..6648ef50
--- /dev/null
+++ b/addons/mail/static/src/js/many2many_tags_email.js
@@ -0,0 +1,135 @@
+odoo.define('mail.many2manytags', function (require) {
+"use strict";
+
+var BasicModel = require('web.BasicModel');
+var core = require('web.core');
+var form_common = require('web.view_dialogs');
+var field_registry = require('web.field_registry');
+var relational_fields = require('web.relational_fields');
+
+var M2MTags = relational_fields.FieldMany2ManyTags;
+var _t = core._t;
+
+BasicModel.include({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} record - an element from the localData
+ * @param {string} fieldName
+ * @return {Promise<Object>} the promise is resolved with the
+ * invalidPartnerIds
+ */
+ _setInvalidMany2ManyTagsEmail: function (record, fieldName) {
+ var self = this;
+ var localID = (record._changes && fieldName in record._changes) ?
+ record._changes[fieldName] :
+ record.data[fieldName];
+ var list = this._applyX2ManyOperations(this.localData[localID]);
+ var invalidPartnerIds = [];
+ _.each(list.data, function (id) {
+ var record = self.localData[id];
+ if (!record.data.email) {
+ invalidPartnerIds.push(record);
+ }
+ });
+ var def;
+ if (invalidPartnerIds.length) {
+ // remove invalid partners
+ var changes = {operation: 'DELETE', ids: _.pluck(invalidPartnerIds, 'id')};
+ def = this._applyX2ManyChange(record, fieldName, changes);
+ }
+ return Promise.resolve(def).then(function () {
+ return {
+ invalidPartnerIds: _.pluck(invalidPartnerIds, 'res_id'),
+ };
+ });
+ },
+});
+
+var FieldMany2ManyTagsEmail = M2MTags.extend({
+ tag_template: "FieldMany2ManyTagsEmail",
+ fieldsToFetch: _.extend({}, M2MTags.prototype.fieldsToFetch, {
+ email: {type: 'char'},
+ }),
+ specialData: "_setInvalidMany2ManyTagsEmail",
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Open a popup for each invalid partners (without email) to fill the email.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _checkEmailPopup: function () {
+ var self = this;
+
+ var popupDefs = [];
+ var validPartners = [];
+
+ // propose the user to correct invalid partners
+ _.each(this.record.specialData[this.name].invalidPartnerIds, function (resID) {
+ var def = new Promise(function (resolve, reject) {
+ var pop = new form_common.FormViewDialog(self, {
+ res_model: self.field.relation,
+ res_id: resID,
+ context: self.record.context,
+ title: "",
+ on_saved: function (record) {
+ if (record.data.email) {
+ validPartners.push(record.res_id);
+ }
+ },
+ }).open();
+ pop.on('closed', self, function () {
+ resolve();
+ });
+ });
+ popupDefs.push(def);
+ });
+ return Promise.all(popupDefs).then(function() {
+ // All popups have been processed for the given ids
+ // It is now time to set the final value with valid partners ids.
+ validPartners = _.uniq(validPartners);
+ if (validPartners.length) {
+ var values = _.map(validPartners, function (id) {
+ return {id: id};
+ });
+ self._setValue({
+ operation: 'ADD_M2M',
+ ids: values,
+ });
+ }
+ });
+ },
+ /**
+ * Override to check if all many2many values have an email set before
+ * rendering the widget.
+ *
+ * @override
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ var _super = this._super.bind(this);
+ return new Promise(function (resolve, reject) {
+ if (self.record.specialData[self.name].invalidPartnerIds.length) {
+ resolve(self._checkEmailPopup());
+ } else {
+ resolve();
+ }
+ }).then(function () {
+ return _super.apply(self, arguments);
+ });
+ },
+});
+
+field_registry.add('many2many_tags_email', FieldMany2ManyTagsEmail);
+
+});
diff --git a/addons/mail/static/src/js/many2one_avatar_user.js b/addons/mail/static/src/js/many2one_avatar_user.js
new file mode 100644
index 00000000..6a5b5270
--- /dev/null
+++ b/addons/mail/static/src/js/many2one_avatar_user.js
@@ -0,0 +1,68 @@
+odoo.define('mail.Many2OneAvatarUser', function (require) {
+ "use strict";
+
+ // This module defines an extension of the Many2OneAvatar widget, which is
+ // integrated with the messaging system. The Many2OneAvatarUser is designed
+ // to display people, and when the avatar of those people is clicked, it
+ // opens a DM chat window with the corresponding user.
+ //
+ // This widget is supported on many2one fields pointing to 'res.users'.
+ //
+ // Usage:
+ // <field name="user_id" widget="many2one_avatar_user"/>
+ //
+ // The widget is designed to be extended, to support many2one fields pointing
+ // to other models than 'res.users'.
+
+ const fieldRegistry = require('web.field_registry');
+ const { Many2OneAvatar } = require('web.relational_fields');
+
+ const { Component } = owl;
+
+ const Many2OneAvatarUser = Many2OneAvatar.extend({
+ events: Object.assign({}, Many2OneAvatar.prototype.events, {
+ 'click .o_m2o_avatar': '_onAvatarClicked',
+ }),
+ // This widget is only supported on many2ones pointing to 'res.users'
+ supportedModels: ['res.users'],
+
+ init() {
+ this._super(...arguments);
+ if (!this.supportedModels.includes(this.field.relation)) {
+ throw new Error(`This widget is only supported on many2one fields pointing to ${JSON.stringify(this.supportedModels)}`);
+ }
+ if (this.mode === 'readonly') {
+ this.className += ' o_clickable_m2o_avatar';
+ }
+ },
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * When the avatar is clicked, open a DM chat window with the
+ * corresponding user.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ async _onAvatarClicked(ev) {
+ ev.stopPropagation(); // in list view, prevent from opening the record
+ const env = Component.env;
+ await env.messaging.openChat({ userId: this.value.res_id });
+ }
+ });
+
+ const KanbanMany2OneAvatarUser = Many2OneAvatarUser.extend({
+ _template: 'mail.KanbanMany2OneAvatarUser',
+ });
+
+ fieldRegistry.add('many2one_avatar_user', Many2OneAvatarUser);
+ fieldRegistry.add('kanban.many2one_avatar_user', KanbanMany2OneAvatarUser);
+
+ return {
+ Many2OneAvatarUser,
+ KanbanMany2OneAvatarUser,
+ };
+});
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 00000000..a7299599
--- /dev/null
+++ b/addons/mail/static/src/js/systray/systray_activity_menu.js
@@ -0,0 +1,202 @@
+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 Time = require('web.time');
+var QWeb = core.qweb;
+
+const { Component } = owl;
+
+/**
+ * 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 .o_mail_activity_action': '_onActivityActionClick',
+ 'click .o_mail_preview': '_onActivityFilterClick',
+ 'show.bs.dropdown': '_onActivityMenuShow',
+ 'hide.bs.dropdown': '_onActivityMenuHide',
+ },
+ start: function () {
+ this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items');
+ Component.env.bus.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; }, 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'
+ });
+ },
+ /**
+ * Return views to display when coming from systray depending on the model.
+ *
+ * @private
+ * @param {string} model
+ * @returns {Array[]} output the list of views to display.
+ */
+ _getViewsList(model) {
+ return [[false, 'kanban'], [false, 'list'], [false, 'form']];
+ },
+ /**
+ * 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', {
+ widget: self,
+ Time: Time
+ }));
+ });
+ },
+ /**
+ * 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 specific action given its xml id or to the activity
+ * view of the current model if no xml id is provided
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onActivityActionClick: function (ev) {
+ ev.stopPropagation();
+ this.$('.dropdown-toggle').dropdown('toggle');
+ var targetAction = $(ev.currentTarget);
+ var actionXmlid = targetAction.data('action_xmlid');
+ if (actionXmlid) {
+ this.do_action(actionXmlid);
+ } else {
+ var domain = [['activity_ids.user_id', '=', session.uid]]
+ if (targetAction.data('domain')) {
+ domain = domain.concat(targetAction.data('domain'))
+ }
+
+ this.do_action({
+ type: 'ir.actions.act_window',
+ name: targetAction.data('model_name'),
+ views: [[false, 'activity'], [false, 'kanban'], [false, 'list'], [false, 'form']],
+ view_mode: 'activity',
+ res_model: targetAction.data('res_model'),
+ domain: domain,
+ }, {
+ clear_breadcrumbs: true,
+ });
+ }
+ },
+
+ /**
+ * 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;
+ }
+ // Necessary because activity_ids of mail.activity.mixin has auto_join
+ // So, duplicates are faking the count and "Load more" doesn't show up
+ context['force_search_count'] = 1;
+
+ var domain = [['activity_ids.user_id', '=', session.uid]]
+ if (data.domain) {
+ domain = domain.concat(data.domain)
+ }
+
+ this.do_action({
+ type: 'ir.actions.act_window',
+ name: data.model_name,
+ res_model: data.res_model,
+ views: this._getViewsList(data.res_model),
+ search_view_id: [false],
+ domain: domain,
+ context:context,
+ }, {
+ clear_breadcrumbs: true,
+ });
+ },
+ /**
+ * @private
+ */
+ _onActivityMenuShow: function () {
+ document.body.classList.add('modal-open');
+ this._updateActivityPreview();
+ },
+ /**
+ * @private
+ */
+ _onActivityMenuHide: function () {
+ document.body.classList.remove('modal-open');
+ },
+});
+
+SystrayMenu.Items.push(ActivityMenu);
+
+return ActivityMenu;
+
+});
diff --git a/addons/mail/static/src/js/tools/debug_manager.js b/addons/mail/static/src/js/tools/debug_manager.js
new file mode 100644
index 00000000..798765eb
--- /dev/null
+++ b/addons/mail/static/src/js/tools/debug_manager.js
@@ -0,0 +1,33 @@
+odoo.define('mail.DebugManager.Backend', function (require) {
+"use strict";
+
+var core = require('web.core');
+var DebugManager = require('web.DebugManager.Backend');
+
+var _t = core._t;
+/**
+ * adds a new method available for the debug manager, called by the "Manage Messages" button.
+ *
+ */
+DebugManager.include({
+ getMailMessages: function () {
+ var selectedIDs = this._controller.getSelectedIds();
+ if (!selectedIDs.length) {
+ console.warn(_t("No message available"));
+ return;
+ }
+ this.do_action({
+ res_model: 'mail.message',
+ name: _t('Manage Messages'),
+ views: [[false, 'list'], [false, 'form']],
+ type: 'ir.actions.act_window',
+ domain: [['res_id', '=', selectedIDs[0]], ['model', '=', this._controller.modelName]],
+ context: {
+ default_res_model: this._controller.modelName,
+ default_res_id: selectedIDs[0],
+ },
+ });
+ },
+});
+
+});
diff --git a/addons/mail/static/src/js/tours/mail.js b/addons/mail/static/src/js/tours/mail.js
new file mode 100644
index 00000000..8870abc6
--- /dev/null
+++ b/addons/mail/static/src/js/tours/mail.js
@@ -0,0 +1,59 @@
+odoo.define('mail.tour', function (require) {
+"use strict";
+
+var core = require('web.core');
+var tour = require('web_tour.tour');
+
+var _t = core._t;
+
+tour.register('mail_tour', {
+ url: "/web#action=mail.widgets.discuss",
+ sequence: 80,
+}, [{
+ trigger: '.o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd',
+ 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_DiscussSidebar_itemNewInput',
+ content: _t("<p>Create a channel here.</p>"),
+ position: 'bottom',
+ auto: true,
+ run: function (actions) {
+ var t = new Date().getTime();
+ actions.text("SomeChannel_" + t, this.$anchor);
+ },
+}, {
+ trigger: ".o_DiscussSidebar_newChannelAutocompleteSuggestions",
+ content: _t("<p>Create a public or private channel.</p>"),
+ position: 'right',
+ run() {
+ this.$consumeEventAnchors.find('li:first').click();
+ },
+}, {
+ trigger: '.o_Discuss_thread .o_ComposerTextInput_textarea',
+ 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,
+ run: function (actions) {
+ var t = new Date().getTime();
+ actions.text("SomeText_" + t, this.$anchor);
+ },
+}, {
+ trigger: '.o_Discuss_thread .o_Composer_buttonSend',
+ content: _t("Post your message on the thread"),
+ position: "top",
+}, {
+ trigger: '.o_Discuss_thread .o_Message_commandStar',
+ content: _t("Messages can be <b>starred</b> to remind you to check back later."),
+ position: "bottom",
+}, {
+ trigger: '.o_DiscussSidebarItem.o-starred-box',
+ content: _t("Once a message has been starred, you can come back and review it at any time here."),
+ position: "bottom",
+}, {
+ trigger: '.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeaderItemAdd',
+ 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
new file mode 100644
index 00000000..d267623d
--- /dev/null
+++ b/addons/mail/static/src/js/utils.js
@@ -0,0 +1,187 @@
+odoo.define('mail.utils', function (require) {
+"use strict";
+
+var core = require('web.core');
+
+var _t = core._t;
+
+/**
+ * WARNING: this is not enough to unescape potential XSS contained in htmlString, transformFunction
+ * should handle it or it should be handled after/before calling parseAndTransform. So if the result
+ * of this function is used in a t-raw, be very careful.
+ *
+ * @param {string} htmlString
+ * @param {function} transformFunction
+ * @returns {string}
+ */
+function parseAndTransform(htmlString, transformFunction) {
+ var openToken = "OPEN" + Date.now();
+ var string = htmlString.replace(/&lt;/g, openToken);
+ var children;
+ try {
+ children = $('<div>').html(string).contents();
+ } catch (e) {
+ children = $('<div>').html('<pre>' + string + '</pre>').contents();
+ }
+ return _parseAndTransform(children, transformFunction)
+ .replace(new RegExp(openToken, "g"), "&lt;");
+}
+
+/**
+ * @param {Node[]} nodes
+ * @param {function} transformFunction with:
+ * param node
+ * param function
+ * return string
+ * @return {string}
+ */
+function _parseAndTransform(nodes, transformFunction) {
+ return _.map(nodes, function (node) {
+ 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.
+// And further extended to include Latin-1 Supplement, Latin Extended-A, Latin Extended-B and Latin Extended Additional.
+var urlRegexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._+~#=\u00C0-\u024F\u1E00-\u1EFF]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_+.~#?&'$//=;\u00C0-\u024F\u1E00-\u1EFF]*)/gi;
+/**
+ * @param {string} text
+ * @param {Object} [attrs={}]
+ * @return {string} linkified text
+ */
+function linkify(text, attrs) {
+ attrs = attrs || {};
+ if (attrs.target === undefined) {
+ attrs.target = '_blank';
+ }
+ if (attrs.target === '_blank') {
+ attrs.rel = 'noreferrer noopener';
+ }
+ attrs = _.map(attrs, function (value, key) {
+ return key + '="' + _.escape(value) + '"';
+ }).join(' ');
+ return text.replace(urlRegexp, function (url) {
+ var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url;
+ return '<a ' + attrs + ' href="' + href + '">' + url + '</a>';
+ });
+}
+
+function addLink(node, transformChildren) {
+ if (node.nodeType === 3) { // text node
+ const linkified = linkify(node.data);
+ if (linkified !== node.data) {
+ const div = document.createElement('div');
+ div.innerHTML = linkified;
+ for (const childNode of [...div.childNodes]) {
+ node.parentNode.insertBefore(childNode, node);
+ }
+ node.parentNode.removeChild(node);
+ return linkified;
+ }
+ return node.textContent;
+ }
+ if (node.tagName === "A") return node.outerHTML;
+ transformChildren();
+ return node.outerHTML;
+}
+
+/**
+ * @param {string} htmlString
+ * @return {string}
+ */
+function htmlToTextContentInline(htmlString) {
+ const fragment = document.createDocumentFragment();
+ const div = document.createElement('div');
+ fragment.appendChild(div);
+ htmlString = htmlString.replace(/<br\s*\/?>/gi,' ');
+ try {
+ div.innerHTML = htmlString;
+ } catch (e) {
+ div.innerHTML = `<pre>${htmlString}</pre>`;
+ }
+ return div
+ .textContent
+ .trim()
+ .replace(/[\n\r]/g, '')
+ .replace(/\s\s+/g, ' ');
+}
+
+function stripHTML(node, transformChildren) {
+ if (node.nodeType === 3) return node.data; // text node
+ if (node.tagName === "BR") return "\n";
+ return transformChildren();
+}
+
+function inline(node, transform_children) {
+ if (node.nodeType === 3) return node.data;
+ if (node.nodeType === 8) return "";
+ if (node.tagName === "BR") return " ";
+ if (node.tagName.match(/^(A|P|DIV|PRE|BLOCKQUOTE)$/)) return transform_children();
+ node.innerHTML = transform_children();
+ return node.outerHTML;
+}
+
+// Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False
+function parseEmail(text) {
+ if (text){
+ var result = text.match(/(.*)<(.*@.*)>/);
+ if (result) {
+ return [_.str.trim(result[1]), _.str.trim(result[2])];
+ }
+ result = text.match(/(.*@.*)/);
+ if (result) {
+ return [_.str.trim(result[1]), _.str.trim(result[1])];
+ }
+ return [text, false];
+ }
+}
+
+/**
+ * Returns an escaped conversion of a content.
+ *
+ * @param {string} content
+ * @returns {string}
+ */
+function escapeAndCompactTextContent(content) {
+ //Removing unwanted extra spaces from message
+ let value = owl.utils.escape(content).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');
+ return value;
+}
+
+// Replaces textarea text into html text (add <p>, <a>)
+// TDE note : should be done server-side, in Python -> use mail.compose.message ?
+function getTextToHTML(text) {
+ return text
+ .replace(/((?:https?|ftp):\/\/[\S]+)/g,'<a href="$1">$1</a> ')
+ .replace(/[\n\r]/g,'<br/>');
+}
+
+function timeFromNow(date) {
+ if (moment().diff(date, 'seconds') < 45) {
+ return _t("now");
+ }
+ return date.fromNow();
+}
+
+return {
+ addLink: addLink,
+ getTextToHTML: getTextToHTML,
+ htmlToTextContentInline,
+ inline: inline,
+ linkify: linkify,
+ parseAndTransform: parseAndTransform,
+ parseEmail: parseEmail,
+ stripHTML: stripHTML,
+ timeFromNow: timeFromNow,
+ escapeAndCompactTextContent,
+};
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_cell.js b/addons/mail/static/src/js/views/activity/activity_cell.js
new file mode 100644
index 00000000..d1cdca94
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_cell.js
@@ -0,0 +1,42 @@
+odoo.define("mail.ActivityCell", function (require) {
+ "use strict";
+
+ require("mail.Activity");
+ const field_registry = require('web.field_registry');
+
+ const KanbanActivity = field_registry.get('kanban_activity');
+
+ const ActivityCell = KanbanActivity.extend({
+ /**
+ * @override
+ * @private
+ */
+ _render() {
+ // replace clock by closest deadline
+ const $date = $('<div class="o_closest_deadline">');
+ const date = new Date(this.record.data.closest_deadline);
+ // To remove year only if current year
+ if (moment().year() === moment(date).year()) {
+ $date.text(date.toLocaleDateString(moment().locale(), {
+ day: 'numeric', month: 'short'
+ }));
+ } else {
+ $date.text(moment(date).format('ll'));
+ }
+ this.$('a').html($date);
+ if (this.record.data.activity_ids.res_ids.length > 1) {
+ this.$('a').append($('<span>', {
+ class: 'badge badge-light badge-pill border-0 ' + this.record.data.activity_state,
+ text: this.record.data.activity_ids.res_ids.length,
+ }));
+ }
+ if (this.$el.hasClass('show')) {
+ // note: this part of the rendering might be asynchronous
+ this._renderDropdown();
+ }
+ }
+ });
+
+ return ActivityCell;
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_controller.js b/addons/mail/static/src/js/views/activity/activity_controller.js
new file mode 100644
index 00000000..106c5ee9
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_controller.js
@@ -0,0 +1,124 @@
+odoo.define('mail.ActivityController', function (require) {
+"use strict";
+
+require('mail.Activity');
+var BasicController = require('web.BasicController');
+var core = require('web.core');
+var field_registry = require('web.field_registry');
+var ViewDialogs = require('web.view_dialogs');
+
+var KanbanActivity = field_registry.get('kanban_activity');
+var _t = core._t;
+
+var ActivityController = BasicController.extend({
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ empty_cell_clicked: '_onEmptyCell',
+ send_mail_template: '_onSendMailTemplate',
+ schedule_activity: '_onScheduleActivity',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @param parent
+ * @param model
+ * @param renderer
+ * @param {Object} params
+ * @param {String} params.title The title used in schedule activity dialog
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.title = params.title;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Overridden to remove the pager as it makes no sense in this view.
+ *
+ * @override
+ */
+ _getPagingInfo: function () {
+ return null;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onScheduleActivity: function () {
+ var self = this;
+ var state = this.model.get(this.handle);
+ new ViewDialogs.SelectCreateDialog(this, {
+ res_model: state.model,
+ domain: this.model.originalDomain,
+ title: _.str.sprintf(_t("Search: %s"), this.title),
+ no_create: !this.activeActions.create,
+ disable_multiple_selection: true,
+ context: state.context,
+ on_selected: function (record) {
+ var fakeRecord = state.getKanbanActivityData({}, record[0]);
+ var widget = new KanbanActivity(self, 'activity_ids', fakeRecord, {});
+ widget.scheduleActivity();
+ },
+ }).open();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onEmptyCell: function (ev) {
+ var state = this.model.get(this.handle);
+ this.do_action({
+ type: 'ir.actions.act_window',
+ res_model: 'mail.activity',
+ view_mode: 'form',
+ view_type: 'form',
+ views: [[false, 'form']],
+ target: 'new',
+ context: {
+ default_res_id: ev.data.resId,
+ default_res_model: state.model,
+ default_activity_type_id: ev.data.activityTypeId,
+ },
+ res_id: false,
+ }, {
+ on_close: this.reload.bind(this),
+ });
+ },
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onSendMailTemplate: function (ev) {
+ var templateID = ev.data.templateID;
+ var activityTypeID = ev.data.activityTypeID;
+ var state = this.model.get(this.handle);
+ var groupedActivities = state.grouped_activities;
+ var resIDS = [];
+ Object.keys(groupedActivities).forEach(function (resID) {
+ var activityByType = groupedActivities[resID];
+ var activity = activityByType[activityTypeID];
+ if (activity) {
+ resIDS.push(parseInt(resID));
+ }
+ });
+ this._rpc({
+ model: this.model.modelName,
+ method: 'activity_send_mail',
+ args: [resIDS, templateID],
+ });
+ },
+});
+
+return ActivityController;
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_model.js b/addons/mail/static/src/js/views/activity/activity_model.js
new file mode 100644
index 00000000..cfb9e36a
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_model.js
@@ -0,0 +1,124 @@
+odoo.define('mail.ActivityModel', function (require) {
+'use strict';
+
+const BasicModel = require('web.BasicModel');
+const session = require('web.session');
+
+const ActivityModel = BasicModel.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * Add the following (activity specific) keys when performing a `get` on the
+ * main list datapoint:
+ * - activity_types
+ * - activity_res_ids
+ * - grouped_activities
+ *
+ * @override
+ */
+ __get: function () {
+ var result = this._super.apply(this, arguments);
+ if (result && result.model === this.modelName && result.type === 'list') {
+ _.extend(result, this.additionalData, {getKanbanActivityData: this.getKanbanActivityData});
+ }
+ return result;
+ },
+ /**
+ * @param {Object} activityGroup
+ * @param {integer} resId
+ * @returns {Object}
+ */
+ getKanbanActivityData(activityGroup, resId) {
+ return {
+ data: {
+ activity_ids: {
+ model: 'mail.activity',
+ res_ids: activityGroup.ids,
+ },
+ activity_state: activityGroup.state,
+ closest_deadline: activityGroup.o_closest_deadline,
+ },
+ fields: {
+ activity_ids: {},
+ activity_state: {
+ selection: [
+ ['overdue', "Overdue"],
+ ['today', "Today"],
+ ['planned', "Planned"],
+ ],
+ },
+ },
+ fieldsInfo: {},
+ model: this.model,
+ type: 'record',
+ res_id: resId,
+ getContext: function () {
+ return {};
+ },
+ };
+ },
+ /**
+ * @override
+ * @param {Array[]} params.domain
+ */
+ __load: function (params) {
+ this.originalDomain = _.extend([], params.domain);
+ params.domain.push(['activity_ids', '!=', false]);
+ this.domain = params.domain;
+ this.modelName = params.modelName;
+ params.groupedBy = [];
+ var def = this._super.apply(this, arguments);
+ return Promise.all([def, this._fetchData()]).then(function (result) {
+ return result[0];
+ });
+ },
+ /**
+ * @override
+ * @param {Array[]} [params.domain]
+ */
+ __reload: function (handle, params) {
+ if (params && 'domain' in params) {
+ this.originalDomain = _.extend([], params.domain);
+ params.domain.push(['activity_ids', '!=', false]);
+ this.domain = params.domain;
+ }
+ if (params && 'groupBy' in params) {
+ params.groupBy = [];
+ }
+ var def = this._super.apply(this, arguments);
+ return Promise.all([def, this._fetchData()]).then(function (result) {
+ return result[0];
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Fetch activity data.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _fetchData: function () {
+ var self = this;
+ return this._rpc({
+ model: "mail.activity",
+ method: 'get_activity_data',
+ kwargs: {
+ res_model: this.modelName,
+ domain: this.domain,
+ context: session.user_context,
+ }
+ }).then(function (result) {
+ self.additionalData = result;
+ });
+ },
+});
+
+return ActivityModel;
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_record.js b/addons/mail/static/src/js/views/activity/activity_record.js
new file mode 100644
index 00000000..98da9dca
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_record.js
@@ -0,0 +1,62 @@
+odoo.define('mail.ActivityRecord', function (require) {
+"use strict";
+
+var KanbanRecord = require('web.KanbanRecord');
+
+var ActivityRecord = KanbanRecord.extend({
+ /**
+ * @override
+ */
+ init: function (parent, state) {
+ this._super.apply(this,arguments);
+
+ this.fieldsInfo = state.fieldsInfo.activity;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ */
+ _render: function () {
+ this.defs = [];
+ this._replaceElement(this.qweb.render('activity-box', this.qweb_context));
+ this.$el.on('click', this._onGlobalClick.bind(this));
+ this.$el.addClass('o_activity_record');
+ this._processFields();
+ this._setupColor();
+ return Promise.all(this.defs);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _setFieldDisplay: function ($el, fieldName) {
+ this._super.apply(this, arguments);
+
+ // attribute muted
+ if (this.fieldsInfo[fieldName].muted) {
+ $el.addClass('text-muted');
+ }
+ },
+ /**
+ * @override
+ * @private
+ */
+ _setState: function () {
+ this._super.apply(this, arguments);
+
+ // activity has a different qweb context
+ this.qweb_context = {
+ activity_image: this._getImageURL.bind(this),
+ record: this.record,
+ user_context: this.getSession().user_context,
+ widget: this,
+ };
+ },
+});
+return ActivityRecord;
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_renderer.js b/addons/mail/static/src/js/views/activity/activity_renderer.js
new file mode 100644
index 00000000..e62d416e
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_renderer.js
@@ -0,0 +1,210 @@
+odoo.define('mail.ActivityRenderer', function (require) {
+"use strict";
+
+const AbstractRendererOwl = require('web.AbstractRendererOwl');
+const ActivityCell = require('mail.ActivityCell');
+const ActivityRecord = require('mail.ActivityRecord');
+const { ComponentAdapter } = require('web.OwlCompatibility');
+const core = require('web.core');
+const KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
+const patchMixin = require('web.patchMixin');
+const QWeb = require('web.QWeb');
+const session = require('web.session');
+const utils = require('web.utils');
+
+const _t = core._t;
+
+const { useState } = owl.hooks;
+
+/**
+ * Owl Component Adapter for ActivityRecord which is KanbanRecord (Odoo Widget)
+ * TODO: Remove this adapter when ActivityRecord is a Component
+ */
+class ActivityRecordAdapter extends ComponentAdapter {
+ renderWidget() {
+ _.invoke(_.pluck(this.widget.subWidgets, '$el'), 'detach');
+ this.widget._render();
+ }
+
+ updateWidget(nextProps) {
+ const state = nextProps.widgetArgs[0];
+ this.widget._setState(state);
+ }
+}
+
+/**
+ * Owl Component Adapter for ActivityCell which is BasicActivity (AbstractField)
+ * TODO: Remove this adapter when ActivityCell is a Component
+ */
+class ActivityCellAdapter extends ComponentAdapter {
+ renderWidget() {
+ this.widget._render();
+ }
+
+ updateWidget(nextProps) {
+ const record = nextProps.widgetArgs[1];
+ this.widget._reset(record);
+ }
+}
+
+/**
+ * Owl Component Adapter for KanbanColumnProgressBar (Odoo Widget)
+ * TODO: Remove this adapter when KanbanColumnProgressBar is a Component
+ */
+class KanbanColumnProgressBarAdapter extends ComponentAdapter {
+ renderWidget() {
+ this.widget._render();
+ }
+
+ updateWidget(nextProps) {
+ const options = nextProps.widgetArgs[0];
+ const columnState = nextProps.widgetArgs[1];
+
+ const columnId = options.columnID;
+ const nextActiveFilter = options.progressBarStates[columnId].activeFilter;
+ this.widget.activeFilter = nextActiveFilter ? this.widget.activeFilter : false;
+ this.widget.columnState = columnState;
+ this.widget.computeCounters();
+ }
+
+ _trigger_up(ev) {
+ // KanbanColumnProgressBar triggers 3 events before being mounted
+ // but we don't need to listen to them in our case.
+ if (this.el) {
+ super._trigger_up(ev);
+ }
+ }
+}
+
+class ActivityRenderer extends AbstractRendererOwl {
+ constructor(parent, props) {
+ super(...arguments);
+ this.qweb = new QWeb(this.env.isDebug(), {_s: session.origin});
+ this.qweb.add_template(utils.json_node_to_xml(props.templates));
+ this.activeFilter = useState({
+ state: null,
+ activityTypeId: null,
+ resIds: []
+ });
+ this.widgetComponents = {
+ ActivityRecord,
+ ActivityCell,
+ KanbanColumnProgressBar,
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Gets all activity resIds in the view.
+ *
+ * @returns filtered resIds first then the rest.
+ */
+ get activityResIds() {
+ const copiedActivityResIds = Array.from(this.props.activity_res_ids)
+ return copiedActivityResIds.sort((a, b) => this.activeFilter.resIds.includes(a) ? -1 : 0);
+ }
+
+ /**
+ * Gets all existing activity type ids.
+ */
+ get activityTypeIds() {
+ const activities = Object.values(this.props.grouped_activities);
+ const activityIds = activities.flatMap(Object.keys);
+ const uniqueIds = Array.from(new Set(activityIds));
+ return uniqueIds.map(Number);
+ }
+
+ getProgressBarOptions(typeId) {
+ return {
+ columnID: typeId,
+ progressBarStates: {
+ [typeId]: {
+ activeFilter: this.activeFilter.activityTypeId === typeId,
+ },
+ },
+ };
+ }
+
+ getProgressBarColumnState(typeId) {
+ const counts = { planned: 0, today: 0, overdue: 0 };
+ for (let activities of Object.values(this.props.grouped_activities)) {
+ if (typeId in activities) {
+ counts[activities[typeId].state] += 1;
+ }
+ }
+ return {
+ count: Object.values(counts).reduce((x, y) => x + y),
+ fields: {
+ activity_state: {
+ type: 'selection',
+ selection: [
+ ['planned', _t('Planned')],
+ ['today', _t('Today')],
+ ['overdue', _t('Overdue')],
+ ],
+ },
+ },
+ progressBarValues: {
+ field: 'activity_state',
+ colors: { planned: 'success', today: 'warning', overdue: 'danger' },
+ counts: counts,
+ },
+ };
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onEmptyCellClicked(ev) {
+ this.trigger('empty_cell_clicked', {
+ resId: parseInt(ev.currentTarget.dataset.resId, 10),
+ activityTypeId: parseInt(ev.currentTarget.dataset.activityTypeId, 10),
+ });
+ }
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onSendMailTemplateClicked(ev) {
+ this.trigger('send_mail_template', {
+ activityTypeID: parseInt(ev.currentTarget.dataset.activityTypeId, 10),
+ templateID: parseInt(ev.currentTarget.dataset.templateId, 10),
+ });
+ }
+ /**
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onSetProgressBarState(ev) {
+ if (ev.detail.values.activeFilter) {
+ this.activeFilter.state = ev.detail.values.activeFilter;
+ this.activeFilter.activityTypeId = ev.detail.columnID;
+ this.activeFilter.resIds = Object.entries(this.props.grouped_activities)
+ .filter(([, resIds]) => ev.detail.columnID in resIds &&
+ resIds[ev.detail.columnID].state === ev.detail.values.activeFilter)
+ .map(([key]) => parseInt(key));
+ } else {
+ this.activeFilter.state = null;
+ this.activeFilter.activityTypeId = null;
+ this.activeFilter.resIds = [];
+ }
+ }
+}
+
+ActivityRenderer.components = {
+ ActivityRecordAdapter,
+ ActivityCellAdapter,
+ KanbanColumnProgressBarAdapter,
+};
+ActivityRenderer.template = 'mail.ActivityRenderer';
+
+return patchMixin(ActivityRenderer);
+
+});
diff --git a/addons/mail/static/src/js/views/activity/activity_view.js b/addons/mail/static/src/js/views/activity/activity_view.js
new file mode 100644
index 00000000..e2e3eded
--- /dev/null
+++ b/addons/mail/static/src/js/views/activity/activity_view.js
@@ -0,0 +1,53 @@
+odoo.define('mail.ActivityView', function (require) {
+"use strict";
+
+const ActivityController = require('mail.ActivityController');
+const ActivityModel = require('mail.ActivityModel');
+const ActivityRenderer = require('mail.ActivityRenderer');
+const BasicView = require('web.BasicView');
+const core = require('web.core');
+const RendererWrapper = require('web.RendererWrapper');
+const view_registry = require('web.view_registry');
+
+const _lt = core._lt;
+
+const ActivityView = BasicView.extend({
+ accesskey: "a",
+ display_name: _lt('Activity'),
+ icon: 'fa-clock-o',
+ config: _.extend({}, BasicView.prototype.config, {
+ Controller: ActivityController,
+ Model: ActivityModel,
+ Renderer: ActivityRenderer,
+ }),
+ viewType: 'activity',
+ searchMenuTypes: ['filter', 'favorite'],
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ this.loadParams.type = 'list';
+ // limit makes no sense in this view as we display all records having activities
+ this.loadParams.limit = false;
+
+ this.rendererParams.templates = _.findWhere(this.arch.children, { 'tag': 'templates' });
+ this.controllerParams.title = this.arch.attrs.string;
+ },
+ /**
+ *
+ * @override
+ */
+ getRenderer(parent, state) {
+ state = Object.assign({}, state, this.rendererParams);
+ return new RendererWrapper(null, this.config.Renderer, state);
+ },
+});
+
+view_registry.add('activity', ActivityView);
+
+return ActivityView;
+
+});