diff --git a/addons/bus/static/src/js/services/bus_service.js b/addons/bus/static/src/js/services/bus_service.js index c6e9517acc6..9a40abae930 100644 --- a/addons/bus/static/src/js/services/bus_service.js +++ b/addons/bus/static/src/js/services/bus_service.js @@ -11,7 +11,7 @@ var BusService = AbstractService.extend({ /** * @override */ - init: function () { + start: function () { this._super.apply(this, arguments); this.bus = bus; this._audio = null; @@ -33,19 +33,18 @@ var BusService = AbstractService.extend({ /** * Send a notification, and notify once per browser's tab * - * @param {web.Widget} widget the widget that called sendNotification * @param {string} title * @param {string} content */ - sendNotification: function (widget, title, content) { + sendNotification: function (title, content) { if (window.Notification && Notification.permission === "granted") { if (this.bus.is_master) { this._sendNativeNotification(title, content); } } else { - widget.do_notify(title, content); + this.do_notify(title, content); if (this.bus.is_master) { - this._beep(widget); + this._beep(); } } }, @@ -58,14 +57,13 @@ var BusService = AbstractService.extend({ * Lazily play the 'beep' audio on sent notification * * @private - * @param {web.Widget} widget */ - _beep: function (widget) { + _beep: function () { if (typeof(Audio) !== "undefined") { if (!this._audio) { this._audio = new Audio(); var ext = this._audio.canPlayType("audio/ogg; codecs=vorbis") ? ".ogg" : ".mp3"; - var session = widget.getSession(); + var session = this.getSession(); this._audio.src = session.url("/mail/static/src/audio/ting" + ext); } this._audio.play(); diff --git a/addons/calendar/static/src/js/systray.js b/addons/calendar/static/src/js/systray_activity_menu.js similarity index 91% rename from addons/calendar/static/src/js/systray.js rename to addons/calendar/static/src/js/systray_activity_menu.js index f0ad7b35304..1962ef3c17c 100644 --- a/addons/calendar/static/src/js/systray.js +++ b/addons/calendar/static/src/js/systray_activity_menu.js @@ -1,7 +1,7 @@ -odoo.define('calendar.systray', function (require) { +odoo.define('calendar.systray.ActivityMenu', function (require) { "use strict"; -var ActivityMenu = require('mail.systray').ActivityMenu; +var ActivityMenu = require('mail.systray.ActivityMenu'); var fieldUtils = require('web.field_utils'); ActivityMenu.include({ @@ -22,7 +22,7 @@ ActivityMenu.include({ _getActivityData: function () { var self = this; return this._super.apply(this, arguments).then(function () { - var meeting = _.find(self.activities, {type: 'meeting'}); + var meeting = _.find(self._activities, {type: 'meeting'}); if (meeting && meeting.meetings) { _.each(meeting.meetings, function (res) { res.start = fieldUtils.parse.datetime(res.start, false, {isUTC: true}); diff --git a/addons/calendar/static/src/xml/base_calendar.xml b/addons/calendar/static/src/xml/base_calendar.xml index 56d10a45afe..0bb242ef530 100644 --- a/addons/calendar/static/src/xml/base_calendar.xml +++ b/addons/calendar/static/src/xml/base_calendar.xml @@ -20,8 +20,8 @@ - - + +
diff --git a/addons/calendar/static/tests/systray_tests.js b/addons/calendar/static/tests/systray_activity_menu_tests.js similarity index 83% rename from addons/calendar/static/tests/systray_tests.js rename to addons/calendar/static/tests/systray_activity_menu_tests.js index 072a1b5f4f1..803b43bb9e9 100644 --- a/addons/calendar/static/tests/systray_tests.js +++ b/addons/calendar/static/tests/systray_activity_menu_tests.js @@ -1,17 +1,17 @@ -odoo.define('calendar.systray_tests', function (require) { +odoo.define('calendar.systray.ActivityMenuTests', function (require) { "use strict"; -var ChatManager = require('mail.ChatManager'); -var systray = require('mail.systray'); +var ActivityMenu = require('mail.systray.ActivityMenu'); +var mailUtils = require('mail.testUtils'); + var testUtils = require('web.test_utils'); -var createBusService = require('mail.testUtils').createBusService; QUnit.module('calendar', {}, function () { QUnit.module('ActivityMenu', { beforeEach: function () { - this.services = [ChatManager, createBusService()]; + this.services = mailUtils.getMailServices(); this.data = { 'calendar.event': { records: [{ @@ -40,7 +40,7 @@ QUnit.module('ActivityMenu', { QUnit.test('activity menu widget:today meetings', function (assert) { assert.expect(8); var self = this; - var activityMenu = new systray.ActivityMenu(); + var activityMenu = new ActivityMenu(); testUtils.addMockEnvironment(activityMenu, { services: this.services, mockRPC: function (route, args) { @@ -53,19 +53,19 @@ QUnit.test('activity menu widget:today meetings', function (assert) { activityMenu.appendTo($('#qunit-fixture')); - assert.ok(activityMenu.$el.hasClass('o_mail_navbar_item'), 'should be the instance of widget'); + assert.ok(activityMenu.$el.hasClass('o_mail_systray_item'), 'should be the instance of widget'); testUtils.intercept(activityMenu, 'do_action', function (event) { assert.ok(true, "should have triggered a do_action"); - assert.strictEqual(event.data.action.res_id, activityMenu.$('.o_meeting_filter').eq(0).data('res_id'), 'should open particular meeting form view') + assert.strictEqual(event.data.action.res_id, activityMenu.$('.o_meeting_filter').eq(0).data('res_id'), 'should open particular meeting form view'); }); activityMenu.$('.dropdown-toggle').click(); activityMenu.$('.o_meeting_filter').eq(0).click(); testUtils.intercept(activityMenu, 'do_action', function (event) { - assert.strictEqual(event.data.action, "calendar.action_calendar_event", 'should open meeting calendar view in day mode') + assert.strictEqual(event.data.action, "calendar.action_calendar_event", 'should open meeting calendar view in day mode'); }); - activityMenu.$('.o_mail_channel_preview').click(); + activityMenu.$('.o_mail_preview').click(); assert.ok(activityMenu.$('.o_meeting_filter'), "should be a meeting"); assert.strictEqual(activityMenu.$('.o_meeting_filter').length, 2, 'there should be 2 meetings'); diff --git a/addons/calendar/views/calendar_templates.xml b/addons/calendar/views/calendar_templates.xml index dac0a52bad0..ab5117a88b9 100644 --- a/addons/calendar/views/calendar_templates.xml +++ b/addons/calendar/views/calendar_templates.xml @@ -5,14 +5,14 @@ - + diff --git a/addons/im_support/static/src/js/chat_manager.js b/addons/im_support/static/src/js/chat_manager.js deleted file mode 100644 index 4fabcd5deb9..00000000000 --- a/addons/im_support/static/src/js/chat_manager.js +++ /dev/null @@ -1,455 +0,0 @@ -odoo.define('im_support.ChatManager', function (require) { -"use strict"; - -/** - * This module includes the ChatManager to handle the case of the Support - * channel, allowing the users of the current database to communicate with - * livechat operators from another database (the Support database). - */ - -var ChatManager = require('mail.ChatManager'); - -var core = require('web.core'); -var session = require('web.session'); -var WebClient = require('web.WebClient'); - -var supportSession = require('im_support.SupportSession'); -var supportBus = require('im_support.SupportBus'); - -var _t = core._t; -var CHANNEL_STATES = { - CLOSED: 'closed', - FOLDED: 'folded', - OPEN: 'open', -}; -var ODOOBOT_ID = "ODOOBOT"; // FIXME: should be exported by the ChatManager -var POLL_TIMEOUT_DELAY = 1000 * 60 * 30; // 30 minutes -var POLL_TIMEOUT_KEY = 'im_support.poll_timeout'; -var SUPPORT_CHANNEL_LIMIT = 30; // limit of messages to fetch -var SUPPORT_CHANNEL_STATE_KEY = 'im_support.channel_state'; - -ChatManager.include({ - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - closeChatSession: function (channelID) { - if (channelID === this.supportChannelUUID) { - this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.CLOSED); - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - detachChannel: function (channelID) { - if (channelID === this.supportChannelUUID) { - this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.OPEN); - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - foldChannel: function (channelID, folded) { - if (channelID === this.supportChannelUUID) { - var value = folded ? CHANNEL_STATES.FOLDED : CHANNEL_STATES.OPEN; - this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, value); - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to filter out the Support channel from the previews. - * - * @override - */ - getChannelsPreview: function () { - var self = this; - return this._super.apply(this, arguments).then(function (channelsPreview) { - return _.reject(channelsPreview, {id: self.supportChannelUUID}); - }); - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - getMentionPartnerSuggestions: function (channel) { - if (channel && channel.id === this.supportChannelUUID) { - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Initialises the Support: checks if there is a pending chat session - * between the user and Support, and if so, re-opens it. - * Note: we can't directly override init(), because it is already called - * when the include is applied, so we use this hook instead (called at - * webclient startup) - */ - initSupport: function () { - var self = this; - this.supportChannelDef = null; - this.supportChannelUUID = null; - this.pollTimeout = null; - this.pollingSupport = false; - - // listen to notifications coming from Support longpolling - supportBus.on('notification', this, this._onSupportNotification); - - // check if there is a pending chat session with the Support - var timeoutTimestamp = this.call('local_storage', 'getItem', POLL_TIMEOUT_KEY); - var pollingDelay = timeoutTimestamp && (JSON.parse(timeoutTimestamp) - Date.now()); - if (pollingDelay && pollingDelay > 0) { - var channelState = this.call('local_storage', 'getItem', SUPPORT_CHANNEL_STATE_KEY); - this.startSupportLivechat(channelState).then(function () { - var supportChannel = self.getChannel(self.supportChannelUUID); - if (supportChannel.available) { - self._startPollingSupport(pollingDelay); - } - }); - } - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - markChannelAsSeen: function (channel) { - if (channel.id === this.supportChannelUUID) { - this._updateChannelUnreadCounter(channel, 0); - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to call a specific route for messages posted in the Support - * channel. - * - * @override - */ - postMessage: function (data, options) { - if (options.channelID && options.channelID === this.supportChannelUUID) { - // ensure that the poll is active before posting the message - if (!this.pollingSupport) { - this._startPollingSupport(); - } - return supportSession.rpc("/odoo_im_support/chat_post", { - uuid: this.supportChannelUUID, - message_content: data.content, - }); - } - return this._super.apply(this, arguments); - }, - /** - * Opens the support channel between a livechat operator from the Support - * database and the current user (if there is an available operator). - * Ensures to perform only once the request to create/retrieve the Support - * channel. - * - * @param {string} [channelState=CHANNEL_STATES.OPEN] state of the Support - * channel (see CHANNEL_STATES for accepted values) - * @returns {Deferred} - */ - startSupportLivechat: function (channelState) { - var self = this; - if (!this.supportChannelDef) { - // retrieve or create the channel - this.supportChannelDef = supportSession.rpc('/odoo_im_support/get_support_channel', { - channel_uuid: session.support_token, - db_uuid: session.db_uuid, - user_name: session.name, - }); - } - return this.supportChannelDef.then(function (channel) { - if (!channel) { - // there is no channel (because there is no online operator, and - // no support channel has been created yet), so create one to - // open in a chat window - channel = { - available: false, - support_channel: true, - type: 'livechat', - uuid: "support_unavailable", - }; - } - if (!channelState) { - channelState = self.discussOpen ? CHANNEL_STATES.CLOSED : CHANNEL_STATES.OPEN; - self.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, channelState); - } - if (!self.supportChannelUUID) { - // this part is only executed the first time the RPC is resolved - self.supportChannelUUID = channel.uuid; - - // add the channel to the ChatManager - self._addChannel(_.extend(channel, { - id: channel.uuid, - is_minimized: _.contains([CHANNEL_STATES.OPEN, CHANNEL_STATES.FOLDED], channelState), - state: channelState, - })); - - // display automatic messages in the channel - if (!channel.available) { - self._addSupportNotAvailableMessage(); - } else { - self._addSupportWelcomeMessage(); - } - } else { - // the channel has already been added to the ChatManager, so - // simply re-open it - channel = self.getChannel(self.supportChannelUUID); - if (self.discussOpen) { - self.openChannel(channel); - } else { - channel.is_folded = channelState === CHANNEL_STATES.FOLDED; - self.call('chat_window_manager', 'openChat', channel); - } - } - }).fail(function () { - self.do_warn(_t("The Support server can't be reached.")); - }); - }, - /** - * Overrides to prevent from calling the server for messages of the Support - * channel (which are records on the Support database). - * - * @override - */ - toggleStarStatus: function (msgID) { - var message = this.getMessage(msgID); - if (_.contains(message.channel_ids, this.supportChannelUUID)) { - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - unsubscribe: function (channel) { - if (channel.id === this.supportChannelUUID) { - return $.when(); - } - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Adds a message in the Support channel indicating that this channel is - * not available for now. - * - * @private - */ - _addSupportNotAvailableMessage: function () { - var msg = { - author_id: ODOOBOT_ID, - body: _t("None of our operators are available. Submit a ticket to ask your question now."), - channel_ids: [this.supportChannelUUID], - id: Number.MAX_SAFE_INTEGER, // last message in the channel - }; - this._addMessage(msg, {silent: true}); - }, - /** - * Adds a welcome message as first message of the Support channel. - * - * @private - */ - _addSupportWelcomeMessage: function () { - var channel = this.getChannel(this.supportChannelUUID); - if (channel.welcome_message) { - var msg = { - author_id: channel.operator, - body: channel.welcome_message, - channel_ids: [this.supportChannelUUID], - id: -1, // first message of the channel - }; - this._addMessage(msg, {silent: true}); - } - }, - /** - * Overrides to handle the Support channel case: fetches the messages from - * the Support database in that case. - * - * @override - * @private - */ - _fetchFromChannel: function (channel, options) { - var self = this; - if (channel.id === this.supportChannelUUID) { - var domain = options && options.domain || []; - var cache = this._getChannelCache(channel, domain); - if (options && options.loadMore) { - var minMessageID = cache.messages[0].id; - domain = [['id', '<', minMessageID]].concat(domain); - } - - return supportSession.rpc('/odoo_im_support/fetch_messages', { - domain: domain, - channel_uuid: session.support_token, - limit: SUPPORT_CHANNEL_LIMIT, - }).then(function (msgs) { - if (!cache.all_history_loaded) { - cache.all_history_loaded = msgs.length < SUPPORT_CHANNEL_LIMIT; - } - cache.loaded = true; - - _.each(msgs, function (msg) { - _.extend(msg, {channel_ids: [self.supportChannelUUID]}); - self._addMessage(msg, { - channel_id: self.supportChannelUUID, - silent: true, - domain: domain, - }); - }); - var channelCache = self._getChannelCache(channel, domain); - return channelCache.messages; - }); - } - return this._super.apply(this, arguments); - }, - /** - * @override - * @private - */ - _makeChannel: function (data) { - var channel = this._super.apply(this, arguments); - if (channel.id === this.supportChannelUUID) { - channel.supportChannel = true; - channel.name = _t('Support'); - channel.available = data.available; - if (!channel.available) { - channel.name += _t(' (offline)'); - } - channel.welcome_message = data.welcome_message; - channel.operator = data.operator; - } - return channel; - }, - /** - * @override - * @private - */ - _makeMessage: function (data) { - if (_.contains(data.channel_ids, this.supportChannelUUID) && data.author_id !== ODOOBOT_ID) { - if (!data.author_id[0]) { // the author is the client - data.author_id = [session.partner_id, session.name]; - } else { // the author is the operator - data.author_id[0] = -1; // prevent from conflicting with partners of this instance - var msg = this._super.apply(this, arguments); - msg.author_redirect = false; - msg.avatar_src = "/mail/static/src/img/odoo_o.png"; - return msg; - } - } - return this._super.apply(this, arguments); - }, - /** - * Automatically stop polling for Support messages after a given delay of - * inactivity. - * - * @private - * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling - * timeout delay to set - */ - _setPollTimeout: function (pollingDelay) { - pollingDelay = pollingDelay || POLL_TIMEOUT_DELAY; - clearTimeout(this.pollTimeout); - this.pollTimeout = setTimeout(this._stopPollingSupport.bind(this), pollingDelay); - // save the timeout expiration datetime into the LocalStorage so that - // we can re-open the Support channel if necessary on F5 - var timeoutTimestamp = Date.now() + pollingDelay; - this.call('local_storage', 'setItem', POLL_TIMEOUT_KEY, timeoutTimestamp); - }, - /** - * Initiates a longpoll with the server hosting the Support channel. - * - * @private - * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling - * timeout delay to set - */ - _startPollingSupport: function (pollingDelay) { - this.pollingSupport = true; - supportBus.add_channel(this.supportChannelUUID); - supportBus.start_polling(); - this._setPollTimeout(pollingDelay); - }, - /** - * Stops the longpoll with the server hosting the Support channel. - * - * @private - */ - _stopPollingSupport: function () { - this.pollingSupport = false; - supportBus.stop_polling(); - this.call('local_storage', 'removeItem', POLL_TIMEOUT_KEY); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Handles poll notifications from the Support server. - * - * @private - * @param {Object[]} notifications - */ - _onSupportNotification: function (notifications) { - var self = this; - if (notifications && notifications.length) { - this._setPollTimeout(); - } - _.each(notifications, function (notification) { - if (notification[1]._type === 'history_command') { - // ignore history requests - return; - } - var msg = _.extend(notification[1], { - channel_ids: [self.supportChannelUUID], - }); - self._manageChannelNotification(msg); - }); - }, -}); - - -// Unfortunately, we can't override init() of ChatManager because it is called -// before the include is applied, so we override the WebClient instead to call -// an initialization hook for Livechat Support in the ChatManager service. -WebClient.include({ - /** - * Overrides to ask the ChatManager service to check whether there is a - * pending chat session with Support, and if so, to re-open it. - * - * @override - */ - show_application: function () { - this.call('chat_manager', 'initSupport'); - return this._super.apply(this, arguments); - }, -}); - -}); diff --git a/addons/im_support/static/src/js/discuss.js b/addons/im_support/static/src/js/discuss.js index 0f29370b049..8f26f272346 100644 --- a/addons/im_support/static/src/js/discuss.js +++ b/addons/im_support/static/src/js/discuss.js @@ -1,13 +1,13 @@ -odoo.define('im_support.chat_discuss', function (require) { +odoo.define('im_support.Discuss', function (require) { "use strict"; +var Discuss = require('mail.Discuss'); + /** * This module includes Discuss to handle the case of the Support channel. */ - -var Discuss = require('mail.chat_discuss'); - Discuss.include({ + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -21,8 +21,8 @@ Discuss.include({ */ _getThreadRenderingOptions: function () { var options = this._super.apply(this, arguments); - if (this.channel.supportChannel) { - options.display_stars = false; + if (this._thread.getType() === 'support_channel') { + options.displayStars = false; } return options; }, @@ -34,13 +34,13 @@ Discuss.include({ * @override * @private */ - _setChannel: function (channel) { + _setThread: function () { var self = this; return this._super.apply(this, arguments).then(function () { - var $buttonAddAttachment = self.basicComposer.$('.o_composer_button_add_attachment'); - if (channel.supportChannel) { - if (!channel.available) { - self.basicComposer.do_hide(); + var $buttonAddAttachment = self._basicComposer.$('.o_composer_button_add_attachment'); + if (self._thread.getType() === 'support_channel') { + if (!self._thread.isAvailable()) { + self._basicComposer.do_hide(); } $buttonAddAttachment.toggleClass('o_hidden', true); } else { @@ -54,9 +54,9 @@ Discuss.include({ * @override * @private */ - _updateControlPanelButtons: function (channel) { + _updateControlPanelButtons: function (thread) { this._super.apply(this, arguments); - if (channel.supportChannel) { + if (thread.getType() === 'support_channel') { this.$buttons.find('button').hide(); } }, diff --git a/addons/im_support/static/src/js/extended_chat_window.js b/addons/im_support/static/src/js/extended_chat_window.js deleted file mode 100644 index 6f598d16cba..00000000000 --- a/addons/im_support/static/src/js/extended_chat_window.js +++ /dev/null @@ -1,47 +0,0 @@ -odoo.define('im_support.ExtendedChatWindow', function (require) { -"use strict"; - -/** - * This module includes ExtendedChatWindow to handle the case of the Support - * channel. - */ - - -var ExtendedChatWindow = require('mail.ExtendedChatWindow'); - -ExtendedChatWindow.include({ - /** - * Overrides to tweak the options in the case of the Support channel (no - * star, and no input if no operator available). - * - * @override - */ - init: function () { - this._super.apply(this, arguments); - var channel = this.call('chat_manager', 'getChannel', this.channel_id); - if (channel && channel.supportChannel) { - this.options.display_stars = false; - if (!channel.available) { - this.options.input_less = true; - } - } - }, - /** - * Overrides to remove the attachment button in the Support channel as this - * feature does not work in that channel. - * - * @note: this feature could be enabled by storing the attachments on the - * Support server. - * @override - */ - start: function () { - var def = this._super.apply(this, arguments); - var channel = this.call('chat_manager', 'getChannel', this.channel_id); - if (channel && channel.supportChannel) { - this.$('.o_composer .o_composer_button_add_attachment').remove(); - } - return def; - }, -}); - -}); diff --git a/addons/im_support/static/src/js/mail_manager.js b/addons/im_support/static/src/js/mail_manager.js new file mode 100644 index 00000000000..b25b47cc1ba --- /dev/null +++ b/addons/im_support/static/src/js/mail_manager.js @@ -0,0 +1,260 @@ +odoo.define('im_support.MailManager', function (require) { +"use strict"; + +var MailManager = require('mail.Manager'); + +var core = require('web.core'); +var session = require('web.session'); +var WebClient = require('web.WebClient'); + +var SupportChannel = require('im_support.SupportChannel'); +var SupportMessage = require('im_support.SupportMessage'); +var supportSession = require('im_support.SupportSession'); +var supportBus = require('im_support.SupportBus'); + +var _t = core._t; + +var POLL_TIMEOUT_DELAY = 1000 * 60 * 30; // 30 minutes +var POLL_TIMEOUT_KEY = 'im_support.poll_timeout'; +var SUPPORT_CHANNEL_STATE_KEY = 'im_support.channel_state'; + +/** + * This module includes the MailService to handle the case of the Support + * channel, allowing the users of the current database to communicate with + * livechat operators from another database (the Support database). + */ +MailManager.include({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Initialises the Support: checks if there is a pending chat session + * between the user and Support, and if so, re-opens it. + * Note: we can't directly override init(), because it is already called + * when the include is applied, so we use this hook instead (called at + * webclient startup) + */ + initSupport: function () { + var self = this; + this.supportChannelDef = null; + this.supportChannelUUID = null; + this.pollTimeout = null; + this.pollingSupport = false; + + // listen to notifications coming from Support longpolling + supportBus.on('notification', this, this._onSupportNotification); + + // check if there is a pending chat session with the Support + var timeoutTimestamp = this.call('local_storage', 'getItem', POLL_TIMEOUT_KEY); + var pollingDelay = timeoutTimestamp && (JSON.parse(timeoutTimestamp) - Date.now()); + if (pollingDelay && pollingDelay > 0) { + var channelState = this.call('local_storage', 'getItem', SUPPORT_CHANNEL_STATE_KEY); + this.startSupportLivechat(channelState).then(function () { + var supportChannel = self.getChannel(self.supportChannelUUID); + if (supportChannel.isAvailable()) { + self.startPollingSupport(pollingDelay); + } + }); + } + }, + /** + * Overrides to filter out the Support channel from the previews. + * + * @override + * @returns {$.Promise} list of valid objects for mail.Preview + * template + */ + getChannelPreviews: function () { + var self = this; + return this._super.apply(this, arguments) + .then(function (channelsPreview) { + return _.reject(channelsPreview, { id: self.supportChannelUUID }); + }); + }, + /** + * Initiates a longpoll with the server hosting the Support channel. + * + * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling + * timeout delay to set + */ + startPollingSupport: function (pollingDelay) { + if (!this.pollingSupport) { + this.pollingSupport = true; + supportBus.add_channel(this.supportChannelUUID); + supportBus.start_polling(); + this._setPollTimeout(pollingDelay); + } + }, + /** + * Opens the Support channel between a livechat operator from the Support + * database and the current user (if there is an available operator). + * Ensures to perform only once the request to create/retrieve the Support + * channel. + * + * @param {string} [channelState='open'] state of the Support + * channel (see CHANNEL_STATES for accepted values) + * @returns {Deferred} + */ + startSupportLivechat: function (channelState) { + var self = this; + if (!this.supportChannelDef) { + // retrieve or create the channel + this.supportChannelDef = supportSession.rpc('/odoo_im_support/get_support_channel', { + channel_uuid: session.support_token, + db_uuid: session.db_uuid, + user_name: session.name, + }); + } + return this.supportChannelDef.then(function (channel) { + if (!channel) { + // there is no channel (because there is no online operator, and + // no support channel has been created yet), so create one to + // open in a chat window + channel = { + available: false, + support_channel: true, + type: 'livechat', + uuid: "support_unavailable", + }; + } + if (!channelState) { + channelState = self._isDiscussOpen() ? 'closed' : 'open'; + self.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, channelState); + } + if (!self.supportChannelUUID) { + // this part is only executed the first time the RPC is resolved + self.supportChannelUUID = channel.uuid; + + // add the channel to the MailManager + self._addChannel(_.extend(channel, { + id: channel.uuid, + is_minimized: _.contains(['open', 'folded'], channelState), + state: channelState, + })); + + // display an automatic message in the channel + var supportChannel = self.getChannel(channel.uuid); + supportChannel.addDefaultMessage(); + } else { + // the channel has already been added to the MailManager, so + // simply re-open it + if (self._isDiscussOpen()) { + self._openThreadInDiscuss(self.supportChannelUUID); + } else { + channel = self.getChannel(self.supportChannelUUID); + channel.fold(channelState === 'folded'); + } + } + }).fail(function () { + self.do_warn(_t("The Support server can't be reached.")); + }); + }, + /** + * Updates the state of the Support channel (stored in the localStorage). + * + * @param {string} state ('closed', 'folded' or 'open') + */ + updateSupportChannelState: function (state) { + this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, state); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _makeChannel: function (data, options) { + if (data.id === this.supportChannelUUID) { + return new SupportChannel(this, data, options, this._commands); + } + return this._super.apply(this, arguments); + }, + /** + * Overrides to instantiate a SupportMessage when necessary. + * @override + */ + _makeMessage: function (data) { + if (_.contains(data.channel_ids, this.supportChannelUUID)) { + return new SupportMessage(this, data, this._emojis); + } + return this._super.apply(this, arguments); + }, + /** + * Automatically stop polling for Support messages after a given delay of + * inactivity. + * + * @private + * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling + * timeout delay to set + */ + _setPollTimeout: function (pollingDelay) { + pollingDelay = pollingDelay || POLL_TIMEOUT_DELAY; + clearTimeout(this.pollTimeout); + this.pollTimeout = setTimeout(this._stopPollingSupport.bind(this), pollingDelay); + // save the timeout expiration datetime into the LocalStorage so that + // we can re-open the Support channel if necessary on F5 + var timeoutTimestamp = Date.now() + pollingDelay; + this.call('local_storage', 'setItem', POLL_TIMEOUT_KEY, timeoutTimestamp); + }, + /** + * Stops the longpoll with the server hosting the Support channel. + * + * @private + */ + _stopPollingSupport: function () { + this.pollingSupport = false; + supportBus.stop_polling(); + this.call('local_storage', 'removeItem', POLL_TIMEOUT_KEY); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handles poll notifications from the Support server. + * + * @private + * @param {Object[]} notifications + */ + _onSupportNotification: function (notifications) { + var self = this; + if (notifications && notifications.length) { + this._setPollTimeout(); + } + _.each(notifications, function (notification) { + if (notification[1]._type === 'history_command') { + // ignore history requests + return; + } + var message = _.extend(notification[1], { + channel_ids: [self.supportChannelUUID], + }); + self._handleChannelNotification(message); + }); + }, +}); + + +// Unfortunately, we can't override init() of MailService because it is called +// before the include is applied, so we override the WebClient instead to call +// an initialization hook for Livechat Support in the Mail service. +WebClient.include({ + /** + * Overrides to ask the Mail service to check whether there is a + * pending chat session with Support, and if so, to re-open it. + * + * @override + */ + show_application: function () { + this.call('mail_service', 'initSupport'); + return this._super.apply(this, arguments); + }, +}); + +}); + diff --git a/addons/im_support/static/src/js/support_channel.js b/addons/im_support/static/src/js/support_channel.js new file mode 100644 index 00000000000..adcf2a0c51e --- /dev/null +++ b/addons/im_support/static/src/js/support_channel.js @@ -0,0 +1,233 @@ +odoo.define('im_support.SupportChannel', function (require) { +"use strict"; + +var supportSession = require('im_support.SupportSession'); + +var ThreadWithCache = require('mail.model.ThreadWithCache'); + +var core = require('web.core'); +var session = require('web.session'); + +var _t = core._t; + +/** + * This mail model represents support channel, which are communication channels + * between two different databases for support-related reasons. It is like + * livechat, but both users are in different databases, and both users + * communicate from their respective 'backend' access. + * + * FIXME: it should inherit from mail.model.Channel, not from + * mail.model.ThreadWithCache + */ +var SupportChannel = ThreadWithCache.extend({ + init: function (parent, data, options) { + + data.type = 'support_channel'; + data.name = _t("Support"); + + this._available = data.available; + this._operator = data.operator; + this._supportChannelUUID = data.id; + this._welcomeMessage = data.welcome_message; + if (!this._available) { + data.name += _t(" (offline)"); + } + + this._super.apply(this, arguments); + + // force stuff that should probably be in Thread (or at least + // ThreadWithCache), but that are currently in Channel + this._detached = data.is_minimized; + this._folded = data.state === 'folded'; + this._uuid = data.uuid; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds the default message in the Support channel (depending on its + * availability). + */ + addDefaultMessage: function () { + if (!this._available) { + this._addSupportNotAvailableMessage(); + } else { + this._addSupportWelcomeMessage(); + } + }, + /** + * Overrides to store the state of the Support channel in the localStorage. + * + * @override + */ + close: function () { + this._super.apply(this, arguments); + this.call('mail_service', 'updateSupportChannelState', 'closed'); + }, + /** + * Overrides to store the state of the Support channel in the localStorage. + * + * @override + */ + detach: function () { + this._super.apply(this, arguments); + this.call('mail_service', 'updateSupportChannelState', 'open'); + }, + /** + * Overrides to store the state of the Support channel in the localStorage. + * + * @override + */ + fold: function (folded) { + this._super.apply(this, arguments); + var value = folded ? 'folded' : 'open'; + this.call('mail_service', 'updateSupportChannelState', value); + }, + /** + * FIXME: this method is necessary just because the support channel is + * considered as a channel, even though it does not inherit from + * mail.model.Channel. + * + * @returns {boolean} + */ + hasBeenPreviewed: function () { + return true; + }, + /** + * @returns {boolean} true iff the Support channel is available + */ + isAvailable: function () { + return this._available; + }, + /** + * FIXME: this override is necessary just because the support channel is + * considered as a channel, even though it does not inherit from + * mail.model.Channel. + * + * @override + * @returns {boolean} + */ + isChannel: function () { + return true; + }, + /** + * Support channels are not considered as chat, so they are not displayed + * in the 'chat' filter of the systray messaging menu. + * + * @override + * @returns {boolean} + */ + isChat: function () { + return false; + }, + /** + * FIXME: this override is necessary just because the support channel is + * considered as a channel, even though it does not inherit from + * mail.model.Channel. + * + * @returns {integer} + */ + getNeedactionCounter: function () { + return 0; + }, + /** + * @return {string} uuid of this channel + */ + getUUID: function () { + return this._uuid; + }, + /** + * Posts the message on the Support server. + * + * @override + * @return {$.Promise} + */ + postMessage: function (data) { + // ensure that the poll is active before posting the message + this.call('mail_service', 'startPollingSupport'); + return supportSession.rpc('/odoo_im_support/chat_post', { + uuid: this._supportChannelUUID, + message_content: data.content, + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a message in the Support channel indicating that this channel is + * not available for now. + * + * @private + */ + _addSupportNotAvailableMessage: function () { + var msg = { + author_id: this.call('mail_service', 'getOdoobotID'), + body: _t("None of our operators are available. Submit a ticket to ask your question now."), + channel_ids: [this.getID()], + id: Number.MAX_SAFE_INTEGER, // last message in the channel + }; + this.call('mail_service', 'addMessage', msg, { silent: true }); + }, + /** + * Adds the welcome message as first message of the Support channel. + * + * @private + */ + _addSupportWelcomeMessage: function () { + if (this._welcomeMessage) { + var msg = { + author_id: this._operator, + body: this._welcomeMessage, + channel_ids: [this.getID()], + id: -1, // first message of the channel + }; + this.call('mail_service', 'addMessage', msg, { silent: true }); + } + }, + /** + * Fetches the messages from the Support server. + * + * @override + * @private + */ + _fetchMessages: function (channel, options) { + var self = this; + var domain = options && options.domain || []; + var cache = this._getCache(channel, domain); + if (options && options.loadMore) { + var minMessageID = cache.messages[0].id; + domain = [['id', '<', minMessageID]].concat(domain); + } + + return supportSession.rpc('/odoo_im_support/fetch_messages', { + domain: domain, + channel_uuid: session.support_token, + limit: self._FETCH_LIMIT, + }).then(function (msgs) { + if (!cache.all_history_loaded) { + cache.all_history_loaded = msgs.length < self._FETCH_LIMIT; + } + cache.loaded = true; + + _.each(msgs, function (msg) { + _.extend(msg, {channel_ids: [self.getID()]}); + self.call('mail_service', 'addMessage', msg, { + channel_id: self.getID(), + silent: true, + domain: domain, + }); + }); + var channelCache = self._getCache(channel, domain); + return channelCache.messages; + }); + }, +}); + +return SupportChannel; + +}); diff --git a/addons/im_support/static/src/js/support_message.js b/addons/im_support/static/src/js/support_message.js new file mode 100644 index 00000000000..df03d0fa477 --- /dev/null +++ b/addons/im_support/static/src/js/support_message.js @@ -0,0 +1,67 @@ +odoo.define('im_support.SupportMessage', function (require) { +"use strict"; + +var Message = require('mail.model.Message'); + +var session = require('web.session'); + +/** + * This is a model for messages that are in the the support channel + */ +var SupportMessage = Message.extend({ + /** + * @override + * @param {Class} parent + * @param {Object} data + * @param {Array} [data.author_id=[]] + */ + init: function (parent, data) { + data.author_id = data.author_id || []; + if (data.author_id !== this.call('mail_service', 'getOdoobotID')) { + if (!data.author_id[0]) { + // the author is the client + data.author_id = [session.partner_id, session.name]; + } else { + // the author is the operator + // prevent from conflicting with partners of this instance + data.author_id[0] = -1; + } + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {string} + */ + getAvatarSource: function () { + return '/mail/static/src/img/odoo_o.png'; + }, + /** + * Overrides to prevent clicks on the Support operator from redirecting. + * + * @override + * @returns {boolean} + */ + shouldRedirectToAuthor: function () { + return false; + }, + /** + * Overrides to prevent from calling the server for messages of the Support + * channel (which are records on the Support database). + * + * @override + * @returns {$.Promise} + */ + toggleStarStatus: function () { + return $.when(); + }, +}); + +return SupportMessage; + +}); diff --git a/addons/im_support/static/src/js/support_session.js b/addons/im_support/static/src/js/support_session.js index d1228674737..1c6268c40db 100644 --- a/addons/im_support/static/src/js/support_session.js +++ b/addons/im_support/static/src/js/support_session.js @@ -1,15 +1,14 @@ odoo.define('im_support.SupportSession', function (require) { "use strict"; +var session = require('web.session'); +var Session = require('web.Session'); + /** * This module returns an instance of Session which is linked to the Support * server, allowing the current instance to communicate with the Support * server (CORS). */ - -var session = require('web.session'); -var Session = require('web.Session'); - return new Session(null, session.support_origin, { modules: odoo._modules, use_cors: true, diff --git a/addons/im_support/static/src/js/systray.js b/addons/im_support/static/src/js/systray_messaging_menu.js similarity index 75% rename from addons/im_support/static/src/js/systray.js rename to addons/im_support/static/src/js/systray_messaging_menu.js index 99c6f7f077b..c6779dfbb4e 100644 --- a/addons/im_support/static/src/js/systray.js +++ b/addons/im_support/static/src/js/systray_messaging_menu.js @@ -1,18 +1,13 @@ -odoo.define('im_support.systray', function (require) { +odoo.define('im_support.systray.MessagingMenu', function (require) { "use strict"; -/** - * This module adds a fake Support channel in the messaging menu of the systray. - */ - -var mailSystray = require('mail.systray'); +var MessagingMenu = require('mail.systray.MessagingMenu'); var config = require('web.config'); var core = require('web.core'); var session = require('web.session'); var _t = core._t; -var MessagingMenu = mailSystray.MessagingMenu; var SUPPORT_CHANNEL_ID = 'SupportChannel'; // Disable Support in mobile for design purposes (for now at least), and don't @@ -21,6 +16,9 @@ if (config.device.isMobile || !session.support_token || !session.support_origin) return; } +/** + * This module adds a fake Support channel in the messaging menu of the systray. + */ MessagingMenu.include({ /** * @override @@ -29,8 +27,8 @@ MessagingMenu.include({ this._super.apply(this, arguments); this.supportChannel = { id: SUPPORT_CHANNEL_ID, - name: _t('Support'), - image_src: '/mail/static/src/img/odoo_o.png', + name: _t("Support"), + imageSRC: '/mail/static/src/img/odoo_o.png', }; }, /** @@ -45,7 +43,7 @@ MessagingMenu.include({ start: function () { var self = this; return this._super.apply(this, arguments).then(function () { - self.$('.o_mail_navbar_dropdown_bottom').addClass('o_mail_navbar_dropdown_channels'); + self.$('.o_mail_systray_dropdown_bottom').addClass('o_mail_systray_dropdown_items'); }); }, @@ -59,10 +57,10 @@ MessagingMenu.include({ * @override * @private */ - _onClickChannel: function (ev) { - var channelID = $(ev.currentTarget).data('channel_id'); - if (channelID === SUPPORT_CHANNEL_ID) { - this.call('chat_manager', 'startSupportLivechat'); + _onClickPreview: function (ev) { + var id = $(ev.currentTarget).data('preview-id'); + if (id === SUPPORT_CHANNEL_ID) { + this.call('mail_service', 'startSupportLivechat'); } else { this._super.apply(this, arguments); } diff --git a/addons/im_support/static/src/js/thread_window.js b/addons/im_support/static/src/js/thread_window.js new file mode 100644 index 00000000000..9173ef21d41 --- /dev/null +++ b/addons/im_support/static/src/js/thread_window.js @@ -0,0 +1,67 @@ +odoo.define('im_support.ThreadWindow', function (require) { +"use strict"; + +/** + * This module includes ThreadWindow to handle the case of the Support channel. + */ +var ThreadWindow = require('mail.ThreadWindow'); + +ThreadWindow.include({ + /** + * Overrides to tweak the options in the case of the Support channel (no + * star). + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + var channel = this.call('mail_service', 'getChannel', this._getThreadID()); + if (channel && channel.getType() === 'support_channel') { + this.options.displayStars = false; + } + }, + /** + * Overrides to remove the attachment button in the Support channel as this + * feature does not work in that channel. + * + * @note: this feature could be enabled by storing the attachments on the + * Support server. + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + var channel = this.call('mail_service', 'getChannel', this._getThreadID()); + if (channel && channel.getType() === 'support_channel') { + this.$('.o_composer .o_composer_button_add_attachment').remove(); + if (!channel.isAvailable()) { + this.$('.o_thread_composer').remove(); + } + } + return def; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Overrides to prevent from displaying a composer if there is no operator + * available. + * + * @override + * @private + */ + _needsComposer: function () { + var channel = this.call('mail_service', 'getChannel', this._getThreadID()); + if ( + channel && + channel.getType() === 'support_channel' && + !channel.isAvailable() + ) { + return false; + } + return this._super.apply(this, arguments); + }, +}); + +}); diff --git a/addons/im_support/static/src/scss/systray.scss b/addons/im_support/static/src/scss/systray.scss index 0e208fb3479..bb834da98f0 100644 --- a/addons/im_support/static/src/scss/systray.scss +++ b/addons/im_support/static/src/scss/systray.scss @@ -1,6 +1,6 @@ -.o_mail_navbar_item { - .o_mail_navbar_dropdown { - .o_mail_navbar_dropdown_bottom { +.o_mail_systray_item { + .o_mail_systray_dropdown { + .o_mail_systray_dropdown_bottom { border-top: 1px solid $gray-lighter-darker; box-sizing: content-box; diff --git a/addons/im_support/static/src/xml/discuss.xml b/addons/im_support/static/src/xml/discuss.xml index 4797c688d1a..ac428d02612 100644 --- a/addons/im_support/static/src/xml/discuss.xml +++ b/addons/im_support/static/src/xml/discuss.xml @@ -1,15 +1,15 @@ diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 1385da9f61e..1151fa0a81e 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -52,8 +52,8 @@ 'static/src/xml/followers.xml', 'static/src/xml/systray.xml', 'static/src/xml/thread.xml', - 'static/src/xml/chat_window.xml', - 'static/src/xml/extended_chat_window.xml', + 'static/src/xml/abstract_thread_window.xml', + 'static/src/xml/thread_window.xml', 'static/src/xml/announcement.xml', 'static/src/xml/web_kanban_activity.xml', ], diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index 799e8eb6d76..fcbd97bc7ae 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -233,7 +233,7 @@

Hello

You have messages to moderate, please go for the proceedings.



Thank you!

diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index fce7eb29f6f..828e29e195d 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -1,12 +1,13 @@ odoo.define('mail.Activity', function (require) { "use strict"; +var mailUtils = require('mail.utils'); + var AbstractField = require('web.AbstractField'); var BasicModel = require('web.BasicModel'); var core = require('web.core'); var field_registry = require('web.field_registry'); var time = require('web.time'); -var utils = require('mail.utils'); var QWeb = core.qweb; var _t = core._t; @@ -71,38 +72,49 @@ BasicModel.include({ /** * Set the 'label_delay' entry in activity data according to the deadline date + * * @param {Array} activities list of activity Object * @return {Array} : list of modified activity Object */ -var setDelayLabel = function(activities){ +var setDelayLabel = function (activities){ var today = moment().startOf('day'); - _.each(activities, function(activity){ - var to_display = ''; + _.each(activities, function (activity){ + var toDisplay = ''; var diff = activity.date_deadline.diff(today, 'days', true); // true means no rounding - if(diff === 0){ - to_display = _t('Today'); - }else{ - if(diff < 0){ // overdue - if(diff === -1){ - to_display = _t('Yesterday'); - }else{ - to_display = _.str.sprintf(_t('%d days overdue'), Math.abs(diff)); + if (diff === 0){ + toDisplay = _t("Today"); + } else { + if (diff < 0){ // overdue + if (diff === -1){ + toDisplay = _t("Yesterday"); + } else { + toDisplay = _.str.sprintf(_t("%d days overdue"), Math.abs(diff)); } - }else{ // due - if(diff === 1){ - to_display = _t('Tomorrow'); - }else{ - to_display = _.str.sprintf(_t('Due in %d days'), Math.abs(diff)); + } else { // due + if (diff === 1){ + toDisplay = _t("Tomorrow"); + } else { + toDisplay = _.str.sprintf(_t("Due in %d days"), Math.abs(diff)); } } } - activity.label_delay = to_display; + activity.label_delay = toDisplay; }); return activities; }; var AbstractActivityField = AbstractField.extend({ - // private + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * @private + * @param {integer} id + * @param {string} feedback + * @return {$.Promise} + */ _markActivityDone: function (id, feedback) { return this._rpc({ model: 'mail.activity', @@ -112,7 +124,14 @@ var AbstractActivityField = AbstractField.extend({ context: this.record.getContext(), }); }, - _scheduleActivity: function (id, previous_activity_type_id, callback) { + /** + * @private + * @param {integer} id + * @param {integer} previousActivityTypeID + * @param {function} callback + * @return {$.Promise} + */ + _scheduleActivity: function (id, previousActivityTypeID, callback) { var action = { type: 'ir.actions.act_window', res_model: 'mail.activity', @@ -123,7 +142,7 @@ var AbstractActivityField = AbstractField.extend({ context: { default_res_id: this.res_id, default_res_model: this.model, - default_previous_activity_type_id: previous_activity_type_id, + default_previous_activity_type_id: previousActivityTypeID, }, res_id: id || false, }; @@ -144,18 +163,49 @@ var Activity = AbstractActivityField.extend({ }, specialData: '_fetchSpecialActivity', - // inherited + /** + * @override + */ init: function () { this._super.apply(this, arguments); - this.activities = this.record.specialData[this.name]; + this._activities = this.record.specialData[this.name]; }, + + //------------------------------------------------------------ + // Public + //------------------------------------------------------------ + + /** + * @param {integer} previousActivityTypeID + * @return {$.Promise} + */ + scheduleActivity: function (previousActivityTypeID) { + var callback = this._reload.bind(this, { activity: true, thread: true }); + return this._scheduleActivity(false, previousActivityTypeID, callback); + }, + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * @private + * @param {Object} fieldsToReload + */ + _reload: function (fieldsToReload) { + this.trigger_up('reload_mail_fields', fieldsToReload); + }, + /** + * @override + * @private + */ _render: function () { - _.each(this.activities, function (activity) { + _.each(this._activities, function (activity) { if (activity.note) { - activity.note = utils.parse_and_transform(activity.note, utils.add_link); + activity.note = mailUtils.parseAndTransform(activity.note, mailUtils.addLink); } }); - var activities = setDelayLabel(this.activities); + var activities = setDelayLabel(this._activities); if (activities.length) { var nbActivities = _.countBy(activities, 'state'); this.$el.html(QWeb.render('mail.activity_items', { @@ -163,31 +213,33 @@ var Activity = AbstractActivityField.extend({ nbPlannedActivities: nbActivities.planned, nbTodayActivities: nbActivities.today, nbOverdueActivities: nbActivities.overdue, - date_format: time.getLangDateFormat(), - datetime_format: time.getLangDatetimeFormat(), + dateFormat: time.getLangDateFormat(), + datetimeFormat: time.getLangDatetimeFormat(), })); } else { this.$el.empty(); } }, + /** + * @override + * @private + * @param {Object} record + */ _reset: function (record) { this._super.apply(this, arguments); - this.activities = this.record.specialData[this.name]; + this._activities = this.record.specialData[this.name]; // the mail widgets being persistent, one need to update the res_id on reset this.res_id = record.res_id; }, - // public - scheduleActivity: function (previous_activity_type_id) { - var callback = this._reload.bind(this, {activity: true, thread: true}); - return this._scheduleActivity(false, previous_activity_type_id, callback); - }, - // private - _reload: function (fieldsToReload) { - this.trigger_up('reload_mail_fields', fieldsToReload); - }, + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ - // handlers + /** + * @private + * @param {MouseEvent} ev + */ _onClickRedirect: function (ev) { var id = $(ev.target).data('oe-id'); if (id) { @@ -199,10 +251,15 @@ var Activity = AbstractActivityField.extend({ }); } }, - _onEditActivity: function (event, options) { - event.preventDefault(); + /** + * @private + * @param {MouseEvent} ev + * @param {Object} options + */ + _onEditActivity: function (ev, options) { + ev.preventDefault(); var self = this; - var activity_id = $(event.currentTarget).data('activity-id'); + var activity_id = $(ev.currentTarget).data('activity-id'); var action = _.defaults(options || {}, { type: 'ir.actions.act_window', res_model: 'mail.activity', @@ -222,12 +279,17 @@ var Activity = AbstractActivityField.extend({ }, }); }, - _onUnlinkActivity: function (event, options) { - event.preventDefault(); - var activity_id = $(event.currentTarget).data('activity-id'); + /** + * @private + * @param {MouseEvent} ev + * @param {Object} options + */ + _onUnlinkActivity: function (ev, options) { + ev.preventDefault(); + var activityID = $(ev.currentTarget).data('activity-id'); options = _.defaults(options || {}, { model: 'mail.activity', - args: [[activity_id]], + args: [[activityID]], }); return this._rpc({ model: options.model, @@ -236,47 +298,51 @@ var Activity = AbstractActivityField.extend({ }) .then(this._reload.bind(this, {activity: true})); }, - _onMarkActivityDone: function (event) { - event.preventDefault(); + /** + * @private + * @param {MouseEvent} ev + */ + _onMarkActivityDone: function (ev) { + ev.preventDefault(); var self = this; - var $popover_el = $(event.currentTarget); - var activity_id = $popover_el.data('activity-id'); - var previous_activity_type_id = $popover_el.data('previous-activity-type-id'); - if (!$popover_el.data('bs.popover')) { - $popover_el.popover({ - title : _t('Feedback'), + var $popoverElement = $(ev.currentTarget); + var activityID = $popoverElement.data('activity-id'); + var previousActivityTypeID = $popoverElement.data('previous-activity-type-id'); + if (!$popoverElement.data('bs.popover')) { + $popoverElement.popover({ + title : _t("Feedback"), html: 'true', trigger:'click', - content : function() { - var $popover = $(QWeb.render("mail.activity_feedback_form", {'previous_activity_type_id': previous_activity_type_id})); + content : function () { + var $popover = $(QWeb.render('mail.activity_feedback_form', { 'previous_activity_type_id': previousActivityTypeID })); $popover.on('click', '.o_activity_popover_done_next', function () { var feedback = _.escape($popover.find('#activity_feedback').val()); - var previous_activity_type_id = $popover_el.data('previous-activity-type-id'); - self._markActivityDone(activity_id, feedback) - .then(self.scheduleActivity.bind(self, previous_activity_type_id)); + var previousActivityTypeID = $popoverElement.data('previous-activity-type-id'); + self._markActivityDone(activityID, feedback) + .then(self.scheduleActivity.bind(self, previousActivityTypeID)); }); $popover.on('click', '.o_activity_popover_done', function () { var feedback = _.escape($popover.find('#activity_feedback').val()); - self._markActivityDone(activity_id, feedback) - .then(self._reload.bind(self, {activity: true, thread: true})); + self._markActivityDone(activityID, feedback) + .then(self._reload.bind(self, { activity: true, thread: true })); }); $popover.on('click', '.o_activity_popover_discard', function () { - $popover_el.popover('hide'); + $popoverElement.popover('hide'); }); return $popover; }, - }).on("show.bs.popover", function (e) { - var $popover = $(this).data("bs.popover").tip(); + }).on('show.bs.popover', function (e) { + var $popover = $(this).data('bs.popover').tip(); $popover.addClass('o_mail_activity_feedback').attr('tabindex', 0); - $(".o_mail_activity_feedback.popover").not(e.target).popover("hide"); - }).on("shown.bs.popover", function () { - var $popover = $(this).data("bs.popover").tip(); + $('.o_mail_activity_feedback.popover').not(e.target).popover('hide'); + }).on('shown.bs.popover', function () { + var $popover = $(this).data('bs.popover').tip(); $popover.find('#activity_feedback').focus(); $popover.off('focusout'); $popover.focusout(function (e) { // outside click of popover hide the popover // e.relatedTarget is the element receiving the focus - if(!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) { + if (!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) { $popover.popover('hide'); } }); @@ -296,7 +362,9 @@ var KanbanActivity = AbstractActivityField.extend({ 'click .o_mark_as_done': '_onMarkActivityDone', }, - // inherited + /** + * @override + */ init: function (parent, name, record) { this._super.apply(this, arguments); var selection = {}; @@ -306,59 +374,98 @@ var KanbanActivity = AbstractActivityField.extend({ this.selection = selection; this._setState(record); }, + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * @private + */ + _reload: function () { + this.trigger_up('reload', { db_id: this.record_id }); + }, + /** + * @override + * @private + */ _render: function () { - var $span = this.$(".o_activity_btn > span"); + var $span = this.$('.o_activity_btn > span'); $span.removeClass(function (index, classNames) { return classNames.split(/\s+/).filter(function (className) { return _.str.startsWith(className, 'o_activity_color_'); }).join(' '); }); - $span.addClass('o_activity_color_' + (this.activity_state || 'default')); + $span.addClass('o_activity_color_' + (this.activityState || 'default')); if (this.$el.hasClass('open')) { // note: this part of the rendering might be asynchronous this._renderDropdown(); } }, - _reset: function (record) { - this._super.apply(this, arguments); - this._setState(record); - }, - - // private - _reload: function () { - this.trigger_up('reload', {db_id: this.record_id}); - }, + /** + * @private + */ _renderDropdown: function () { var self = this; - this.$('.o_activity').html(QWeb.render("mail.KanbanActivityLoading")); + this.$('.o_activity').html(QWeb.render('mail.KanbanActivityLoading')); return _readActivities(this, this.value.res_ids).then(function (activities) { - self.$('.o_activity').html(QWeb.render("mail.KanbanActivityDropdown", { + self.$('.o_activity').html(QWeb.render('mail.KanbanActivityDropdown', { selection: self.selection, records: _.groupBy(setDelayLabel(activities), 'state'), uid: self.getSession().uid, })); }); }, + /** + * @override + * @private + * @param {Object} record + */ + _reset: function (record) { + this._super.apply(this, arguments); + this._setState(record); + }, + /** + * @private + * @param {Object} record + */ _setState: function (record) { this.record_id = record.id; - this.activity_state = this.recordData.activity_state; + this.activityState = this.recordData.activity_state; }, - // handlers - _onButtonClicked: function (event) { - event.preventDefault(); + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * @private + * @param {MouseEvent} ev + */ + _onButtonClicked: function (ev) { + ev.preventDefault(); if (!this.$el.hasClass('open')) { this._renderDropdown(); } }, - _onMarkActivityDone: function (event) { - event.stopPropagation(); - var activity_id = $(event.currentTarget).data('activity-id'); - this._markActivityDone(activity_id).then(this._reload.bind(this)); + /** + * @private + * @param {MouseEvent} ev + */ + _onMarkActivityDone: function (ev) { + ev.stopPropagation(); + var activityID = $(ev.currentTarget).data('activity-id'); + this._markActivityDone(activityID).then(this._reload.bind(this)); }, - _onScheduleActivity: function (event) { - var activity_id = $(event.currentTarget).data('activity-id') || false; - return this._scheduleActivity(activity_id, false, this._reload.bind(this)); + /** + * @private + * @param {MouseEvent} ev + * @returns {$.Promise} + */ + _onScheduleActivity: function (ev) { + var activityID = $(ev.currentTarget).data('activity-id') || false; + return this._scheduleActivity(activityID, false, this._reload.bind(this)); }, }); diff --git a/addons/mail/static/src/js/chat_window.js b/addons/mail/static/src/js/chat_window.js deleted file mode 100644 index 9e080f3a248..00000000000 --- a/addons/mail/static/src/js/chat_window.js +++ /dev/null @@ -1,199 +0,0 @@ -odoo.define('mail.ChatWindow', function (require) { -"use strict"; - -var ChatThread = require('mail.ChatThread'); - -var config = require('web.config'); -var core = require('web.core'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -var HEIGHT_OPEN = '400px'; -var HEIGHT_FOLDED = '34px'; - -return Widget.extend({ - template: "mail.ChatWindow", - custom_events: { - escape_pressed: '_onEscapePressed', - document_viewer_closed: '_onDocumentViewerClose', - }, - events: { - 'click .o_chat_composer': '_onComposerClick', - "click .o_mail_thread": "_onChatWindowClicked", - "keydown .o_chat_composer": "on_keydown", - "keypress .o_chat_composer": "on_keypress", - "click .o_chat_window_close": "on_click_close", - "click .o_chat_title": "on_click_fold", - }, - - init: function (parent, channel_id, title, is_folded, unread_msgs, options) { - this._super(parent); - this.title = title; - this.channel_id = channel_id; - this.folded = is_folded; - this.options = _.defaults(options || {}, { - autofocus: true, - display_stars: true, - display_reply_icon: false, - display_email_icon: false, - placeholder: _t("Say something"), - input_less: false, - }); - this.status = this.options.status; - this.unread_msgs = unread_msgs || 0; - this.is_hidden = false; - this.isMobile = config.device.isMobile; - }, - start: function () { - this.$input = this.$('.o_composer_text_field'); - this.$header = this.$('.o_chat_header'); - - this.thread = new ChatThread(this, { - channel_id: this.channel_id, - display_needactions: false, - display_stars: this.options.display_stars, - }); - this.thread.on('toggle_star_status', null, this.trigger.bind(this, 'toggle_star_status')); - this.thread.on('redirect_to_channel', null, this.trigger.bind(this, 'redirect_to_channel')); - this.thread.on('redirect', null, this.trigger.bind(this, 'redirect')); - - if (this.folded) { - this.$el.css('height', HEIGHT_FOLDED); - } else if (this.options.autofocus) { - this.focus_input(); - } - if (!config.device.isMobile) { - this.$el.css('margin-right', $.position.scrollbarWidth()); - } - var def = this.thread.replace(this.$('.o_chat_content')); - return $.when(this._super(), def); - }, - render: function (messages) { - this.update_unread(this.unread_msgs); - this.thread.render(messages, {display_load_more: false}); - }, - update_unread: function (counter) { - this.unread_msgs = counter; - this.render_header(); - }, - update_status: function (status) { - this.status = status; - this.render_header(); - }, - render_header: function () { - this.$header.html(QWeb.render('mail.ChatWindowHeaderContent', { - status: this.status, - title: this.title, - unread_counter: this.unread_msgs, - widget: this, - })); - }, - fold: function () { - this.$el.animate({ - height: this.folded ? HEIGHT_FOLDED : HEIGHT_OPEN - }, 200); - }, - toggle_fold: function (fold) { - this.folded = _.isBoolean(fold) ? fold : !this.folded; - if (!this.folded) { - this.thread.scroll_to(); - this.focus_input(); - } - this.fold(); - }, - focus_input: function () { - if (config.device.touch && config.device.size_class <= config.device.SIZES.SM) { - return; - } - this.$input.focus(); - }, - do_show: function () { - this.is_hidden = false; - this._super.apply(this, arguments); - }, - do_hide: function () { - this.is_hidden = true; - this._super.apply(this, arguments); - }, - do_toggle: function (display) { - this.is_hidden = _.isBoolean(display) ? !display : !this.is_hidden; - this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - on_keypress: function (event) { - event.stopPropagation(); // to prevent jquery's blockUI to cancel event - }, - on_keydown: function (event) { - event.stopPropagation(); // to prevent jquery's blockUI to cancel event - // ENTER key (avoid requiring jquery ui for external livechat) - if (event.which === 13) { - var content = _.str.trim(this.$input.val()); - var message = { - content: content, - attachment_ids: [], - partner_ids: [], - }; - this.$input.val(''); - if (content) { - this.trigger('post_message', message, { - channelID: this.channel_id, - }); - } - } - }, - on_click_close: function (event) { - event.stopPropagation(); - event.preventDefault(); - this.trigger("close_chat_session"); - }, - on_click_fold: function () { - if (!config.device.isMobile) { - this.toggle_fold(); - this.trigger("fold_channel", this.channel_id, this.folded); - } - }, - /** - * When a chat window is clicked on, we want to give the focus to the main - * input. An exception is made when the user is selecting something. - * - * @private - */ - _onChatWindowClicked: function () { - var selectObj = window.getSelection(); - if (selectObj.anchorOffset === selectObj.focusOffset) { - this.$input.focus(); - } - }, - /** - * Called when the composer is clicked -> forces focus on input even if - * jquery's blockUI is enabled. - * - * @private - * @param {Event} ev - */ - _onComposerClick: function (ev) { - if ($(ev.target).closest('a, button').length) { - return; - } - this.focus_input(); - }, - _onDocumentViewerClose: function (ev) { - this.focus_input(); - }, - /** - * @private - */ - _onEscapePressed: function () { - if (!this.folded) { - this.trigger("close_chat_session"); - } - }, -}); - -}); diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index 63948b8429a..01f6c3ec3ff 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -2,10 +2,10 @@ odoo.define('mail.Chatter', function (require) { "use strict"; var Activity = require('mail.Activity'); -var ChatterComposer = require('mail.ChatterComposer'); +var ChatterComposer = require('mail.composer.Chatter'); var Followers = require('mail.Followers'); var ThreadField = require('mail.ThreadField'); -var utils = require('mail.utils'); +var mailUtils = require('mail.utils'); var concurrency = require('web.concurrency'); var config = require('web.config'); @@ -47,13 +47,13 @@ var Chatter = Widget.extend({ this._super.apply(this, arguments); this._setState(record); - this.dp = new concurrency.DropPrevious(); + this._dp = new concurrency.DropPrevious(); // mention: get the prefetched partners and use them as mention suggestions // if there is a follower widget, the followers will be added to the // suggestions as well once fetched - this.mentionPartnerSuggestions = this.call('chat_manager', 'getMentionPartnerSuggestions'); - this.mentionSuggestions = this.mentionPartnerSuggestions; + this._mentionPartnerSuggestions = this.call('mail_service', 'getMentionPartnerSuggestions'); + this._mentionSuggestions = this._mentionPartnerSuggestions; this.fields = {}; if (mailFields.mail_activity) { @@ -73,19 +73,19 @@ var Chatter = Widget.extend({ * @override */ start: function () { - this.$topbar = this.$('.o_chatter_topbar'); + this._$topbar = this.$('.o_chatter_topbar'); // render and append the buttons - this.$topbar.append(QWeb.render('mail.Chatter.Buttons', { - new_message_btn: !!this.fields.thread, - log_note_btn: this.hasLogButton, - schedule_activity_btn: !!this.fields.activity, + this._$topbar.append(QWeb.render('mail.chatter.Buttons', { + newMessageButton: !!this.fields.thread, + logNoteButton: this.hasLogButton, + scheduleActivityButton: !!this.fields.activity, isMobile: config.device.isMobile, })); // start and append the widgets var fieldDefs = _.invoke(this.fields, 'appendTo', $('
')); - var def = this.dp.add($.when.apply($, fieldDefs)); + var def = this._dp.add($.when.apply($, fieldDefs)); this._render(def).then(this._updateMentionSuggestions.bind(this)); return this._super.apply(this, arguments); @@ -132,7 +132,7 @@ var Chatter = Widget.extend({ fieldsToReset = this.fields; } var fieldDefs = _.invoke(fieldsToReset, 'reset', record); - var def = this.dp.add($.when.apply($, fieldDefs)); + var def = this._dp.add($.when.apply($, fieldDefs)); this._render(def).then(function () { self.$el.height('auto'); self._updateMentionSuggestions(); @@ -148,13 +148,19 @@ var Chatter = Widget.extend({ * @param {boolean} force */ _closeComposer: function (force) { - if (this.composer && (this.composer.is_empty() || force)) { + if (this._composer && (this._composer.isEmpty() || force)) { this.$el.removeClass('o_chatter_composer_active'); this.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active'); - this.composer.do_hide(); - this.composer.clear_composer(); + this._composer.do_hide(); + this._composer.clearComposer(); } }, + /** + * @private + */ + _disableChatter: function () { + this.$('.btn').prop('disabled', true); // disable buttons + }, /** * Discard changes on the record. * @@ -176,22 +182,16 @@ var Chatter = Widget.extend({ * after posting it * * @private - * @param {Object} message + * @param {Object} messageData * @return {$.Deferred} resolved if no reload or proceed to discard the * changes on the record, rejected otherwise */ - _discardOnReload: function (message) { - if (this._reloadAfterPost(message)) { + _discardOnReload: function (messageData) { + if (this._reloadAfterPost(messageData)) { return this._discardChanges(); } return $.when(); }, - /** - * @private - */ - _disableChatter: function () { - this.$('.btn').prop('disabled', true); // disable buttons - }, /** * @private */ @@ -202,51 +202,49 @@ var Chatter = Widget.extend({ * @private * @param {Object} options * @param {Object[]} [options.suggested_partners=[]] - * @param {boolean} [options.is_log] + * @param {boolean} [options.isLog] */ _openComposer: function (options) { var self = this; - var old_composer = this.composer; + var oldComposer = this._composer; // create the new composer - this.composer = new ChatterComposer(this, this.record.model, options.suggested_partners || [], { - commands_enabled: false, + this._composer = new ChatterComposer(this, this.record.model, options.suggested_partners || [], { + commandsEnabled: false, context: this.context, - input_min_height: 50, - input_max_height: Number.MAX_VALUE, // no max_height limit for the chatter - input_baseline: 14, - is_log: options && options.is_log, - record_name: this.record_name, - default_body: old_composer && old_composer.$input && old_composer.$input.val(), - default_mention_selections: old_composer && old_composer.mention_get_listener_selections(), + inputMinHeight: 50, + isLog: options && options.isLog, + recordName: this.recordName, + defaultBody: oldComposer && oldComposer.$input && oldComposer.$input.val(), + defaultMentionSelections: oldComposer && oldComposer.getMentionListenerSelections(), }); - this.composer.on('input_focused', this, function () { - this.composer.mention_set_prefetched_partners(this.mentionSuggestions || []); + this._composer.on('input_focused', this, function () { + this._composer.mentionSetPrefetchedPartners(this._mentionSuggestions || []); }); - this.composer.insertAfter(this.$('.o_chatter_topbar')).then(function () { + this._composer.insertAfter(this.$('.o_chatter_topbar')).then(function () { // destroy existing composer - if (old_composer) { - old_composer.destroy(); + if (oldComposer) { + oldComposer.destroy(); } if (!config.device.touch) { - self.composer.focus(); + self._composer.focus(); } - self.composer.on('post_message', self, function (message) { - self._discardOnReload(message).then(function () { - self.fields.thread.postMessage(message).then(function () { + self._composer.on('post_message', self, function (messageData) { + self._discardOnReload(messageData).then(function () { + self.fields.thread.postMessage(messageData).then(function () { self._closeComposer(true); - if (self._reloadAfterPost(message)) { + if (self._reloadAfterPost(messageData)) { self.trigger_up('reload'); } }); }); }); - self.composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload')); - self.composer.on('close_composer', null, self._closeComposer.bind(self, true)); + self._composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload')); + self._composer.on('close_composer', null, self._closeComposer.bind(self, true)); self.$el.addClass('o_chatter_composer_active'); self.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active'); - self.$('.o_chatter_button_new_message').toggleClass('o_active', !self.composer.options.is_log); - self.$('.o_chatter_button_log_note').toggleClass('o_active', self.composer.options.is_log); + self.$('.o_chatter_button_new_message').toggleClass('o_active', !self._composer.options.isLog); + self.$('.o_chatter_button_log_note').toggleClass('o_active', self._composer.options.isLog); }); }, /** @@ -254,17 +252,17 @@ var Chatter = Widget.extend({ * Useful to warn the user of unsaved changes if the record is dirty. * * @private - * @param {Object} message - * @param {Array} [message.partner_ids] list of recipients of a message + * @param {Object} messageData + * @param {Array} [messageData.partner_ids] list of recipients of a message * @return {boolean} true if record will be reloaded after posting the * message, false otherwise */ - _reloadAfterPost: function (message) { + _reloadAfterPost: function (messageData) { return this.postRefresh === 'always' || ( this.postRefresh === 'recipients' && - message.partner_ids && - message.partner_ids.length + messageData.partner_ids && + messageData.partner_ids.length ); }, /** @@ -291,14 +289,14 @@ var Chatter = Widget.extend({ self.fields.activity.$el.appendTo(self.$el); } if (self.fields.followers) { - self.fields.followers.$el.appendTo(self.$topbar); + self.fields.followers.$el.appendTo(self._$topbar); } if (self.fields.thread) { self.fields.thread.$el.appendTo(self.$el); } }).always(function () { // disable widgets in create mode, otherwise enable - self.isCreateMode ? self._disableChatter() : self._enableChatter(); + self._isCreateMode ? self._disableChatter() : self._enableChatter(); $spinner.remove(); }); }, @@ -311,8 +309,7 @@ var Chatter = Widget.extend({ */ _setState: function (record) { - this.wasCreateMode = !!this.isCreateMode; - this.isCreateMode = !record.res_id; + this._isCreateMode = !record.res_id; if (!this.record || this.record.res_id !== record.res_id) { this.context = { @@ -324,7 +321,7 @@ var Chatter = Widget.extend({ this.suggested_partners_def = undefined; } this.record = record; - this.record_name = record.data.display_name; + this.recordName = record.data.display_name; }, /** * @private @@ -335,28 +332,28 @@ var Chatter = Widget.extend({ } var self = this; - this.mentionSuggestions = []; + this._mentionSuggestions = []; // add the followers to the mention suggestions - var follower_suggestions = []; + var followerSuggestions = []; var followers = this.fields.followers.getFollowers(); _.each(followers, function (follower) { if (follower.res_model === 'res.partner') { - follower_suggestions.push({ + followerSuggestions.push({ id: follower.res_id, name: follower.name, email: follower.email, }); } }); - if (follower_suggestions.length) { - this.mentionSuggestions.push(follower_suggestions); + if (followerSuggestions.length) { + this._mentionSuggestions.push(followerSuggestions); } // add the partners (followers filtered out) to the mention suggestions - _.each(this.mentionPartnerSuggestions, function (partners) { - self.mentionSuggestions.push(_.filter(partners, function (partner) { - return !_.findWhere(follower_suggestions, { id: partner.id }); + _.each(this._mentionPartnerSuggestions, function (partners) { + self._mentionSuggestions.push(_.filter(partners, function (partner) { + return !_.findWhere(followerSuggestions, { id: partner.id }); })); }); }, @@ -390,7 +387,7 @@ var Chatter = Widget.extend({ var suggested_partners = []; var thread_recipients = result[self.context.default_res_id]; _.each(thread_recipients, function (recipient) { - var parsed_email = recipient[1] && utils.parse_email(recipient[1]); + var parsed_email = recipient[1] && mailUtils.parseEmail(recipient[1]); suggested_partners.push({ checked: true, partner_id: recipient[0], @@ -404,14 +401,14 @@ var Chatter = Widget.extend({ }); } this.suggested_partners_def.then(function (suggested_partners) { - self._openComposer({ is_log: false, suggested_partners: suggested_partners }); + self._openComposer({ isLog: false, suggested_partners: suggested_partners }); }); }, /** * @private */ _onOpenComposerNote: function () { - this._openComposer({is_log: true}); + this._openComposer({ isLog: true }); }, /** * @private diff --git a/addons/mail/static/src/js/chatter_composer.js b/addons/mail/static/src/js/chatter_composer.js deleted file mode 100644 index b5c17f3f593..00000000000 --- a/addons/mail/static/src/js/chatter_composer.js +++ /dev/null @@ -1,254 +0,0 @@ -odoo.define('mail.ChatterComposer', function (require) { -"use strict"; - -var composer = require('mail.composer'); -var utils = require('mail.utils'); - -var core = require('web.core'); -var view_dialogs = require('web.view_dialogs'); - -var _t = core._t; - -// Chat Composer for the Chatter -// -// Extends the basic Composer Widget to add 'suggested partner' layer (open -// popup when suggested partner is selected without email, or other -// informations), and the button to open the full composer wizard. -var ChatterComposer = composer.BasicComposer.extend({ - template: 'mail.chatter.ChatComposer', - - init: function (parent, model, suggested_partners, options) { - this._super(parent, options); - this.model = model; - this.suggested_partners = suggested_partners; - this.options = _.defaults(this.options, { - display_mode: 'textarea', - record_name: false, - is_log: false, - }); - if (this.options.is_log) { - this.options.send_text = _t('Log'); - } - this.events = _.extend(this.events, { - 'click .o_composer_button_full_composer': 'on_open_full_composer', - }); - this.notInline = true; - }, - - should_send: function () { - return false; - }, - - preprocess_message: function () { - var self = this; - var def = $.Deferred(); - this._super().then(function (message) { - message = _.extend(message, { - subtype: 'mail.mt_comment', - message_type: 'comment', - context: self.context, - }); - - // Subtype - if (self.options.is_log) { - message.subtype = 'mail.mt_note'; - } - - // Partner_ids - if (!self.options.is_log) { - var checked_suggested_partners = self.get_checked_suggested_partners(); - self.check_suggested_partners(checked_suggested_partners).done(function (partner_ids) { - message.partner_ids = (message.partner_ids || []).concat(partner_ids); - // update context - message.context = _.defaults({}, message.context, { - mail_post_autofollow: true, - }); - def.resolve(message); - }); - } else { - def.resolve(message); - } - - }); - - return def; - }, - - clear_composer_on_send: function() { - /* chatter don't clear message on sent but after successful sent */ - }, - - /** - * Send the message on SHIFT+ENTER, but go to new line on ENTER - */ - prevent_send: function (event) { - return !event.shiftKey; - }, - - /** - * Get the list of selected suggested partners - * @returns {Array} list of 'recipient' selected partners (may not be created in db) - **/ - get_checked_suggested_partners: function () { - var self = this; - var checked_partners = []; - this.$('.o_composer_suggested_partners input:checked').each(function() { - var full_name = $(this).data('fullname'); - checked_partners = checked_partners.concat(_.filter(self.suggested_partners, function(item) { - return full_name === item.full_name; - })); - }); - return checked_partners; - }, - - /** - * Check the additional partners (not necessary registered partners), and open a popup form view - * for the ones who informations is missing. - * @param {Array} checked_suggested_partners list of 'recipient' partners to complete informations or validate - * @returns {Deferred} resolved with the list of checked suggested partners (real partner) - **/ - check_suggested_partners: function (checked_suggested_partners) { - var self = this; - var check_done = $.Deferred(); - - var recipients = _.filter(checked_suggested_partners, function (recipient) { return recipient.checked; }); - var recipients_to_find = _.filter(recipients, function (recipient) { return (! recipient.partner_id); }); - var names_to_find = _.pluck(recipients_to_find, 'full_name'); - var recipients_to_check = _.filter(recipients, function (recipient) { return (recipient.partner_id && ! recipient.email_address); }); - var recipient_ids = _.pluck(_.filter(recipients, function (recipient) { return recipient.partner_id && recipient.email_address; }), 'partner_id'); - - var names_to_remove = []; - var recipient_ids_to_remove = []; - - // have unknown names -> call message_get_partner_info_from_emails to try to find partner_id - var def; - if (names_to_find.length > 0) { - def = this._rpc({ - model: this.model, - method: 'message_partner_info_from_emails', - args: [[this.context.default_res_id], names_to_find], - }); - } - - // for unknown names + incomplete partners -> open popup - cancel = remove from recipients - $.when(def).pipe(function (result) { - result = result || []; - var emails_deferred = []; - var recipient_popups = result.concat(recipients_to_check); - - _.each(recipient_popups, function (partner_info) { - var deferred = $.Deferred(); - emails_deferred.push(deferred); - - var partner_name = partner_info.full_name; - var partner_id = partner_info.partner_id; - var parsed_email = utils.parse_email(partner_name); - - var dialog = new view_dialogs.FormViewDialog(self, { - res_model: 'res.partner', - res_id: partner_id, - context: { - active_model: self.model, - active_id: self.context.default_res_id, - force_email: true, - ref: "compound_context", - default_name: parsed_email[0], - default_email: parsed_email[1], - }, - title: _t("Please complete customer's informations"), - disable_multiple_selection: true, - }).open(); - dialog.on('closed', self, function () { - deferred.resolve(); - }); - dialog.opened().then(function () { - dialog.form_view.on('on_button_cancel', self, function () { - names_to_remove.push(partner_name); - if (partner_id) { - recipient_ids_to_remove.push(partner_id); - } - }); - }); - }); - $.when.apply($, emails_deferred).then(function () { - var new_names_to_find = _.difference(names_to_find, names_to_remove); - var def; - if (new_names_to_find.length > 0) { - def = self._rpc({ - model: self.model, - method: 'message_partner_info_from_emails', - args: [[self.context.default_res_id], new_names_to_find, true], - }); - } - $.when(def).pipe(function (result) { - result = result || []; - var recipient_popups = result.concat(recipients_to_check); - _.each(recipient_popups, function (partner_info) { - if (partner_info.partner_id && _.indexOf(partner_info.partner_id, recipient_ids_to_remove) === -1) { - recipient_ids.push(partner_info.partner_id); - } - }); - }).pipe(function () { - check_done.resolve(recipient_ids); - }); - }); - }); - return check_done; - }, - - on_open_full_composer: function() { - if (!this.do_check_attachment_upload()){ - return false; - } - - var self = this; - var recipientDoneDef = $.Deferred(); - - // any operation on the full-composer will reload the record, so - // warn the user that any unsaved changes on the record will be lost. - this.trigger_up('discard_record_changes', { - proceed: function () { - if (self.options.is_log) { - recipientDoneDef.resolve([]); - } else { - var checked_suggested_partners = self.get_checked_suggested_partners(); - self.check_suggested_partners(checked_suggested_partners) - .then(recipientDoneDef.resolve.bind(recipientDoneDef)); - } - }, - }); - - recipientDoneDef.then(function (partner_ids) { - var context = { - default_parent_id: self.id, - default_body: utils.get_text2html(self.$input.val()), - default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'), - default_partner_ids: partner_ids, - default_is_log: self.options.is_log, - mail_post_autofollow: true, - }; - - if (self.context.default_model && self.context.default_res_id) { - context.default_model = self.context.default_model; - context.default_res_id = self.context.default_res_id; - } - - var action = { - type: 'ir.actions.act_window', - res_model: 'mail.compose.message', - view_mode: 'form', - view_type: 'form', - views: [[false, 'form']], - target: 'new', - context: context, - }; - self.do_action(action, { - on_close: self.trigger.bind(self, 'need_refresh'), - }).then(self.trigger.bind(self, 'close_composer')); - }); - } -}); - -return ChatterComposer; - -}); diff --git a/addons/mail/static/src/js/composer.js b/addons/mail/static/src/js/composer.js deleted file mode 100644 index f89c0382554..00000000000 --- a/addons/mail/static/src/js/composer.js +++ /dev/null @@ -1,922 +0,0 @@ -odoo.define('mail.composer', function (require) { -"use strict"; - -var DocumentViewer = require('mail.DocumentViewer'); -var utils = require('mail.utils'); - -var config = require('web.config'); -var core = require('web.core'); -var data = require('web.data'); -var dom = require('web.dom'); -var session = require('web.session'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -var NON_BREAKING_SPACE = '\u00a0'; - -var MENTION_PARTNER_DELIMITER = '@'; -var MENTION_CHANNEL_DELIMITER = '#'; -var MENTION_CANNED_RESPONSE_DELIMITER = ':'; -var MENTION_COMMAND_DELIMITER = '/'; - -// The MentionManager allows the Composer to register listeners. For each -// listener, it detects if the user is currently typing a mention (starting by a -// given delimiter). If so, if fetches mention suggestions and renders them. On -// suggestion clicked, it updates the selection for the corresponding listener. -var MentionManager = Widget.extend({ - className: 'dropup o_composer_mention_dropdown', - - events: { - "mouseover .o_mention_proposition": "on_hover_mention_proposition", - "click .o_mention_proposition": "on_click_mention_item", - }, - - init: function (parent, options) { - this._super.apply(this, arguments); - - this.composer = parent; - this.options = _.extend({}, options, { - min_length: 0, - }); - - this.open = false; - this.listeners = []; - this.set('mention_suggestions', []); - this.on('change:mention_suggestions', this, this._render_suggestions); - }, - - // Events - on_hover_mention_proposition: function (event) { - var $elem = $(event.currentTarget); - this.$('.o_mention_proposition').removeClass('active'); - $elem.addClass('active'); - }, - on_click_mention_item: function (event) { - event.preventDefault(); - - var text_input = this.composer.$input.val(); - var id = $(event.currentTarget).data('id'); - var selected_suggestion = _.find(_.flatten(this.get('mention_suggestions')), function (s) { - return s.id === id; - }); - var substitution = selected_suggestion.substitution; - if (!substitution) { // no substitution string given, so use the mention name instead - // replace white spaces with non-breaking spaces to facilitate mentions detection in text - selected_suggestion.name = selected_suggestion.name.replace(/ /g, NON_BREAKING_SPACE); - substitution = this.active_listener.delimiter + selected_suggestion.name; - } - var get_mention_index = function (matches, cursor_position) { - for (var i=0; i links - * @param {string} [model] (optional) the model used for redirection - * @param {string} [redirect_classname] (optional) the classname of the wrapping the mention - * @param {array} [selection] (optional) initial mentions for each listener - * @param {string} [suggestion_template] the template of suggestions' dropdown - */ - register: function (listener) { - this.listeners.push(_.defaults(listener, { - model: '', - redirect_classname: '', - selection: [], - })); - }, - - /** - * Returns true if the mention suggestions dropdown is open, false otherwise - */ - is_open: function () { - return this.open; - }, - - /** - * Returns the mentions of the given listener that haven't been erased from the composer's input - */ - get_listener_selection: function (delimiter) { - var listener = _.findWhere(this.listeners, {delimiter: delimiter}); - if (listener) { - var input_mentions = this.composer.$input.val().match(new RegExp(delimiter+'[^ ]+(?= | )', 'g')); - return this._validate_selection(listener.selection, input_mentions); - } - return []; - }, - - get_listener_selections: function () { - var selections = {}; - _.each(this.listeners, function (listener) { - selections[listener.delimiter] = listener.selection; - }); - return selections; - }, - - /** - * Detects if the user is currently typing a mention word - * @return the search string if it is, false otherwise - */ - detect_delimiter: function () { - var self = this; - var text_val = this.composer.$input.val(); - var cursor_position = this._get_selection_positions().start; - var left_string = text_val.substring(0, cursor_position); - function validate_keyword (delimiter, beginning_only) { - var delimiter_position = left_string.lastIndexOf(delimiter) - 1; - if (beginning_only && delimiter_position > 0) { - return false; - } - var search_str = text_val.substring(delimiter_position, cursor_position); - var pattern = "(^"+delimiter+"|(^\\s"+delimiter+"))"; - var regex_start = new RegExp(pattern, "g"); - search_str = search_str.replace(/^\s\s*|^[\n\r]/g, ''); - if (regex_start.test(search_str) && search_str.length > self.options.min_length) { - search_str = search_str.replace(pattern, ''); - return search_str.indexOf(' ') < 0 && !/[\r\n]/.test(search_str) ? search_str.replace(delimiter, '') : false; - } - return false; - } - - this.active_listener = undefined; - for (var i=0; i Flatten and insert dividers between groups - var insert_divider = false; - _.each(this.get('mention_suggestions'), function (suggestion_group) { - if (suggestion_group.length > 0) { - if (insert_divider) { - suggestions.push({ divider: true }); - } - suggestions = suggestions.concat(suggestion_group); - insert_divider = true; - } - }); - } else { - suggestions = this.get('mention_suggestions'); - } - if (suggestions.length) { - this.$el.html(QWeb.render(this.active_listener.suggestion_template, { - suggestions: suggestions, - })); - this.$el - .addClass('open') - .find('ul').css("max-width", this.composer.$input.width()) - .find('.o_mention_proposition').first().addClass('active'); - this.open = true; - } else { - this.$el.removeClass('open'); - this.$el.empty(); - this.open = false; - } - }, - _validate_selection: function (selection, input_mentions) { - var validated_selection = []; - _.each(input_mentions, function (mention) { - var validated_mention = _.findWhere(selection, {name: mention.slice(1)}); - if (validated_mention) { - validated_selection.push(validated_mention); - } - }); - return validated_selection; - }, - - // Cursor position and selection utils - _get_selection_positions: function () { - var el = this.composer.$input.get(0); - return el ? dom.getSelectionRange(el) : {start: 0, end: 0}; - }, - _set_cursor_position: function (pos) { - this.composer.$input.each(function (index, elem) { - dom.setSelectionRange(elem, {start: pos, end: pos}); - }); - }, - -}); - -var BasicComposer = Widget.extend({ - template: "mail.ChatComposer", - events: { - "keydown .o_composer_input textarea": "on_keydown", - "keyup .o_composer_input": "on_keyup", - "change input.o_input_file": "on_attachment_change", - "click .o_composer_button_send": "send_message", - "click .o_composer_button_add_attachment": "on_click_add_attachment", - "click .o_attachment_delete": "on_attachment_delete", - "click .o_attachment_download": "_onAttachmentDownload", - "click .o_attachment_view": "_onAttachmentView", - 'click .o_composer_button_emoji': '_onEmojiButtonClick', - 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout', - 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus', - 'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick', - }, - // RPCs done to fetch the mention suggestions are throttled with the following value - MENTION_THROTTLE: 200, - - init: function (parent, options) { - this._super.apply(this, arguments); - this.options = _.defaults(options || {}, { - commands_enabled: true, - context: {}, - input_baseline: 18, - input_max_height: 150, - input_min_height: 28, - mention_fetch_limit: 8, - mention_partners_restricted: false, // set to true to only suggest prefetched partners - send_text: _t('Send'), - default_body: '', - default_mention_selections: {}, - isMobile: config.device.isMobile - }); - this.context = this.options.context; - - // Attachments - this.AttachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context); - this.fileupload_id = _.uniqueId('o_chat_fileupload'); - this.set('attachment_ids', []); - - // Mention - this.mention_manager = new MentionManager(this); - this.mention_manager.register({ - delimiter: MENTION_PARTNER_DELIMITER, - fetch_callback: this.mention_fetch_partners.bind(this), - generate_links: true, - model: 'res.partner', - redirect_classname: 'o_mail_redirect', - selection: this.options.default_mention_selections[MENTION_PARTNER_DELIMITER], - suggestion_template: 'mail.MentionPartnerSuggestions', - }); - this.mention_manager.register({ - delimiter: MENTION_CHANNEL_DELIMITER, - fetch_callback: this.mention_fetch_channels.bind(this), - generate_links: true, - model: 'mail.channel', - redirect_classname: 'o_channel_redirect', - selection: this.options.default_mention_selections[MENTION_CHANNEL_DELIMITER], - suggestion_template: 'mail.MentionChannelSuggestions', - }); - this.mention_manager.register({ - delimiter: MENTION_CANNED_RESPONSE_DELIMITER, - fetch_callback: this.mention_get_canned_responses.bind(this), - selection: this.options.default_mention_selections[MENTION_CANNED_RESPONSE_DELIMITER], - suggestion_template: 'mail.MentionCannedResponseSuggestions', - }); - if (this.options.commands_enabled) { - this.mention_manager.register({ - beginning_only: true, - delimiter: MENTION_COMMAND_DELIMITER, - fetch_callback: this.mention_get_commands.bind(this), - selection: this.options.default_mention_selections[MENTION_COMMAND_DELIMITER], - suggestion_template: 'mail.MentionCommandSuggestions', - }); - } - - this.isMini = options.isMini; - - this.avatarURL = session.uid > 0 ? session.url('/web/image', { - model: 'res.users', - field: 'image_small', - id: session.uid, - }) : '/web/static/src/img/user_menu_avatar.png'; - }, - - start: function () { - var self = this; - - this.$attachment_button = this.$(".o_composer_button_add_attachment"); - this.$attachments_list = this.$('.o_composer_attachments_list'); - this.$input = this.$('.o_composer_input textarea'); - this.$input.focus(function () { - self.trigger('input_focused'); - }); - this.$input.val(this.options.default_body); - dom.autoresize(this.$input, {parent: this, min_height: this.options.input_min_height}); - - // Attachments - $(window).on(this.fileupload_id, this.on_attachment_loaded); - this.on("change:attachment_ids", this, this.render_attachments); - - // Mention - this.mention_manager.prependTo(this.$('.o_composer')); - - return this._super(); - }, - - destroy: function () { - $(window).off(this.fileupload_id); - return this._super.apply(this, arguments); - }, - - preprocess_message: function () { - // Return a deferred as this function is extended with asynchronous - // behavior for the chatter composer - - //Removing unwanted extra spaces from message - var value = _.escape(this.$input.val()).trim(); - value = value.replace(/(\r|\n){2,}/g, '

'); - value = value.replace(/(\r|\n)/g, '
'); - - // prevent html space collapsing - value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2'); - var commands = this.options.commands_enabled ? this.mention_manager.get_listener_selection('/') : []; - return $.when({ - content: this.mention_manager.generate_links(value), - attachment_ids: _.pluck(this.get('attachment_ids'), 'id'), - partner_ids: _.uniq(_.pluck(this.mention_manager.get_listener_selection('@'), 'id')), - command: commands.length > 0 ? commands[0].name : undefined, - }); - }, - - send_message: function () { - if (this.is_empty() || !this.do_check_attachment_upload()) { - return; - } - - clearTimeout(this.canned_timeout); - var self = this; - this.preprocess_message().then(function (message) { - self.trigger('post_message', message); - self.clear_composer_on_send(); - self.$input.focus(); - }); - }, - - clear_composer: function() { - // Empty input, selected partners and attachments - this.$input.val(''); - this.mention_manager.reset_selections(); - this.set('attachment_ids', []); - }, - - clear_composer_on_send: function() { - this.clear_composer(); - }, - - getState: function () { - return { - attachments: this.get('attachment_ids'), - text: this.$input.val(), - }; - }, - - // Events - on_click_add_attachment: function () { - this.$('input.o_input_file').click(); - this.$input.focus(); - // set ignoreEscape to avoid escape_pressed event when file selector dialog is opened - // when user press escape to cancel file selector dialog then escape_pressed event should not be trigerred - this.ignoreEscape = true; - }, - - setState: function (state) { - this.set('attachment_ids', state.attachments); - this.$input.val(state.text); - }, - - /** - * Send the message on ENTER, but go to new line on SHIFT+ENTER - */ - should_send: function (event) { - return !event.shiftKey; - }, - - on_keydown: function (event) { - switch(event.which) { - // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - case $.ui.keyCode.TAB: - if (this.mention_manager.is_open()) { - event.preventDefault(); - } - break; - // ENTER: submit the message only if the dropdown mention proposition is not displayed - case $.ui.keyCode.ENTER: - if (this.mention_manager.is_open()) { - event.preventDefault(); - } else { - var send_message = event.ctrlKey || this.should_send(event); - if (send_message) { - event.preventDefault(); - this.send_message(); - } - } - break; - } - }, - - on_keyup: function (event) { - switch(event.which) { - // ESCAPED KEYS: do nothing - case $.ui.keyCode.END: - case $.ui.keyCode.PAGE_UP: - case $.ui.keyCode.PAGE_DOWN: - break; - // ESCAPE: close mention propositions - case $.ui.keyCode.ESCAPE: - if (this.mention_manager.is_open()) { - event.stopPropagation(); - this.mention_manager.reset_suggestions(); - } else if (this.ignoreEscape) { - this.ignoreEscape = false; - } else { - this.trigger_up("escape_pressed"); - } - break; - // ENTER, UP, DOWN, TAB: check if navigation in mention propositions - case $.ui.keyCode.ENTER: - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - case $.ui.keyCode.TAB: - if (this.mention_manager.is_open()) { - this.mention_manager._propositionNavigation(event.which); - } - break; - // Otherwise, check if a mention is typed - default: - this.mention_manager.detect_delimiter(); - } - }, - - // Attachments - on_attachment_change: function(event) { - var self = this, - files = event.target.files, - attachments = self.get('attachment_ids'); - - _.each(files, function(file){ - var attachment = _.findWhere(attachments, {name: file.name}); - // if the files already exits, delete the file before upload - if(attachment){ - self.AttachmentDataSet.unlink([attachment.id]); - attachments = _.without(attachments, attachment); - } - }); - - this.$('form.o_form_binary_form').submit(); - this.$attachment_button.prop('disabled', true); - var upload_attachments = _.map(files, function(file){ - return { - 'id': 0, - 'name': file.name, - 'filename': file.name, - 'url': '', - 'upload': true, - 'mimetype': '', - }; - }); - attachments = attachments.concat(upload_attachments); - this.set('attachment_ids', attachments); - }, - on_attachment_loaded: function(event) { - var self = this, - attachments = this.get('attachment_ids'), - files = Array.prototype.slice.call(arguments, 1); - - _.each(files, function(file){ - if(file.error || !file.id){ - this.do_warn(file.error); - attachments = _.filter(attachments, function (attachment) { return !attachment.upload; }); - }else{ - var attachment = _.findWhere(attachments, {filename: file.filename, upload: true}); - if(attachment){ - attachments = _.without(attachments, attachment); - attachments.push({ - 'id': file.id, - 'name': file.name || file.filename, - 'filename': file.filename, - 'mimetype': file.mimetype, - 'url': session.url('/web/content', {'id': file.id, download: true}), - }); - } - } - }.bind(this)); - this.set('attachment_ids', attachments); - this.$attachment_button.prop('disabled', false); - }, - on_attachment_delete: function(event){ - event.stopPropagation(); - var self = this; - var attachment_id = $(event.target).data("id"); - if (attachment_id) { - var attachments = []; - _.each(this.get('attachment_ids'), function(attachment){ - if (attachment_id !== attachment.id) { - attachments.push(attachment); - } else { - self.AttachmentDataSet.unlink([attachment_id]); - } - }); - this.set('attachment_ids', attachments); - } - }, - do_check_attachment_upload: function () { - if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) { - this.do_warn(_t("Uploading error"), _t("Please, wait while the file is uploading.")); - return false; - } - return true; - }, - render_attachments: function() { - this.$attachments_list.html(QWeb.render('mail.ChatComposer.Attachments', { - attachments: this.get('attachment_ids'), - })); - }, - - // Mention - mention_fetch_throttled: function (model, method, kwargs) { - var self = this; - // Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing - var def = $.Deferred(); - clearTimeout(this.mention_fetch_timer); - this.mention_fetch_timer = setTimeout(function () { - return self._rpc({model: model, method: method, kwargs: kwargs}) - .then(function (results) { - def.resolve(results); - }); - }, this.MENTION_THROTTLE); - return def; - }, - mention_fetch_channels: function (search) { - return this.mention_fetch_throttled('mail.channel', 'get_mention_suggestions', { - limit: this.options.mention_fetch_limit, - search: search, - }).then(function (suggestions) { - return _.partition(suggestions, function (suggestion) { - return _.contains(['public', 'groups'], suggestion.public); - }); - }); - }, - mention_fetch_partners: function (search) { - var self = this; - return $.when(this.mention_prefetched_partners).then(function (prefetched_partners) { - // filter prefetched partners with the given search string - var suggestions = []; - var limit = self.options.mention_fetch_limit; - var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i'); - _.each(prefetched_partners, function (partners) { - if (limit > 0) { - var filtered_partners = _.filter(partners, function (partner) { - return partner.email && search_regexp.test(partner.email) || - partner.name && search_regexp.test(utils.unaccent(partner.name)); - }); - if (filtered_partners.length) { - suggestions.push(filtered_partners.slice(0, limit)); - limit -= filtered_partners.length; - } - } - }); - if (!suggestions.length && !self.options.mention_partners_restricted) { - // no result found among prefetched partners, fetch other suggestions - suggestions = self.mention_fetch_throttled('res.partner', 'get_mention_suggestions', { - limit: limit, - search: search, - }); - } - return suggestions; - }); - }, - mention_get_canned_responses: function (search) { - var self = this; - var def = $.Deferred(); - clearTimeout(this.canned_timeout); - this.canned_timeout = setTimeout(function () { - var cannedResponses = self.call('chat_manager', 'getCannedResponses'); - var matches = fuzzy.filter(utils.unaccent(search), _.pluck(cannedResponses, 'source')); - var indexes = _.pluck(matches.slice(0, self.options.mention_fetch_limit), 'index'); - def.resolve(_.map(indexes, function (i) { - return cannedResponses[i]; - })); - }, 500); - return def; - }, - mention_get_commands: function (search) { - var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i'); - return _.filter(this.mention_commands, function (command) { - return search_regexp.test(command.name); - }).slice(0, this.options.mention_fetch_limit); - }, - mention_set_prefetched_partners: function (prefetched_partners) { - this.mention_prefetched_partners = prefetched_partners; - }, - mention_set_enabled_commands: function (commands) { - this.mention_commands = commands; - }, - mention_get_listener_selections: function () { - return this.mention_manager.get_listener_selections(); - }, - - // Others - is_empty: function () { - return !this.$input.val().trim() && !this.$('.o_attachments').children().length; - }, - focus: function () { - this.$input.focus(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Hides the emojis container. - * - * @private - */ - _hideEmojis: function () { - this.$emojisContainer.remove(); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} event - */ - _onAttachmentDownload: function (event) { - event.stopPropagation(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onAttachmentView: function (event) { - event.stopPropagation(); - var activeAttachmentID = $(event.currentTarget).data('id'); - var attachments = this.get('attachment_ids'); - if (activeAttachmentID) { - var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID); - attachmentViewer.appendTo($('body')); - } - }, - /** - * Called when the emoji button is clicked -> opens/hides the emoji panel. - * Also, this method is in charge of the rendering of this panel the first - * time it is opened. - * - * @private - */ - _onEmojiButtonClick: function () { - if (!this.$emojisContainer) { // lazy rendering - this.$emojisContainer = $(QWeb.render('mail.ChatComposer.emojis', { - emojis: this.call('chat_manager', 'getEmojis'), - })); - } - if (this.$emojisContainer.parent().length) { - this._hideEmojis(); - } else { - this.$emojisContainer.appendTo(this.$('.o_composer')); - } - }, - /** - * Called when the emoji button is blurred -> closes the emoji panel. The - * closing is scheduled to be done at the end of the current execution - * stack to allow stoping this closing if the button was focusout to select - * an emoji (for example). - * - * @private - */ - _onEmojiButtonFocusout: function () { - if (this.$emojisContainer) { - this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0); - } - }, - /** - * Called when an emoji is focused -> @see _onEmojiButtonFocusout - * - * @private - */ - _onEmojiImageFocus: function () { - clearTimeout(this._hideEmojisTimeout); - }, - /** - * Called when an emoji is clicked -> adds it in the , focuses the - * and closes the emoji panel. - * - * @private - * @param {Event} ev - */ - _onEmojiImageClick: function (ev) { - this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " "); - this.$input.focus(); - this._hideEmojis(); - }, -}); - -var ExtendedComposer = BasicComposer.extend({ - init: function (parent, options) { - options = _.defaults(options || {}, { - input_min_height: 120, - }); - this._super(parent, options); - this.extended = true; - }, - - start: function () { - this.$subject_input = this.$(".o_composer_subject input"); - return this._super.apply(this, arguments); - }, - - preprocess_message: function () { - var self = this; - return this._super().then(function (message) { - var subject = self.$subject_input.val(); - self.$subject_input.val(""); - message.subject = subject; - return message; - }); - }, - clear_composer: function () { - this._super.apply(this, arguments); - this.$subject_input.val(''); - }, - getState: function () { - var state = this._super.apply(this, arguments); - state.subject = this.$subject_input.val(); - return state; - }, - should_send: function () { - return false; - }, - focus: function (target) { - if (target === 'body') { - this.$input.focus(); - } else { - this.$subject_input.focus(); - } - }, - set_subject: function(subject) { - this.$('.o_composer_subject input').val(subject); - }, - setState: function (state) { - this._super.apply(this, arguments); - this.set_subject(state.subject); - }, -}); - -return { - BasicComposer: BasicComposer, - ExtendedComposer: ExtendedComposer, -}; - -}); diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js new file mode 100644 index 00000000000..ce7c1017ffa --- /dev/null +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -0,0 +1,640 @@ +odoo.define('mail.composer.Basic', function (require) { +"use strict"; + +var emojis = require('mail.emojis'); +var MentionManager = require('mail.composer.MentionManager'); +var DocumentViewer = require('mail.DocumentViewer'); +var mailUtils = require('mail.utils'); + +var config = require('web.config'); +var core = require('web.core'); +var data = require('web.data'); +var dom = require('web.dom'); +var session = require('web.session'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +var BasicComposer = Widget.extend({ + template: "mail.Composer", + events: { + 'change input.o_input_file': '_onAttachmentChange', + 'click .o_attachment_delete': '_onAttachmentDelete', + 'click .o_attachment_download': '_onAttachmentDownload', + 'click .o_attachment_view': '_onAttachmentView', + 'click .o_composer_button_add_attachment': '_onClickAddAttachment', + 'click .o_composer_button_emoji': '_onEmojiButtonClick', + 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout', + 'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick', + 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus', + 'keydown .o_composer_input textarea': '_onKeydown', + 'keyup .o_composer_input': '_onKeyup', + 'click .o_composer_button_send': '_sendMessage', + }, + // RPCs done to fetch the mention suggestions are throttled with the + // following value + MENTION_THROTTLE: 200, + + init: function (parent, options) { + this._super.apply(this, arguments); + this.options = _.defaults(options || {}, { + commandsEnabled: true, + context: {}, + inputMinHeight: 28, + mentionFetchLimit: 8, + // set to true to only suggest prefetched partners + mentionPartnersRestricted: false, + sendText: _t("Send"), + defaultBody: '', + defaultMentionSelections: {}, + isMobile: config.device.isMobile + }); + this.context = this.options.context; + + // Attachments + this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context); + this.fileuploadID = _.uniqueId('o_chat_fileupload'); + this.set('attachment_ids', []); + + // Mention + this._mentionManager = new MentionManager(this); + this._mentionManager.register({ + delimiter: '@', + fetchCallback: this._mentionFetchPartners.bind(this), + generateLinks: true, + model: 'res.partner', + redirectClassname: 'o_mail_redirect', + selection: this.options.defaultMentionSelections['@'], + suggestionTemplate: 'mail.MentionSuggestions.Partner', + }); + this._mentionManager.register({ + delimiter: '#', + fetchCallback: this._mentionFetchChannels.bind(this), + generateLinks: true, + model: 'mail.channel', + redirectClassname: 'o_channel_redirect', + selection: this.options.defaultMentionSelections['#'], + suggestionTemplate: 'mail.MentionSuggestions.Channel', + }); + this._mentionManager.register({ + delimiter: ':', + fetchCallback: this._mentionGetCannedResponses.bind(this), + selection: this.options.defaultMentionSelections[':'], + suggestionTemplate: 'mail.MentionSuggestions.CannedResponse', + }); + if (this.options.commandsEnabled) { + this._mentionManager.register({ + beginningOnly: true, + delimiter: '/', + fetchCallback: this._mentionGetCommands.bind(this), + selection: this.options.defaultMentionSelections['/'], + suggestionTemplate: 'mail.MentionSuggestions.Command', + }); + } + + this.isMini = options.isMini; + + this.avatarURL = session.uid > 0 ? session.url('/web/image', { + model: 'res.users', + field: 'image_small', + id: session.uid, + }) : '/web/static/src/img/user_menu_avatar.png'; + }, + start: function () { + var self = this; + + this._$attachmentButton = this.$('.o_composer_button_add_attachment'); + this._$attachmentsList = this.$('.o_composer_attachments_list'); + this.$input = this.$('.o_composer_input textarea'); + this.$input.focus(function () { + self.trigger('input_focused'); + }); + this.$input.val(this.options.defaultBody); + dom.autoresize(this.$input, { + parent: this, + min_height: this.options.inputMinHeight + }); + + // Attachments + $(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this)); + this.on('change:attachment_ids', this, this._renderAttachments); + + // Mention + this._mentionManager.prependTo(this.$('.o_composer')); + + return this._super(); + }, + destroy: function () { + $(window).off(this.fileuploadID); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Empty input, selected partners and attachments + */ + clearComposer: function () { + this.$input.val(''); + this._mentionManager.resetSelections(); + this.set('attachment_ids', []); + }, + /** + * Set the focus on the input + */ + focus: function () { + this.$input.focus(); + }, + /** + * Get the state of the composer. + * + * This is useful in order to (re)store its state when switchng to another + * thread with a composer, and coming back to the thread with this composer. + * + * @returns {Object} + */ + getState: function () { + return { + attachments: this.get('attachment_ids'), + text: this.$input.val(), + }; + }, + /** + * @returns {integer} it returns length of attachment and trim content of + * input box to check for empty. + */ + isEmpty: function () { + return !this.$input.val().trim() && + !this.$('.o_attachments').children().length; + }, + /** + * Set the state of the composer. + * + * This is useful in order to (re)store its state when switching to another + * thread with a composer, and coming back to the thread with this composer. + * + * @param {Object} state + * @param {Array} state.attachments + * @param {string} state.text + */ + setState: function (state) { + this.set('attachment_ids', state.attachments); + this.$input.val(state.text); + }, + /** + * Set the list of command suggestions on the thread. + * + * When the user partially types a command, matching commands are displayed + * to the user (@see _mentionGetCommands). + * + * @param {Object[]} commands + */ + mentionSetCommands: function (commands) { + this._mentionCommands = commands; + }, + /** + * Set the list of partner mentions suggestions that has been prefetched. + * + * When the user partially types a mention, matching prefetched partners are + * displayed to the user. If none of them match, then it will fetch for more + * partner suggestions (@see _mentionFetchPartners). + * + * @param {$.Deferred} prefetchedPartners + */ + mentionSetPrefetchedPartners: function (prefetchedPartners) { + this._mentionPrefetchedPartners = prefetchedPartners; + }, + /** + * @returns {Object} containing listener selections, grouped by delimiter + * as key of the object. + */ + getMentionListenerSelections: function () { + return this._mentionManager.getListenerSelections(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _clearComposerOnSend: function () { + this.clearComposer(); + }, + /** + * @private + */ + _doCheckAttachmentUpload: function () { + if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) { + this.do_warn(_t("Uploading error"), _t("Please, wait while the file is uploading.")); + return false; + } + return true; + }, + /** + * Hides the emojis container. + * + * @private + */ + _hideEmojis: function () { + this._$emojisContainer.remove(); + }, + /** + * @private + * @param {string} search + * @returns {$.Deferred} + */ + _mentionGetCannedResponses: function (search) { + var self = this; + var def = $.Deferred(); + clearTimeout(this._cannedTimeout); + this._cannedTimeout = setTimeout(function () { + var cannedResponses = self.call('mail_service', 'getCannedResponses'); + var matches = fuzzy.filter(mailUtils.unaccent(search), _.pluck(cannedResponses, 'source')); + var indexes = _.pluck(matches.slice(0, self.options.mentionFetchLimit), 'index'); + def.resolve(_.map(indexes, function (index) { + return cannedResponses[index]; + })); + }, 500); + return def; + }, + /** + * @private + * @param {string} search + * @returns {Array} + */ + _mentionGetCommands: function (search) { + var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i'); + return _.filter(this._mentionCommands, function (command) { + return searchRegexp.test(command.name); + }).slice(0, this.options.mentionFetchLimit); + }, + /** + * @private + * @param {string} search + */ + _mentionFetchChannels: function (search) { + return this._mentionFetchThrottled('mail.channel', 'get_mention_suggestions', { + limit: this.options.mentionFetchLimit, + search: search, + }).then(function (suggestions) { + return _.partition(suggestions, function (suggestion) { + return _.contains(['public', 'groups'], suggestion.public); + }); + }); + }, + /** + * @private + * @param {string} search + */ + _mentionFetchPartners: function (search) { + var self = this; + return $.when(this._mentionPrefetchedPartners).then(function (partners) { + // filter prefetched partners with the given search string + var suggestions = []; + var limit = self.options.mentionFetchLimit; + var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i'); + if (limit > 0) { + var filteredPartners = _.filter(partners, function (partner) { + return partner.email && searchRegexp.test(partner.email) || + partner.name && searchRegexp.test(mailUtils.unaccent(partner.name)); + }); + if (filteredPartners.length) { + suggestions.push(filteredPartners.slice(0, limit)); + limit -= filteredPartners.length; + } + } + if (!suggestions.length && !self.options.mentionPartnersRestricted) { + // no result found among prefetched partners, fetch other suggestions + suggestions = self._mentionFetchThrottled( + 'res.partner', + 'get_mention_suggestions', + { limit: limit, search: search } + ); + } + return suggestions; + }); + }, + /** + * @private + * @param {string} model + * @param {string} method + * @param {Object} kwargs + * @return {$.Deferred} + */ + _mentionFetchThrottled: function (model, method, kwargs) { + var self = this; + // Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing + var def = $.Deferred(); + clearTimeout(this.mentionFetchTimer); + this.mentionFetchTimer = setTimeout(function () { + return self._rpc({model: model, method: method, kwargs: kwargs}) + .then(function (results) { + def.resolve(results); + }); + }, this.MENTION_THROTTLE); + return def; + }, + /** + * @private + * @returns {$.Deferred} + */ + _preprocessMessage: function () { + // Return a deferred as this function is extended with asynchronous + // behavior for the chatter composer + + //Removing unwanted extra spaces from message + var value = _.escape(this.$input.val()).trim(); + value = value.replace(/(\r|\n){2,}/g, '

'); + value = value.replace(/(\r|\n)/g, '
'); + + // prevent html space collapsing + value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2'); + var commands = this.options.commandsEnabled ? + this._mentionManager.getListenerSelection('/') : + []; + return $.when({ + content: this._mentionManager.generateLinks(value), + attachment_ids: _.pluck(this.get('attachment_ids'), 'id'), + partner_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelection('@'), 'id')), + command: commands.length > 0 ? commands[0].name : undefined, + }); + }, + /** + * @private + */ + _renderAttachments: function () { + this._$attachmentsList.html(QWeb.render('mail.composer.Attachments', { + attachments: this.get('attachment_ids'), + })); + }, + /** + * @private + */ + _sendMessage: function () { + if (this.isEmpty() || !this._doCheckAttachmentUpload()) { + return; + } + + clearTimeout(this._cannedTimeout); + var self = this; + this._preprocessMessage().then(function (message) { + self.trigger('post_message', message); + self._clearComposerOnSend(); + self.$input.focus(); + }); + }, + /** + * Send the message on ENTER, but go to new line on SHIFT+ENTER + * + * @private + */ + _shouldSend: function (ev) { + return !ev.shiftKey; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQuery.Event} ev + */ + _onAttachmentChange: function (ev) { + var self = this; + var files = ev.target.files; + var attachments = this.get('attachment_ids'); + + _.each(files, function (file){ + var attachment = _.findWhere(attachments, {name: file.name}); + // if the files already exits, delete the file before upload + if (attachment){ + self._attachmentDataSet.unlink([attachment.id]); + attachments = _.without(attachments, attachment); + } + }); + + this.$('form.o_form_binary_form').submit(); + this._$attachmentButton.prop('disabled', true); + var uploadAttachments = _.map(files, function (file){ + return { + id: 0, + name: file.name, + filename: file.name, + url: '', + upload: true, + mimetype: '', + }; + }); + attachments = attachments.concat(uploadAttachments); + this.set('attachment_ids', attachments); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onAttachmentDelete: function (ev){ + ev.stopPropagation(); + var self = this; + var attachmentID = $(ev.target).data('id'); + if (attachmentID) { + var attachments = []; + _.each(this.get('attachment_ids'), function (attachment){ + if (attachmentID !== attachment.id) { + attachments.push(attachment); + } else { + self._attachmentDataSet.unlink([attachmentID]); + } + }); + this.set('attachment_ids', attachments); + } + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onAttachmentDownload: function (ev) { + ev.stopPropagation(); + }, + /** + * @private + */ + _onAttachmentLoaded: function () { + var attachments = this.get('attachment_ids'); + var files = Array.prototype.slice.call(arguments, 1); + + _.each(files, function (file) { + if (file.error || !file.id){ + this.do_warn(file.error); + attachments = _.filter(attachments, function (attachment) { + return !attachment.upload; + }); + } else { + var attachment = _.findWhere(attachments, { filename: file.filename, upload: true }); + if (attachment) { + attachments = _.without(attachments, attachment); + attachments.push({ + id: file.id, + name: file.name || file.filename, + filename: file.filename, + mimetype: file.mimetype, + url: session.url('/web/content', { id: file.id, download: true }), + }); + } + } + }.bind(this)); + this.set('attachment_ids', attachments); + this._$attachmentButton.prop('disabled', false); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onAttachmentView: function (ev) { + var activeAttachmentID = $(ev.currentTarget).data('id'); + var attachments = this.get('attachment_ids'); + if (activeAttachmentID) { + var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID); + attachmentViewer.appendTo($('body')); + } + }, + _onClickAddAttachment: function () { + this.$('input.o_input_file').click(); + this.$input.focus(); + }, + /** + * Called when the emoji button is clicked -> opens/hides the emoji panel. + * Also, this method is in charge of the rendering of this panel the first + * time it is opened. + * + * @private + */ + _onEmojiButtonClick: function () { + if (!this._$emojisContainer) { // lazy rendering + this._$emojisContainer = $(QWeb.render('mail.Composer.emojis', { + emojis: emojis, + })); + } + if (this._$emojisContainer.parent().length) { + this._hideEmojis(); + } else { + this._$emojisContainer.appendTo(this.$('.o_composer')); + } + }, + /** + * Called when the emoji button is blurred -> closes the emoji panel. The + * closing is scheduled to be done at the end of the current execution + * stack to allow stoping this closing if the button was focusout to select + * an emoji (for example). + * + * @private + */ + _onEmojiButtonFocusout: function () { + if (this._$emojisContainer) { + this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0); + } + }, + /** + * Called when an emoji is clicked -> adds it in the , focuses the + * and closes the emoji panel. + * + * @private + * @param {Event} ev + */ + _onEmojiImageClick: function (ev) { + this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " "); + this.$input.focus(); + this._hideEmojis(); + }, + /** + * Called when an emoji is focused -> @see _onEmojiButtonFocusout + * + * @private + */ + _onEmojiImageFocus: function () { + clearTimeout(this._hideEmojisTimeout); + }, + /** + * _onKeydown event is triggered when is key is pressed + * - on UP and DOWN arrow is pressed then event prevents it's default + * behaviour if mention manager is open else it break it. + * - on ENTER key is pressed and mention manager is open then event + * prevents it's default behaviour else check if ControlKey is + * pressed or not if yes then it send a message + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown: function (ev) { + switch (ev.which) { + // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions + case $.ui.keyCode.UP: + case $.ui.keyCode.DOWN: + case $.ui.keyCode.TAB: + if (this._mentionManager.isOpen()) { + ev.preventDefault(); + } + break; + // ENTER: submit the message only if the dropdown mention proposition is not displayed + case $.ui.keyCode.ENTER: + if (this._mentionManager.isOpen()) { + ev.preventDefault(); + } else { + var sendMessage = ev.ctrlKey || this._shouldSend(ev); + if (sendMessage) { + ev.preventDefault(); + this._sendMessage(); + } + } + break; + } + }, + /** + * _onKeyup event is triggered when key is released. + * on ESCAP key it close the mention suggestion dropdown menu. + * on ENTER key it selects that mention and add to a content editable div. + * on UP or DOWN key it highlights previous or next mention respectively. + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeyup: function (ev) { + switch (ev.which) { + // ESCAPED KEYS: do nothing + case $.ui.keyCode.END: + case $.ui.keyCode.PAGE_UP: + case $.ui.keyCode.PAGE_DOWN: + break; + // ESCAPE: close mention propositions + case $.ui.keyCode.ESCAPE: + if (this._mentionManager.isOpen()) { + ev.stopPropagation(); + this._mentionManager.resetSuggestions(); + } else { + this.trigger_up('escape_pressed'); + } + break; + // ENTER, UP, DOWN, TAB: check if navigation in mention propositions + case $.ui.keyCode.ENTER: + case $.ui.keyCode.UP: + case $.ui.keyCode.DOWN: + case $.ui.keyCode.TAB: + if (this._mentionManager.isOpen()) { + this._mentionManager.propositionNavigation(ev.which); + } + break; + // Otherwise, check if a mention is typed + default: + this._mentionManager.detectDelimiter(); + } + }, +}); + +return BasicComposer; + +}); diff --git a/addons/mail/static/src/js/composers/chatter_composer.js b/addons/mail/static/src/js/composers/chatter_composer.js new file mode 100644 index 00000000000..d1a990b31e4 --- /dev/null +++ b/addons/mail/static/src/js/composers/chatter_composer.js @@ -0,0 +1,295 @@ +odoo.define('mail.composer.Chatter', function (require) { +"use strict"; + +var BasicComposer = require('mail.composer.Basic'); +var mailUtils = require('mail.utils'); + +var core = require('web.core'); +var viewDialogs = require('web.view_dialogs'); + +var _t = core._t; + +/** + * Chat Composer for the Chatter + * + * Extends the basic Composer Widget to add 'suggested partner' layer (open + * popup when suggested partner is selected without email, or other + * informations), and the button to open the full composer wizard. + */ +var ChatterComposer = BasicComposer.extend({ + template: 'mail.chatter.Composer', + events: _.extend({}, BasicComposer.prototype.events, { + 'click .o_composer_button_full_composer': '_onOpenFullComposer', + }), + init: function (parent, model, suggestedPartners, options) { + this._super(parent, options); + this._model = model; + this.suggestedPartners = suggestedPartners; + this.options = _.defaults(this.options, { + display_mode: 'textarea', + recordName: false, + isLog: false, + }); + if (this.options.isLog) { + this.options.sendText = _t("Log"); + } + this.notInline = true; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Chatter don't clear message on sent but after successful sent + * + * @override + */ + clearComposerOnSend: function () {}, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Check the additional partners (not necessary registered partners), and + * open a popup form view for the ones who informations is missing. + * + * @private + * @param {Array} checkedSuggestedPartners list of 'recipient' partners to + * complete informations or validate + * @returns {Deferred} resolved with the list of checked suggested partners + * (real partner) + **/ + _checkSuggestedPartners: function (checkedSuggestedPartners) { + var self = this; + var checkDone = $.Deferred(); + + var recipients = _.filter(checkedSuggestedPartners, function (recipient) { + return recipient.checked; + }); + var recipientsToFind = _.filter(recipients, function (recipient) { + return (! recipient.partner_id); + }); + var namesToFind = _.pluck(recipientsToFind, 'full_name'); + var recipientsToCheck = _.filter(recipients, function (recipient) { + return (recipient.partner_id && ! recipient.email_address); + }); + var recipientIDs = _.pluck(_.filter(recipients, function (recipient) { + return recipient.partner_id && recipient.email_address; + }), 'partner_id'); + + var namesToRemove = []; + var recipientIDsToRemove = []; + + // have unknown names + // -> call message_get_partner_info_from_emails to try to find + // partner_id + var def; + if (namesToFind.length > 0) { + def = this._rpc({ + model: this._model, + method: 'message_partner_info_from_emails', + args: [[this.context.default_res_id], namesToFind], + }); + } + + // for unknown names + incomplete partners + // -> open popup - cancel = remove from recipients + $.when(def).pipe(function (result) { + result = result || []; + var emailDefs = []; + var recipientPopups = result.concat(recipientsToCheck); + + _.each(recipientPopups, function (partnerInfo) { + var deferred = $.Deferred(); + emailDefs.push(deferred); + + var partnerName = partnerInfo.full_name; + var partnerID = partnerInfo.partner_id; + var parsedEmail = mailUtils.parseEmail(partnerName); + + var dialog = new viewDialogs.FormViewDialog(self, { + res_model: 'res.partner', + res_id: partnerID, + context: { + active_model: self._model, + active_id: self.context.default_res_id, + force_email: true, + ref: 'compound_context', + default_name: parsedEmail[0], + default_email: parsedEmail[1], + }, + title: _t("Please complete customer's informations"), + disable_multiple_selection: true, + }).open(); + dialog.on('closed', self, function () { + deferred.resolve(); + }); + dialog.opened().then(function () { + dialog.form_view.on('on_button_cancel', self, function () { + namesToRemove.push(partnerName); + if (partnerID) { + recipientIDsToRemove.push(partnerID); + } + }); + }); + }); + $.when.apply($, emailDefs).then(function () { + var newNamesToFind = _.difference(namesToFind, namesToRemove); + var def; + if (newNamesToFind.length > 0) { + def = self._rpc({ + model: self._model, + method: 'message_partner_info_from_emails', + args: [[self.context.default_res_id], newNamesToFind, true], + }); + } + $.when(def).pipe(function (result) { + result = result || []; + var recipientPopups = result.concat(recipientsToCheck); + _.each(recipientPopups, function (partnerInfo) { + if ( + partnerInfo.partner_id && + _.indexOf(partnerInfo.partner_id, recipientIDsToRemove) === -1 + ) { + recipientIDs.push(partnerInfo.partner_id); + } + }); + }).pipe(function () { + checkDone.resolve(recipientIDs); + }); + }); + }); + return checkDone; + }, + /** + * Get the list of selected suggested partners + * + * @private + * @returns {Array} list of 'recipient' selected partners (may not be + * created in db) + **/ + _getCheckedSuggestedPartners: function () { + var self = this; + var checkedPartners = []; + this.$('.o_composer_suggested_partners input:checked').each(function () { + var fullName = $(this).data('fullname'); + checkedPartners = checkedPartners.concat( + _.filter(self.suggestedPartners, function (item) { + return fullName === item.full_name; + }) + ); + }); + return checkedPartners; + }, + /** + * @override + * @private + * @returns {$.Deferred} + */ + _preprocessMessage: function () { + var self = this; + var def = $.Deferred(); + this._super().then(function (message) { + message = _.extend(message, { + subtype: 'mail.mt_comment', + message_type: 'comment', + context: self.context, + }); + + // Subtype + if (self.options.isLog) { + message.subtype = 'mail.mt_note'; + } + + // Partner_ids + if (!self.options.isLog) { + var checkedSuggestedPartners = self._getCheckedSuggestedPartners(); + self._checkSuggestedPartners(checkedSuggestedPartners).done(function (partnerIDs) { + message.partner_ids = (message.partner_ids || []).concat(partnerIDs); + // update context + message.context = _.defaults({}, message.context, { + mail_post_autofollow: true, + }); + def.resolve(message); + }); + } else { + def.resolve(message); + } + + }); + + return def; + }, + /** + * @override + * @private + */ + _shouldSend: function () { + return false; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onOpenFullComposer: function () { + if (!this._doCheckAttachmentUpload()){ + return false; + } + + var self = this; + var recipientDoneDef = $.Deferred(); + + // any operation on the full-composer will reload the record, so + // warn the user that any unsaved changes on the record will be lost. + this.trigger_up('discard_record_changes', { + proceed: function () { + if (self.options.isLog) { + recipientDoneDef.resolve([]); + } else { + var checkedSuggestedPartners = self._getCheckedSuggestedPartners(); + self._checkSuggestedPartners(checkedSuggestedPartners) + .then(recipientDoneDef.resolve.bind(recipientDoneDef)); + } + }, + }); + + recipientDoneDef.then(function (partnerIDs) { + var context = { + default_parent_id: self.id, + default_body: mailUtils.getTextToHTML(self.$input.val()), + default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'), + default_partner_ids: partnerIDs, + default_is_log: self.options.isLog, + mail_post_autofollow: true, + }; + + if (self.context.default_model && self.context.default_res_id) { + context.default_model = self.context.default_model; + context.default_res_id = self.context.default_res_id; + } + + var action = { + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + view_mode: 'form', + view_type: 'form', + views: [[false, 'form']], + target: 'new', + context: context, + }; + self.do_action(action, { + on_close: self.trigger.bind(self, 'need_refresh'), + }).then(self.trigger.bind(self, 'close_composer')); + }); + } +}); + +return ChatterComposer; + +}); diff --git a/addons/mail/static/src/js/composers/extended_composer.js b/addons/mail/static/src/js/composers/extended_composer.js new file mode 100644 index 00000000000..f6e8560da8f --- /dev/null +++ b/addons/mail/static/src/js/composers/extended_composer.js @@ -0,0 +1,100 @@ +odoo.define('mail.composer.Extended', function (require) { +"use strict"; + +var BasicComposer = require('mail.composer.Basic'); + +var ExtendedComposer = BasicComposer.extend({ + /** + * @override + */ + init: function (parent, options) { + options = _.defaults(options || {}, { + inputMinHeight: 120, + }); + this._super(parent, options); + this.extended = true; + }, + /** + * @override + */ + start: function () { + this._$subjectInput = this.$('.o_composer_subject input'); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + clearComposer: function () { + this._super.apply(this, arguments); + this._$subjectInput.val(''); + }, + /** + * @override + * @param {string} target + */ + focus: function (target) { + if (target === 'body') { + this.$input.focus(); + } else { + this._$subjectInput.focus(); + } + }, + /** + * @override + */ + getState: function () { + var state = this._super.apply(this, arguments); + state.subject = this._$subjectInput.val(); + return state; + }, + /** + * @override + * @param {Object} state + * @param {string} state.subject + */ + setState: function (state) { + this._super.apply(this, arguments); + this.setSubject(state.subject); + }, + /** + * @param {string} subject + */ + setSubject: function (subject) { + this.$('.o_composer_subject input').val(subject); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @returns {$.Deferred} + */ + _preprocessMessage: function () { + var self = this; + return this._super().then(function (message) { + var subject = self._$subjectInput.val(); + self._$subjectInput.val(""); + message.subject = subject; + return message; + }); + }, + /** + * @override + * @private + */ + _shouldSend: function () { + return false; + }, +}); + +return ExtendedComposer; + +}); diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js new file mode 100644 index 00000000000..fb8d9ddc065 --- /dev/null +++ b/addons/mail/static/src/js/composers/mention_manager.js @@ -0,0 +1,432 @@ +odoo.define('mail.composer.MentionManager', function (require) { +"use strict"; + +var core = require('web.core'); +var dom = require('web.dom'); +var session = require('web.session'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + +var NON_BREAKING_SPACE = '\u00a0'; + +// The MentionManager allows the Composer to register listeners. For each +// listener, it detects if the user is currently typing a mention (starting by a +// given delimiter). If so, if fetches mention suggestions and renders them. On +// suggestion clicked, it updates the selection for the corresponding listener. +var MentionManager = Widget.extend({ + className: 'dropup o_composer_mention_dropdown', + events: { + 'click .o_mention_proposition': '_onClickMentionItem', + 'mouseover .o_mention_proposition': '_onHoverMentionProposition', + }, + init: function (parent) { + this._super.apply(this, arguments); + + this._composer = parent; + + this._open = false; + this._listeners = []; + this.set('mention_suggestions', []); + this.on('change:mention_suggestions', this, this._renderSuggestions); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns true if the mention suggestions dropdown is open, false otherwise + * + * @returns {boolean} + */ + isOpen: function () { + return this._open; + }, + /** + * Returns the mentions of the given listener that haven't been erased from + * the composer's input + * + * @returns {Array} + */ + getListenerSelection: function (delimiter) { + var listener = _.findWhere(this._listeners, { delimiter: delimiter }); + if (listener) { + var inputMentions = this._composer + .$input + .val() + .match(new RegExp(delimiter+'[^ ]+(?= | )', 'g')); + return this._validateSelection(listener.selection, inputMentions); + } + return []; + }, + /** + * @returns {Object} containing listener selections, grouped by delimiter + * as key of the object. + */ + getListenerSelections: function () { + var selections = {}; + _.each(this._listeners, function (listener) { + selections[listener.delimiter] = listener.selection; + }); + return selections; + }, + /** + * Detects if the user is currently typing a mention word + */ + detectDelimiter: function () { + var self = this; + var textVal = this._composer.$input.val(); + var cursorPosition = this._getSelectionPositions().start; + var leftString = textVal.substring(0, cursorPosition); + + function validateKeyword(delimiter, beginningOnly) { + // use position before delimiter because there should be whitespaces + // or line feed/carriage return before the delimiter + var beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1; + if (beginningOnly && beforeDelimiterPosition > 0) { + return false; + } + var searchStr = textVal.substring(beforeDelimiterPosition, cursorPosition); + // regex string start with delimiter or whitespace then delimiter + var pattern = "^"+delimiter+"|^\\s"+delimiter; + var regexStart = new RegExp(pattern, 'g'); + // trim any left whitespaces or the left line feed/ carriage return + // at the beginning of the string + searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, ''); + if (regexStart.test(searchStr) && searchStr.length) { + searchStr = searchStr.replace(pattern, ''); + return searchStr.indexOf(' ') < 0 && !/[\r\n]/.test(searchStr) ? + searchStr.replace(delimiter, '') : + false; + } + return false; + } + + this._activeListener = undefined; + for (var i = 0; i < this._listeners.length; i++) { + var listener = this._listeners[i]; + this._mentionWord = validateKeyword(listener.delimiter, listener.beginningOnly); + + if (this._mentionWord !== false) { + this._activeListener = listener; + break; + } + } + + if (this._activeListener) { + var mentionWord = this._mentionWord; + $.when(this._activeListener.fetchCallback(mentionWord)) + .then(function (suggestions) { + if (mentionWord === self._mentionWord) { + // update suggestions only if mentionWord didn't change + // in the meantime + self.set('mention_suggestions', suggestions); + } + }); + } else { + this.set('mention_suggestions', []); // close the dropdown + } + }, + /** + * Replaces mentions appearing in the string 's' by html links with proper + * redirection + * + * @param {string} s + * @returns {string} + */ + generateLinks: function (s) { + var self = this; + var baseHREF = session.url('/web'); + var linkParams = "href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'"; + var mentionLink = "
" + + "%s%s" + + ""; + _.each(this._listeners, function (listener) { + if (!listener.generateLinks) { + return; + } + var selection = listener.selection; + if (selection.length) { + var matches = self._getMatch(s, listener); + var substrings = []; + var startIndex = 0; + for (var i = 0; i < matches.length; i++) { + var match = matches[i]; + var endIndex = match.index + match[0].length; + var selectionID = self._getSelectionID(match, selection); + // put back white spaces instead of non-breaking spaces in + // mention's name + var matchName = match[0].substring(1) + .replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' '); + var href = baseHREF + + _.str.sprintf("#model=%s&id=%s", listener.model, selectionID); + var processedText = _.str.sprintf(mentionLink, + href, + listener.redirectClassname, + selectionID, + listener.model, + listener.delimiter, + matchName); + var subtext = s.substring(startIndex, endIndex) + .replace(match[0], processedText); + substrings.push(subtext); + startIndex = endIndex; + } + substrings.push(s.substring(startIndex, s.length)); + s = substrings.join(''); + } + }); + return s; + }, + /** + * @param {integer} keycode + */ + propositionNavigation: function (keycode) { + var $active = this.$('.o_mention_proposition.active'); + if (keycode === $.ui.keyCode.ENTER) { + // selecting proposition + $active.click(); + } else { + // navigation in propositions + var $to; + if (keycode === $.ui.keyCode.DOWN) { + $to = $active.nextAll('.o_mention_proposition').first(); + } else if (keycode === $.ui.keyCode.UP) { + $to = $active.prevAll('.o_mention_proposition').first(); + } else if (keycode === $.ui.keyCode.TAB) { + $to = $active.nextAll('.o_mention_proposition').first(); + if (!$to.length) { + $to = $active.prevAll('.o_mention_proposition').last(); + } + } + if ($to && $to.length) { + $active.removeClass('active'); + $to.addClass('active'); + } + } + }, + /** + * Registers a new listener, described by an object containing the following + * keys + * + * @param {Object} listener + * @param {boolean} [listener.beginningOnly] true to enable autocomplete + * only at first position of input + * @param {char} [listener.delimiter] the mention delimiter + * @param {function} [listener.fetchCallback] the callback to fetch mention + * suggestions + * @param {boolean} [listener.generateLinks] true to wrap mentions in + * links + * @param {string} [listener.model=''] (optional) the model used for + * redirection + * @param {string} [listener.redirectClassname=''] (optional) the classname + * of the wrapping the mention + * @param {Array} [listener.selection=[]] (optional) initial mentions for + * each listener + * @param {string} [listener.suggestionTemplate] the template of + * suggestions' dropdown + */ + register: function (listener) { + this._listeners.push(_.defaults(listener, { + model: '', + redirectClassname: '', + selection: [], + })); + }, + resetSuggestions: function () { + this.set('mention_suggestions', []); + }, + resetSelections: function () { + _.each(this._listeners, function (listener) { + listener.selection = []; + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the matches (as RexExp.exec does) for the mention in the input + * text + * + * @private + * @param {string} inputText: the text to search matches + * @param {Object} listener: the listener for which we want to find a match + * @returns {Object[]} matches in the same format as RexExp.exec() + */ + _getMatch: function (inputText, listener) { + // create the regex of all mention's names + var names = _.pluck(listener.selection, 'name'); + var escapedNames = _.map(names, function (str) { + return "("+_.str.escapeRegExp(listener.delimiter+str)+")"; + }); + var regexStr = escapedNames.join('|'); + // extract matches + var result = []; + if (regexStr.length) { + var myRegexp = new RegExp(regexStr, 'g'); + var match = myRegexp.exec(inputText); + while (match !== null) { + result.push(match); + match = myRegexp.exec(inputText); + } + } + return result; + }, + /** + * @private + * @param {string[]} match + * @param {Object} selection + * @returns {Object} + */ + _getSelectionID: function (match, selection) { + return _.findWhere(selection, { name: match[0].slice(1) }).id; + }, + /** + * Get cursor position and selection + * + * @private + * @returns a current cursor position + */ + _getSelectionPositions: function () { + var InputElement = this._composer.$input.get(0); + return InputElement ? dom.getSelectionRange(InputElement) : { start: 0, end: 0 }; + }, + /** + * @private + */ + _renderSuggestions: function () { + var suggestions = []; + if (_.find(this.get('mention_suggestions'), _.isArray)) { + // Array of arrays -> Flatten and insert dividers between groups + var insertDivider = false; + _.each(this.get('mention_suggestions'), function (suggestionGroup) { + if (suggestionGroup.length > 0) { + if (insertDivider) { + suggestions.push({ divider: true }); + } + suggestions = suggestions.concat(suggestionGroup); + insertDivider = true; + } + }); + } else { + suggestions = this.get('mention_suggestions'); + } + if (suggestions.length) { + this.$el.html(QWeb.render(this._activeListener.suggestionTemplate, { + suggestions: suggestions, + })); + this.$el + .addClass('open') + .find('ul') + .css('max-width', this._composer.$input.width()) + .find('.o_mention_proposition') + .first() + .addClass('active'); + this._open = true; + } else { + this.$el.removeClass('open'); + this.$el.empty(); + this._open = false; + } + }, + /* + * Set cursor position + * + * @private + * @param {integer} pos + */ + _setCursorPosition: function (pos) { + this._composer.$input.each(function (index, elem) { + dom.setSelectionRange(elem, { start: pos, end: pos }); + }); + }, + /** + * @private + * @param {Object} selection + * @param {Array} inputMentions + * @returns {Array} + */ + _validateSelection: function (selection, inputMentions) { + var validatedSelection = []; + _.each(inputMentions, function (mention) { + var validatedMention = _.findWhere(selection, { name: mention.slice(1) }); + if (validatedMention) { + validatedSelection.push(validatedMention); + } + }); + return validatedSelection; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {KeyboardEvent} ev - to get a current selected mention from list + * and eventlistener. + */ + _onClickMentionItem: function (ev) { + ev.preventDefault(); + + var textInput = this._composer.$input.val(); + var id = $(ev.currentTarget).data('id'); + var suggestions = _.flatten(this.get('mention_suggestions')); + var selectedSuggestion = _.find(suggestions, function (s) { + return s.id === id; + }); + var substitution = selectedSuggestion.substitution; + if (!substitution) { + // no substitution string given, so use the mention name instead + // replace white spaces with non-breaking spaces to facilitate + // mentions detection in text + selectedSuggestion.name = selectedSuggestion.name.replace(/ /g, NON_BREAKING_SPACE); + substitution = this._activeListener.delimiter + selectedSuggestion.name; + } + var getMentionIndex = function (matches, cursorPosition) { + for (var i = 0; i < matches.length; i++) { + if (cursorPosition <= matches[i].index) { + return i; + } + } + return i; + }; + + // add the selected suggestion to the list + if (this._activeListener.selection.length) { + // get mention matches (ordered by index in the text) + var matches = this._getMatch(textInput, this._activeListener); + var index = getMentionIndex(matches, this._getSelectionPositions().start); + this._activeListener.selection.splice(index, 0, selectedSuggestion); + } else { + this._activeListener.selection.push(selectedSuggestion); + } + + // update input text, and reset dropdown + var cursorPosition = this._getSelectionPositions().start; + var textLeft = textInput.substring(0, cursorPosition-(this._mentionWord.length+1)); + var textRight = textInput.substring(cursorPosition, textInput.length); + var textInputNew = textLeft + substitution + ' ' + textRight; + this._composer.$input.val(textInputNew); + this._setCursorPosition(textLeft.length+substitution.length+2); + this.set('mention_suggestions', []); + this._composer.focus(); // to trigger autoresize + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onHoverMentionProposition: function (ev) { + var $elem = $(ev.currentTarget); + this.$('.o_mention_proposition').removeClass('active'); + $elem.addClass('active'); + }, + +}); + +return MentionManager; + +}); diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index def90b01595..ba0ad31a849 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -1,8 +1,9 @@ -odoo.define('mail.chat_discuss', function (require) { +odoo.define('mail.Discuss', function (require) { "use strict"; -var ChatThread = require('mail.ChatThread'); -var composer = require('mail.composer'); +var BasicComposer = require('mail.composer.Basic'); +var ExtendedComposer = require('mail.composer.Extended'); +var ThreadWidget = require('mail.widget.Thread'); var AbstractAction = require('web.AbstractAction'); var config = require('web.config'); @@ -26,24 +27,24 @@ var _t = core._t; * validated. */ var PartnerInviteDialog = Dialog.extend({ - dialog_title: _t('Invite people'), - template: "mail.PartnerInviteDialog", + dialog_title: _t("Invite people"), + template: 'mail.PartnerInviteDialog', /** * @override * @param {integer|string} channelID id of the channel, - * a string for static channels (e.g. 'channel_inbox'). + * a string for static channels (e.g. 'mailbox_inbox'). */ init: function (parent, title, channelID) { - this.channelID = channelID; + this._channelID = channelID; this._super(parent, { title: title, - size: "medium", + size: 'medium', buttons: [{ text: _t("Invite"), close: true, - classes: "btn-primary", + classes: 'btn-primary', click: this._addChannel.bind(this), }], }); @@ -53,17 +54,17 @@ var PartnerInviteDialog = Dialog.extend({ */ start: function () { var self = this; - this.$input = this.$('.o_mail_chat_partner_invite_input'); + this.$input = this.$('.o_mail_discuss_partner_invite_input'); this.$input.select2({ width: '100%', allowClear: true, multiple: true, formatResult: function (item) { - var status = QWeb.render('mail.chat.UserStatus', {status: item.im_status}); + var status = QWeb.render('mail.UserStatus', { status: item.im_status }); return $('').text(item.text).prepend(status); }, query: function (query) { - self.call('chat_manager', 'searchPartner', query.term, 20) + self.call('mail_service', 'searchPartner', query.term, 20) .then(function (partners) { query.callback({ results: _.map(partners, function (partner) { @@ -91,16 +92,20 @@ var PartnerInviteDialog = Dialog.extend({ return this._rpc({ model: 'mail.channel', method: 'channel_invite', - args: [this.channelID], + args: [this._channelID], kwargs: {partner_ids: _.pluck(data, 'id')}, }).then(function () { var names = _.escape(_.pluck(data, 'text').join(', ')); - var notification = _.str.sprintf(_t('You added %s to the conversation.'), names); - self.do_notify(_t('New people'), notification); - // Clear the membersDeferred to fetch again the partner - // when getMentionPartnerSuggestions from the chatManager is triggered - var channel = self.call('chat_manager', 'getChannel', self.channelID); - delete channel.membersDeferred; + var notification = + _.str.sprintf( + _t("You added %s to the conversation."), + names + ); + self.do_notify(_t("New people"), notification); + // Update list of members with the invited user, so that + // we can mention this user in this channel right away. + var channel = self.call('mail_service', 'getChannel', self._channelID); + channel.forceFetchMembers(); }); } }, @@ -113,7 +118,7 @@ var PartnerInviteDialog = Dialog.extend({ * This let the moderator provide a reason for rejecting the messages. */ var ModeratorRejectMessageDialog = Dialog.extend({ - template: "mail.ModeratorRejectMessageDialog", + template: 'mail.ModeratorRejectMessageDialog', /** * @override @@ -135,12 +140,12 @@ var ModeratorRejectMessageDialog = Dialog.extend({ this._messageIDs = params.messageIDs; this._proceedReject = params.proceedReject; this._super(parent, { - title: _t('Send explanation to author'), - size: "medium", + title: _t("Send explanation to author"), + size: 'medium', buttons: [{ text: _t("Send"), close: true, - classes: "btn-primary", + classes: 'btn-primary', click: _.bind(this._onSendClicked, this), }], }); @@ -175,14 +180,14 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { update_moderation_buttons: '_onUpdateModerationButtons', }, events: { - 'blur .o_mail_add_channel input': '_onAddChannelBlur', + 'blur .o_mail_add_thread input': '_onAddThreadBlur', 'click .o_mail_annoying_notification_bar .fa-close': '_onCloseNotificationBar', - 'click .o_mail_chat_channel_item': '_onChannelClicked', + 'click .o_mail_discuss_item': '_onDiscussItemClicked', 'click .o_mail_open_channels': '_onPublicChannelsClick', 'click .o_mail_partner_unpin': '_onUnpinChannel', 'click .o_mail_channel_settings': '_onChannelSettingsClicked', 'click .o_mail_request_permission': '_onRequestNotificationPermission', - 'click .o_mail_sidebar_title .o_add': '_onAddChannel', + 'click .o_mail_sidebar_title .o_add': '_onAddThread', 'keydown': '_onKeydown', }, @@ -191,71 +196,92 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ init: function (parent, action, options) { this._super.apply(this, arguments); + + this.action = action; this.action_manager = parent; this.dataset = new data.DataSetSearch(this, 'mail.message'); + this.displayNotificationBar = ( + window.Notification && + window.Notification.permission === 'default' + ); this.domain = []; - this.action = action; this.options = options || {}; - this.channelsScrolltop = {}; - this.throttledUpdateChannels = _.throttle(this._updateChannels.bind(this), 100, { leading: false }); - this.notification_bar = (window.Notification && window.Notification.permission === "default"); - this.selected_message = null; - this.composerStates = {}; - this.defaultChannelID = this.options.active_id || - this.action.context.active_id || - this.action.params.default_active_id || - 'channel_inbox'; + + this._threadsScrolltop = {}; + this._composerStates = {}; + this._defaultThreadID = this.options.active_id || + this.action.context.active_id || + this.action.params.default_active_id || + 'mailbox_inbox'; + this._selectedMessage = null; + this._throttledUpdateThreads = _.throttle( + this._updateThreads.bind(this), 100, { leading: false }); }, /** * @override */ willStart: function () { var self = this; - var viewID = this.action && this.action.search_view_id && this.action.search_view_id[0]; + var viewID = this.action && + this.action.search_view_id && + this.action.search_view_id[0]; var def = this .loadFieldView(this.dataset, viewID, 'search') - .then(function (fields_view) { - self.fields_view = fields_view; + .then(function (fieldsView) { + self.fields_view = fieldsView; }); - return $.when(this._super(), this.call('chat_manager', 'isReady'), def); + return $.when(this._super(), this.call('mail_service', 'isReady'), def); }, /** * @override */ start: function () { var self = this; - var defaultChannel = this.call('chat_manager', 'getChannel', this.defaultChannelID) || - this.call('chat_manager', 'getChannel', 'channel_inbox'); - this.basicComposer = new composer.BasicComposer(this, {mention_partners_restricted: true}); - this.extendedComposer = new composer.ExtendedComposer(this, {mention_partners_restricted: true}); - this.basicComposer.on('post_message', this, this._onPostMessage); - this.basicComposer.on('input_focused', this, this._onComposerFocused); - this.extendedComposer.on('post_message', this, this._onPostMessage); - this.extendedComposer.on('input_focused', this, this._onComposerFocused); + this._basicComposer = new BasicComposer(this, + { mentionPartnersRestricted: true } + ); + this._extendedComposer = new ExtendedComposer(this, + { mentionPartnersRestricted: true } + ); + this._basicComposer + .on('post_message', this, this._onPostMessage) + .on('input_focused', this, this._onComposerFocused); + this._extendedComposer + .on('post_message', this, this._onPostMessage) + .on('input_focused', this, this._onComposerFocused); this._renderButtons(); var defs = []; - defs.push(this._renderThread()); - defs.push(this.basicComposer.appendTo(this.$('.o_mail_chat_content'))); - defs.push(this.extendedComposer.appendTo(this.$('.o_mail_chat_content'))); + defs.push( + this._renderThread() + ); + defs.push( + this._basicComposer.appendTo(this.$('.o_mail_discuss_content'))); + defs.push( + this._extendedComposer.appendTo(this.$('.o_mail_discuss_content'))); defs.push(this._renderSearchView()); return this.alive($.when.apply($, defs)) .then(function () { - return self.alive(self._setChannel(defaultChannel)); + return self.alive(self._setThread(self._defaultThreadID)); }) .then(function () { - self._updateChannels(); + self._updateThreads(); self._startListening(); - self.thread.$el.on("scroll", null, _.debounce(function () { - if (self.thread.get_scrolltop() < 20 && - !self.thread.$('.o_mail_no_content').length && - !self.call('chat_manager', 'isAllHistoryLoaded', self.channel, self.domain)) { + self._threadWidget.$el.on('scroll', null, _.debounce(function () { + if ( + self._threadWidget.getScrolltop() < 20 && + !self._threadWidget.$('.o_mail_no_content').length && + !self._thread.isAllHistoryLoaded(self.domain) + ) { self._loadMoreMessages(); } - if (self.thread.is_at_bottom()) { - self.call('chat_manager', 'markChannelAsSeen', self.channel); + if ( + self._threadWidget.isAtBottom() && + self._thread.getType() !== 'mailbox' + ) { + self._thread.markAsRead(); } }, 100)); }); @@ -268,7 +294,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { this._updateControlPanel(); this.action_manager.do_push_state({ action: this.action.id, - active_id: this.channel.id, + active_id: this._thread.getID(), }); }, /** @@ -284,9 +310,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @override */ on_attach_callback: function () { - this.call('chat_manager', 'getChatBus').trigger('discuss_open', true); - if (this.channel) { - this.thread.scroll_to({offset: this.channelsScrolltop[this.channel.id]}); + this.call('mail_service', 'getMailBus').trigger('discuss_open', true); + if (this._thread) { + this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]); } this._loadEnoughMessages(); }, @@ -294,8 +320,8 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @override */ on_detach_callback: function () { - this.call('chat_manager', 'getChatBus').trigger('discuss_open', false); - this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop(); + this.call('mail_service', 'getMailBus').trigger('discuss_open', false); + this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop(); }, //-------------------------------------------------------------------------- @@ -312,9 +338,12 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _banAuthorsFromMessageIDs: function (messageIDs) { var self = this; var emailList = _.map(messageIDs, function (messageID) { - return self.call('chat_manager', 'getMessage', messageID).email_from; + return self.call('mail_service', 'getMessage', messageID).getEmailFrom(); }).join(", "); - var text = _.str.sprintf(_t("You are going to ban: %s. Do you confirm the action?"), emailList); + var text = _.str.sprintf( + _t("You are going to ban: %s. Do you confirm the action?"), + emailList + ); var options = { confirm_callback: function () { self._moderateMessages(messageIDs, 'ban'); @@ -334,7 +363,10 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { var num = messageIDs.length; var text; if (num > 1) { - text = _.str.sprintf(_t("You are going to discard %s messages. Do you confirm the action?"), num); + text = _.str.sprintf( + _t("You are going to discard %s messages. Do you confirm the action?"), + num + ); } else if (num === 1) { text = _t("You are going to discard 1 message. Do you confirm the action?"); } @@ -351,42 +383,51 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _fetchAndRenderThread: function () { var self = this; - return this.call('chat_manager', 'getMessages', { - channelID: this.channel.id, - domain: this.domain - }).then(function (messages) { - self.thread.render(messages, self._getThreadRenderingOptions(messages)); + return this._thread.getMessages(this.domain) + .then(function (messages) { + self._threadWidget.render( + messages, + self._getThreadRenderingOptions(messages) + ); self._updateButtonStatus(messages.length === 0); return self._loadEnoughMessages(); }); }, /** * @private - * @param {Object} messages + * @param {mail.model.Message} messages * @returns {Object} */ _getThreadRenderingOptions: function (messages) { - // Compute position of the 'New messages' separator, only once when joining - // a channel to keep it in the thread when new messages arrive - if (_.isUndefined(this.messages_separator_position)) { - if (!this.unread_counter) { - this.messages_separator_position = false; // no unread message -> don't display separator + // Compute position of the 'New messages' separator, only once when + // joining a channel to keep it in the thread when new messages arrive + if (_.isUndefined(this.messagesSeparatorPosition)) { + if (!this._unreadCounter) { + // no unread message -> don't display separator + this.messagesSeparatorPosition = false; } else { - var msg = this.call('chat_manager', 'getLastSeenMessage', this.channel); - this.messages_separator_position = msg ? msg.id : 'top'; + var messageID = this._thread.getLastSeenMessageID(); + this.messagesSeparatorPosition = messageID || 'top'; } } return { - channel_id: this.channel.id, - display_load_more: !this.call('chat_manager', 'isAllHistoryLoaded', this.channel, this.domain), - display_needactions: this.channel.display_needactions, - messages_separator_position: this.messages_separator_position, - squash_close_messages: this.channel.type !== 'static' && !this.channel.mass_mailing, - display_empty_channel: !messages.length && !this.domain.length, - display_no_match: !messages.length && this.domain.length, - display_subject: this.channel.mass_mailing || this.channel.id === "channel_inbox" || this.channel.id === "channel_moderation", - display_email_icon: false, - display_reply_icon: true, + threadID: this._thread.getID(), + displayLoadMore: !this._thread.isAllHistoryLoaded(this.domain), + displayMarkAsRead: this._thread.getID() === 'mailbox_inbox', + messagesSeparatorPosition: this.messagesSeparatorPosition, + squashCloseMessages: this._thread.getType() !== 'mailbox' && + !this._thread.isMassMailing(), + displayEmptyChannel: !messages.length && !this.domain.length, + displayNoMatchFound: !messages.length && this.domain.length, + displaySubjectOnMessages: + ( + this._thread.getType() !== 'mailbox' && + this._thread.isMassMailing() + ) || + this._thread.getID() === 'mailbox_inbox' || + this._thread.getID() === 'mailbox_moderation', + displayEmailIcons: false, + displayReplyIcons: true, }; }, /** @@ -423,11 +464,13 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * screen, or when there is no more message to fetch */ _loadEnoughMessages: function () { - var loadMoreMessages = this.thread.el.clientHeight && - this.thread.el.clientHeight === this.thread.el.scrollHeight && - !this.call('chat_manager', 'isAllHistoryLoaded', this.channel, this.domain); + var loadMoreMessages = + this._threadWidget.el.clientHeight && + (this._threadWidget.el.clientHeight === this._threadWidget.el.scrollHeight) && + !this._thread.isAllHistoryLoaded(this.domain); if (loadMoreMessages) { - return this._loadMoreMessages().then(this._loadEnoughMessages.bind(this)); + return this._loadMoreMessages() + .then(this._loadEnoughMessages.bind(this)); } }, /** @@ -438,21 +481,22 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _loadMoreMessages: function () { var self = this; - var oldestMsgID = this.$('.o_thread_message').first().data('messageId'); - var oldestMsgSelector = '.o_thread_message[data-message-id="' + oldestMsgID + '"]'; - var offset = -dom.getPosition(document.querySelector(oldestMsgSelector)).top; - return this.call('chat_manager', 'getMessages', { - channelID: this.channel.id, - domain: this.domain, - loadMore: true - }) + var oldestMessageID = this.$('.o_thread_message').first().data('messageId'); + var oldestMessageSelector = '.o_thread_message[data-message-id="' + oldestMessageID + '"]'; + var offset = -dom.getPosition(document.querySelector(oldestMessageSelector)).top; + return this._thread.getMessages(this.domain, true) .then(function (messages) { - if (self.messages_separator_position === 'top') { - self.messages_separator_position = undefined; // reset value to re-compute separator position + if (self.messagesSeparatorPosition === 'top') { + // reset value to re-compute separator position + self.messagesSeparatorPosition = undefined; } - self.thread.render(messages, self._getThreadRenderingOptions(messages)); - offset += dom.getPosition(document.querySelector(oldestMsgSelector)).top; - self.thread.scroll_to({offset: offset}); + self._threadWidget.render( + messages, + self._getThreadRenderingOptions(messages) + ); + offset += dom.getPosition(document.querySelector(oldestMessageSelector) + ).top; + self._threadWidget.scrollToPosition(offset); }); }, /** @@ -484,68 +528,71 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }, /** * Binds handlers on the given $input to make them autocomplete and/or - * create channels. + * create threads. * * @private * @param {JQuery} $input the input to prepare - * @param {string} type the type of channel to create ('dm', 'public' or - * 'private') + * @param {string} type the type of thread to create ('dm', 'public' or + * 'private' channel) */ - _prepareAddChannelInput: function ($input, type) { + _prepareAddThreadInput: function ($input, type) { var self = this; if (type === 'public') { $input.autocomplete({ source: function (request, response) { - self.last_search_val = _.escape(request.term); - self._searchChannel(self.last_search_val).done(function (result){ + self._lastSearchVal = _.escape(request.term); + self._searchChannel(self._lastSearchVal).done(function (result){ result.push({ - 'label': _.str.sprintf(''+_t("Create %s")+'', '"#'+self.last_search_val+'"'), + 'label': _.str.sprintf( + '' + _t("Create %s") + '', + '"#' + self._lastSearchVal + '"' + ), 'value': '_create', }); response(result); }); }, - select: function (event, ui) { - if (self.last_search_val) { + select: function (ev, ui) { + if (self._lastSearchVal) { if (ui.item.value === '_create') { - self.call('chat_manager', 'createChannel', self.last_search_val, "public"); + self.call('mail_service', 'createChannel', self._lastSearchVal, 'public'); } else { - self.call('chat_manager', 'joinChannel', ui.item.id); + self.call( 'mail_service', 'joinChannel', ui.item.id); } } }, - focus: function (event) { - event.preventDefault(); + focus: function (ev) { + ev.preventDefault(); }, html: true, }); } else if (type === 'private') { - $input.on('keyup', this, function (event) { - var name = _.escape($(event.target).val()); - if (event.which === $.ui.keyCode.ENTER && name) { - self.call('chat_manager', 'createChannel', name, "private"); + $input.on('keyup', this, function (ev) { + var name = _.escape($(ev.target).val()); + if (ev.which === $.ui.keyCode.ENTER && name) { + self.call('mail_service', 'createChannel', name, 'private'); } }); } else if (type === 'dm') { $input.autocomplete({ source: function (request, response) { - self.last_search_val = _.escape(request.term); - self.call('chat_manager', 'searchPartner', self.last_search_val, 10).done(response); + self._lastSearchVal = _.escape(request.term); + self.call('mail_service', 'searchPartner', self._lastSearchVal, 10).done(response); }, - select: function (event, ui) { - var partner_id = ui.item.id; - var dm = self.call('chat_manager', 'getDmFromPartnerID', partner_id); + select: function (ev, ui) { + var partnerID = ui.item.id; + var dm = self.call('mail_service', 'getDmFromPartnerID', partnerID); if (dm) { - self._setChannel(dm); + self._setThread(dm.getID()); } else { - self.call('chat_manager', 'createChannel', partner_id, "dm"); + self.call('mail_service', 'createChannel', partnerID, 'dm'); } // clear the input $(this).val(''); return false; }, - focus: function (event) { - event.preventDefault(); + focus: function (ev) { + ev.preventDefault(); }, }); } @@ -572,14 +619,15 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @private */ _renderButtons: function () { - this.$buttons = $(QWeb.render("mail.chat.ControlButtons", {debug: session.debug})); - this.$buttons.find('button').css({display:"inline-block"}); - this.$buttons.on('click', '.o_mail_chat_button_invite', this._onInviteButtonClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_mark_read', this._onMarkAllReadClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_unstar_all', this._onUnstarAllClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_moderate_all', this._onModerateAllClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_select_all', this._onSelectAllClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_unselect_all', this._onUnselectAllClicked.bind(this)); + this.$buttons = $(QWeb.render('mail.discuss.ControlButtons', { debug: session.debug })); + this.$buttons.find('button').css({display:'inline-block'}); + this.$buttons + .on('click', '.o_mail_discuss_button_invite', this._onInviteButtonClicked.bind(this)) + .on('click', '.o_mail_discuss_button_mark_all_read', this._onMarkAllAsReadClicked.bind(this)) + .on('click', '.o_mail_discuss_button_unstar_all', this._onUnstarAllClicked.bind(this)) + .on('click', '.o_mail_discuss_button_moderate_all', this._onModerateAllClicked.bind(this)) + .on('click', '.o_mail_discuss_button_select_all', this._onSelectAllClicked.bind(this)) + .on('click', '.o_mail_discuss_button_unselect_all', this._onUnselectAllClicked.bind(this)); }, /** * @private @@ -588,25 +636,40 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _renderSearchView: function () { var self = this; var options = { - $buttons: $("
"), + $buttons: $('
'), action: this.action, disable_groupby: true, }; this.searchview = new SearchView(this, this.dataset, this.fields_view, options); - return this.alive(this.searchview.appendTo($("
"))).then(function () { - self.$searchview_buttons = self.searchview.$buttons.contents(); - // manually call do_search to generate the initial domain and filter - // the messages in the default channel - self.searchview.do_search(); - }); + return this.alive(this.searchview.appendTo($('
'))) + .then(function () { + self.$searchview_buttons = self.searchview.$buttons.contents(); + // manually call do_search to generate the initial domain and filter + // the messages in the default thread + self.searchview.do_search(); + }); }, /** + * Render the sidebar of discuss app + * * @private * @param {Object} options - * @returns {JQuery} + * @param {mail.model.Thread[]} [options.threads=[]] + * @returns {jQueryElement} */ _renderSidebar: function (options) { - return $(QWeb.render("mail.chat.Sidebar", options)); + var inbox = this.call('mail_service', 'getMailbox', 'inbox'); + var starred = this.call('mail_service', 'getMailbox', 'starred'); + var moderation = this.call('mail_service', 'getMailbox', 'moderation'); + var $sidebar = $(QWeb.render('mail.discuss.Sidebar', { + activeThreadID: this._thread ? this._thread.getID() : undefined, + threads: options.threads, + needactionCounter: inbox.getMailboxCounter(), + starredCounter: starred.getMailboxCounter(), + moderationCounter: moderation ? moderation.getMailboxCounter() : 0, + isModerator: this.call('mail_service', 'isModerator'), + })); + return $sidebar; }, /** * @private @@ -620,7 +683,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { } timeout = timeout || 20000; this.$snackbar = $(QWeb.render(template, context)); - this.$('.o_mail_chat_content').append(this.$snackbar); + this.$('.o_mail_discuss_content').append(this.$snackbar); // Hide snackbar after [timeout] milliseconds (by default, 20s) var $snackbar = this.$snackbar; setTimeout(function () { $snackbar.fadeOut(); }, timeout); @@ -632,52 +695,57 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @returns {Deferred} */ _renderThread: function () { - this.thread = new ChatThread(this, {display_help: true, loadMoreOnScroll: true}); + this._threadWidget = new ThreadWidget(this, { + displayHelp: true, + loadMoreOnScroll: true + }); - this.thread.on('redirect', this, function (resModel, resID) { - this.call('chat_manager', 'redirect', resModel, resID, this._setChannel.bind(this)); - }); - this.thread.on('redirect_to_channel', this, function (channelID) { - this.call('chat_manager', 'joinChannel', channelID).then(this._setChannel.bind(this)); - }); - this.thread.on('load_more_messages', this, this._loadMoreMessages); - this.thread.on('mark_as_read', this, function (messageID) { - this.call('chat_manager', 'markAsRead', [messageID]); - }); - this.thread.on('toggle_star_status', this, function (messageID) { - this.call('chat_manager', 'toggleStarStatus', messageID); - }); - this.thread.on('select_message', this, this._selectMessage); - this.thread.on('unselect_message', this, this._unselectMessage); - return this.thread.appendTo(this.$('.o_mail_chat_content')); + this._threadWidget + .on('redirect', this, function (resModel, resID) { + this.call('mail_service', 'redirect', resModel, resID, this._setThread.bind(this)); + }) + .on('redirect_to_channel', this, function (channelID) { + this.call('mail_service', 'joinChannel', channelID).then(this._setThread.bind(this)); + }) + .on('load_more_messages', this, this._loadMoreMessages) + .on('mark_as_read', this, function (messageID) { + this.call('mail_service', 'markMessagesAsRead', [messageID]); + }) + .on('toggle_star_status', this, function (messageID) { + var message = this.call('mail_service', 'getMessage', messageID); + message.toggleStarStatus(); + }) + .on('select_message', this, this._selectMessage) + .on('unselect_message', this, this._unselectMessage); + + return this._threadWidget.appendTo(this.$('.o_mail_discuss_content')); }, /** - * Restores the scroll position and composer state of the current channel + * @private + * @param {mail.model.Channel} channel + */ + _restoreComposerState: function (channel) { + var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer; + var composerState = this._composerStates[channel.getUUID()]; + if (composerState) { + composer.setState(composerState); + } + }, + /** + * Restores the scroll position and composer state of the current thread * * @private */ - _restoreChannelState: function () { + _restoreThreadState: function () { var $newMessagesSeparator = this.$('.o_thread_new_messages_separator'); if ($newMessagesSeparator.length) { - this.thread.$el.scrollTo($newMessagesSeparator); + this._threadWidget.$el.scrollTo($newMessagesSeparator); } else { - var newChannelScrolltop = this.channelsScrolltop[this.channel.id]; - this.thread.scroll_to({offset: newChannelScrolltop}); + var newThreadScrolltop = this._threadsScrolltop[this._thread.getID()]; + this._threadWidget.scrollToPosition(newThreadScrolltop); } - this._restoreComposerState(this.channel); - }, - /** - * @private - * @param {Object} channel - */ - _restoreComposerState: function (channel) { - if (channel.type === 'static') { - return; // no composer in static channels - } - var composer = channel.mass_mailing ? this.extendedComposer : this.basicComposer; - var composerState = this.composerStates[channel.uuid]; - if (composerState) { - composer.setState(composerState); + if (this._thread.getType() !== 'mailbox') { + this._restoreComposerState(this._thread); } }, /** @@ -694,10 +762,10 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { .then(function (result){ var values = []; _.each(result, function (channel){ - var escaped_name = _.escape(channel.name); + var escapedName = _.escape(channel.name); values.push(_.extend(channel, { - 'value': escaped_name, - 'label': escaped_name, + 'value': escapedName, + 'label': escapedName, })); }); return values; @@ -709,145 +777,134 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _selectMessage: function (messageID) { this.$el.addClass('o_mail_selection_mode'); - var message = this.call('chat_manager', 'getMessage', messageID);; - this.selected_message = message; - var subject = "Re: " + message.record_name; - this.extendedComposer.set_subject(subject); + var message = this.call('mail_service', 'getMessage', messageID); + this._selectedMessage = message; + var subject = "Re: " + message.getDocumentName(); + this._extendedComposer.setSubject(subject); - if (this.channel.type !== 'static') { - this.basicComposer.do_hide(); + if (this._thread.getType() !== 'mailbox') { + this._basicComposer.do_hide(); } - this.extendedComposer.do_show(); + this._extendedComposer.do_show(); - this.thread.scroll_to({id: messageID, duration: 200, only_if_necessary: true}); - this.extendedComposer.focus('body'); + this._threadWidget.scrollToMessage({ + msgID: messageID, + duration: 200, + onlyIfNecessary: true + }); + this._extendedComposer.focus('body'); }, /** + * Set the selected thread with ID `threadID`. + * If there is no thread with such ID, fallback on mailbox 'inbox'. + * * @private - * @param {Object} channel + * @param {integer} threadID a thread with such ID * @returns {$.Promise} */ - _setChannel: function (channel) { + _setThread: function (threadID) { var self = this; - // Store scroll position and composer state of the previous channel - this._storeChannelState(); + // Store scroll position and composer state of the previous thread + this._storeThreadState(); - this.channel = channel; - this.messages_separator_position = undefined; // reset value on channel change - this.unread_counter = this.channel.unread_counter; - this.last_seen_message_id = this.channel.last_seen_message_id; + this._thread = this.call('mail_service', 'getThread', threadID) || + this.call('mail_service', 'getMailbox', 'inbox'); + + // reset value on channel change + this.messagesSeparatorPosition = undefined; + this._unreadCounter = this._thread.getUnreadCounter(); if (this.$snackbar) { this.$snackbar.remove(); } - this.action.context.active_id = channel.id; - this.action.context.active_ids = [channel.id]; + this.action.context.active_id = this._thread.getID(); + this.action.context.active_ids = [this._thread.getID()]; return this._fetchAndRenderThread().then(function () { - // Mark channel's messages as read and clear needactions - if (channel.type !== 'static') { - self.call('chat_manager', 'markChannelAsSeen', channel); + // Mark thread's messages as read and clear needactions + if (self._thread.getType() !== 'mailbox') { + self._thread.markAsRead(); } - // Restore scroll position and composer of the new current channel - self._restoreChannelState(); + // Restore scroll position and composer of the new current thread + self._restoreThreadState(); - // Update control panel before focusing the composer, otherwise focus is on the searchview - self.set("title", '#' + channel.name); + // Update control panel before focusing the composer, otherwise + // focus is on the searchview + self.set("title", '#' + self._thread.getName()); self._updateControlPanel(); - self._updateControlPanelButtons(channel); + self._updateControlPanelButtons(self._thread); - // Display and focus the adequate composer, and unselect possibly selected message - // to prevent sending messages as reply to that message + // Display and focus the adequate composer, and unselect possibly + // selected message to prevent sending messages as reply to that + // message self._unselectMessage(); self.action_manager.do_push_state({ action: self.action.id, - active_id: self.channel.id, + active_id: self._thread.getID(), }); }); }, /** - * Binds handlers on chatManager events + * Binds handlers on mail bus events * * @private */ _startListening: function () { - var chatBus = this.call('chat_manager', 'getChatBus'); - chatBus.on('open_channel', this, this._setChannel); - chatBus.on('new_message', this, this._onNewMessage); - chatBus.on('update_message', this, this._onMessageUpdated); - chatBus.on('new_channel', this, this._onNewChannel); - chatBus.on('anyone_listening', this, function (channel, query) { - query.is_displayed = query.is_displayed || - (channel.id === this.channel.id && this.thread.is_at_bottom()); - }); - chatBus.on('unsubscribe_from_channel', this, this._onChannelLeft); - chatBus.on('update_needaction', this, this.throttledUpdateChannels); - chatBus.on('update_starred', this, this.throttledUpdateChannels); - chatBus.on('update_channel_unread_counter', this, this.throttledUpdateChannels); - chatBus.on('update_dm_presence', this, this.throttledUpdateChannels); - chatBus.on('activity_updated', this, this.throttledUpdateChannels); - chatBus.on('update_moderation_counter', this, this.throttledUpdateChannels); + this.call('mail_service', 'getMailBus') + .on('open_thread_in_discuss', this, this._onOpenThreadInDiscuss) + .on('new_message', this, this._onNewMessage) + .on('update_message', this, this._onMessageUpdated) + .on('new_channel', this, this._onNewChannel) + .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) + .on('unsubscribe_from_channel', this, this._onChannelLeft) + .on('update_needaction', this, this._throttledUpdateThreads) + .on('update_starred', this, this._throttledUpdateThreads) + .on('update_thread_unread_counter', this, this._throttledUpdateThreads) + .on('update_dm_presence', this, this._throttledUpdateThreads) + .on('activity_updated', this, this._throttledUpdateThreads) + .on('update_moderation_counter', this, this._throttledUpdateThreads); }, /** - * Stores the scroll position and composer state of the current channel + * @private + * @param {mail.model.Channel} channel + */ + _storeComposerState: function (channel) { + var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer; + this._composerStates[channel.getUUID()] = composer.getState(); + composer.clearComposer(); + }, + /** + * Stores the scroll position of the current thread. + * For channels, also stores composer state. * * @private */ - _storeChannelState: function () { - if (this.channel) { - this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop(); - this._storeComposerState(this.channel); + _storeThreadState: function () { + if (this._thread) { + this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop(); + if (this._thread.getType() !== 'mailbox') { + this._storeComposerState(this._thread); + } } }, - /** - * @private - * @param {Object} channel - */ - _storeComposerState: function (channel) { - if (channel.type === 'static') { - return; // no composer in static channels - } - var composer = channel.mass_mailing ? this.extendedComposer : this.basicComposer; - this.composerStates[channel.uuid] = composer.getState(); - composer.clear_composer(); - }, /** * @private */ _unselectMessage: function () { - this.basicComposer.do_toggle(this.channel.type !== 'static' && !this.channel.mass_mailing); - this.extendedComposer.do_toggle(this.channel.type !== 'static' && !!this.channel.mass_mailing); + this._basicComposer.do_toggle(this._thread.getType() !== 'mailbox' && !this._thread.isMassMailing()); + this._extendedComposer.do_toggle(this._thread.getType() !== 'mailbox' && !!this._thread.isMassMailing()); if (!config.device.touch) { - var composer = this.channel.mass_mailing ? this.extendedComposer : this.basicComposer; + var composer = this._thread.getType() !== 'mailbox' && this._thread.isMassMailing() ? + this._extendedComposer : + this._basicComposer; composer.focus(); } this.$el.removeClass('o_mail_selection_mode'); - this.thread.unselect(); - this.selected_message = null; - }, - /** - * Renders the mainside bar with current channels - * - * @private - */ - _updateChannels: function () { - var self = this; - var $sidebar = this._renderSidebar({ - active_channel_id: this.channel ? this.channel.id: undefined, - channels: this.call('chat_manager', 'getChannels'), - needaction_counter: this.call('chat_manager', 'getNeedactionCounter'), - starred_counter: this.call('chat_manager', 'getStarredCounter'), - moderationCounter: this.call('chat_manager', 'getModerationCounter'), - isModerator: this.call('chat_manager', 'isModerator'), - }); - this.$(".o_mail_chat_sidebar").html($sidebar.contents()); - _.each(['dm', 'public', 'private'], function (type) { - var $input = self.$('.o_mail_add_channel[data-type=' + type + '] input'); - self._prepareAddChannelInput($input, type); - }); + this._threadWidget.unselectMessage(); + this._selectedMessage = null; }, /** * @private @@ -855,25 +912,31 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @param {string} type */ _updateButtonStatus: function (disabled, type) { - if (this.channel.id === "channel_inbox") { + if (this._thread.getID() === 'mailbox_inbox') { this.$buttons - .find('.o_mail_chat_button_mark_read') + .find('.o_mail_discuss_button_mark_all_read') .toggleClass('disabled', disabled); // Display Rainbowman when all inbox messages are read through // 'MARK ALL READ' or marking last inbox message as read if (disabled && type === 'mark_as_read') { this.trigger_up('show_effect', { - message: _t('Congratulations, your inbox is empty!'), + message: _t("Congratulations, your inbox is empty!"), type: 'rainbow_man', }); } } - if (this.channel.id === "channel_starred") { + if (this._thread.getID() === 'mailbox_starred') { this.$buttons - .find('.o_mail_chat_button_unstar_all') + .find('.o_mail_discuss_button_unstar_all') .toggleClass('disabled', disabled); } - if ((this.channel.isModerated && this.channel.isModerator) || this.channel.id === "channel_moderation") { + if ( + this._thread.getID() === 'mailbox_moderation' || + ( + this._thread.isModerated() && + this._thread.isModerator() + ) + ) { this._updateModerationButtons(); } }, @@ -891,36 +954,37 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }); }, /** - * Updates the control panel buttons visibility based on channel type + * Updates the control panel buttons visibility based on thread type * * @private - * @param {Object} channel + * @param {mail.model.Thread} thread */ - _updateControlPanelButtons: function (channel) { + _updateControlPanelButtons: function (thread) { // Hide 'unsubscribe' button in state channels and DM and channels with group-based subscription this.$buttons - .find('.o_mail_chat_button_invite, .o_mail_chat_button_settings') - .toggle(channel.type !== "dm" && channel.type !== 'static'); + .find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings') + .toggle(thread.getType() !== 'dm' && thread.getType() !== 'mailbox'); this.$buttons - .find('.o_mail_chat_button_mark_read') - .toggle(channel.id === "channel_inbox") - .removeClass("o_hidden"); + .find('.o_mail_discuss_button_mark_all_read') + .toggle(thread.getID() === 'mailbox_inbox') + .removeClass('o_hidden'); this.$buttons - .find('.o_mail_chat_button_unstar_all') - .toggle(channel.id === "channel_starred") - .removeClass("o_hidden"); + .find('.o_mail_discuss_button_unstar_all') + .toggle(thread.getID() === 'mailbox_starred') + .removeClass('o_hidden'); this.$buttons - .find('.o_mail_chat_button_select_all') - .toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation") - .removeClass("o_hidden"); + .find('.o_mail_discuss_button_select_all') + .toggle((thread.isModerated() && thread.isModerator()) || thread.getID() === 'mailbox_moderation') + .removeClass('o_hidden'); this.$buttons - .find('.o_mail_chat_button_unselect_all') - .toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation") - .removeClass("o_hidden"); - this.$buttons.find('.o_mail_chat_button_moderate_all').hide(); - this.$('.o_mail_chat_channel_item') + .find('.o_mail_discuss_button_unselect_all') + .toggle((thread.isModerated() && thread.isModerator()) || thread.getID() === 'mailbox_moderation') + .removeClass('o_hidden'); + this.$buttons.find('.o_mail_discuss_button_moderate_all').hide(); + + this.$('.o_mail_discuss_item') .removeClass('o_active') - .filter('[data-channel-id=' + channel.id + ']') + .filter('[data-thread-id=' + thread.getID() + ']') .removeClass('o_unread_message') .addClass('o_active'); }, @@ -942,20 +1006,20 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @private */ _updateModerationDecisionButton: function () { - if (this.thread.$('.moderation_checkbox:checked').length) { - this.$buttons.find('.o_mail_chat_button_moderate_all').show(); + if (this._threadWidget.$('.moderation_checkbox:checked').length) { + this.$buttons.find('.o_mail_discuss_button_moderate_all').show(); } else { - this.$buttons.find('.o_mail_chat_button_moderate_all').hide(); + this.$buttons.find('.o_mail_discuss_button_moderate_all').hide(); } }, /** * @private */ _updateSelectUnselectAllButtons: function () { - var buttonSelect = this.$buttons.find('.o_mail_chat_button_select_all'); - var buttonUnselect = this.$buttons.find('.o_mail_chat_button_unselect_all'); - var numCheckboxes = this.thread.$('.moderation_checkbox').length; - var numCheckboxesChecked = this.thread.$('.moderation_checkbox:checked').length; + var buttonSelect = this.$buttons.find('.o_mail_discuss_button_select_all'); + var buttonUnselect = this.$buttons.find('.o_mail_discuss_button_unselect_all'); + var numCheckboxes = this._threadWidget.$('.moderation_checkbox').length; + var numCheckboxesChecked = this._threadWidget.$('.moderation_checkbox:checked').length; if (numCheckboxes) { if (numCheckboxesChecked === numCheckboxes) { buttonSelect.toggleClass('disabled', true); @@ -972,6 +1036,26 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { buttonUnselect.toggleClass('disabled', true); } }, + /** + * Renders the mainside bar with current threads + * + * @private + */ + _updateThreads: function () { + var self = this; + + var $sidebar = this._renderSidebar({ + threads: _.filter(this.call('mail_service', 'getThreads'), function (thread) { + return thread.getType() !== 'document_thread'; + }), + }); + + this.$('.o_mail_discuss_sidebar').html($sidebar.contents()); + _.each(['dm', 'public', 'private'], function (type) { + var $input = self.$('.o_mail_add_thread[data-type=' + type + '] input'); + self._prepareAddThreadInput($input, type); + }); + }, //-------------------------------------------------------------------------- // Handlers @@ -979,103 +1063,123 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onAddChannel: function (event) { - event.preventDefault(); - var type = $(event.target).data("type"); - this.$('.o_mail_add_channel[data-type=' + type + ']') + _onAddThread: function (ev) { + ev.preventDefault(); + var type = $(ev.target).data('type'); + this.$('.o_mail_add_thread[data-type=' + type + ']') .show() - .find("input").focus(); + .find('input').focus(); }, /** * @private */ - _onAddChannelBlur: function () { - this.$('.o_mail_add_channel').hide(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onChannelClicked: function (event) { - event.preventDefault(); - var channelID = $(event.currentTarget).data('channel-id'); - var channel = this.call('chat_manager', 'getChannel', channelID); - this._setChannel(channel); + _onAddThreadBlur: function () { + this.$('.o_mail_add_thread').hide(); }, /** * @private * @param {integer|string} channelID */ _onChannelLeft: function (channelID) { - if (this.channel.id === channelID) { - var channel = this.call('chat_manager', 'getChannel', 'channel_inbox'); - this._setChannel(channel); + if (this._thread.getID() === channelID) { + this._setThread('mailbox_inbox'); } - this._updateChannels(); - delete this.channelsScrolltop[channelID]; + this._updateThreads(); + delete this._threadsScrolltop[channelID]; }, /** * @private */ _onCloseNotificationBar: function () { - this.$(".o_mail_annoying_notification_bar").slideUp(); + this.$('.o_mail_annoying_notification_bar').slideUp(); }, /** * @private */ _onComposerFocused: function () { - var composer = this.channel.mass_mailing ? this.extendedComposer : this.basicComposer; - var commands = this.call('chat_manager', 'getCommands', this.channel); - var partners = this.call('chat_manager', 'getMentionPartnerSuggestions', this.channel); - composer.mention_set_enabled_commands(commands); - composer.mention_set_prefetched_partners(partners); + var composer = this._thread.isMassMailing() ? this._extendedComposer : this._basicComposer; + var commands = this._thread.getCommands(); + var partners = this._thread.getMentionPartnerSuggestions(); + composer.mentionSetCommands(commands); + composer.mentionSetPrefetchedPartners(partners); }, /** + * When clicking on an item in the sidebar + * + * @private + * @param {MouseEvent} ev + */ + _onDiscussItemClicked: function (ev) { + ev.preventDefault(); + var threadID = $(ev.currentTarget).data('thread-id'); + this._setThread(threadID); + }, + /** + * Invite button is only for channels (not mailboxes) + * * @private */ _onInviteButtonClicked: function () { - var title = _.str.sprintf(_t('Invite people to #%s'), this.channel.name); - new PartnerInviteDialog(this, title, this.channel.id).open(); + var title = _.str.sprintf(_t("Invite people to #%s"), this._thread.getName()); + new PartnerInviteDialog(this, title, this._thread.getID()).open(); + }, + /** + * Called when someone asks discuss whether the bottom of `thread` is + * visible or not. An object `query` is provided in order to reponse on the + * key `isDisplayed`. + * + * @private + * @param {mail.model.Thread} thread + * @param {Object} query + * @param {boolean} query.isBottomVisible the response to provide on whether + * the thread is visible in discuss. + */ + _onIsThreadBottomVisible: function (thread, query) { + query.isBottomVisible = query.isBottomVisible || + ( + thread.getID() === this._thread.getID() && + this._threadWidget.isAtBottom() + ); }, /** * @private - * @param {KeyEvent} event + * @param {KeyEvent} ev */ - _onKeydown: function (event) { - if (event.which === $.ui.keyCode.ESCAPE && this.selected_message) { + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.ESCAPE && this._selectedMessage) { this._unselectMessage(); } }, /** * @private */ - _onMarkAllReadClicked: function () { - this.call('chat_manager', 'markAllAsRead', this.channel, this.domain); + _onMarkAllAsReadClicked: function () { + this._thread.markAllMessagesAsRead(this.domain); }, /** * @private - * @param {Object} message + * @param {mail.model.Message} message * @param {string} type the channel type */ _onMessageUpdated: function (message, type) { var self = this; - var currentChannelID = this.channel.id; - if ((currentChannelID === "channel_starred" && !message.is_starred) || - (currentChannelID === "channel_moderation" && !message.needsModeration) || - (currentChannelID === "channel_inbox" && !message.is_needaction)) { - this.call('chat_manager', 'getMessages', { - channelID: this.channel.id, - domain: this.domain - }).then(function (messages) { + var currentThreadID = this._thread.getID(); + if ( + (currentThreadID === 'mailbox_starred' && !message.isStarred()) || + (currentThreadID === 'mailbox_inbox' && !message.isNeedaction()) || + (currentThreadID === 'mailbox_moderation' && !message.needsModeration()) + ) { + this._thread.getMessages(this.domain) + .then(function (messages) { var options = self._getThreadRenderingOptions(messages); - self.thread.remove_message_and_render(message.id, messages, options) + self._threadWidget.removeMessageAndRender(message.getID(), messages, options) .then(function () { self._updateButtonStatus(messages.length === 0, type); }); }); - } else if (_.contains(message.channel_ids, currentChannelID) || (message.res_id === currentChannelID)) { + } else if (_.contains(message.getThreadIDs(), currentThreadID)) { this._fetchAndRenderThread(); } }, @@ -1085,7 +1189,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _onModerateAllClicked: function (ev) { var decision = $(ev.target).data('decision'); - var messageIDs = this.thread.$('.moderation_checkbox:checked') + var messageIDs = this._threadWidget.$('.moderation_checkbox:checked') .map(function () { return $(this).data('message-id'); }) @@ -1105,55 +1209,69 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }, /** * @private - * @param {Object} channel + * @param {mail.model.Channel} channel */ _onNewChannel: function (channel) { - this._updateChannels(); - if (channel.autoswitch) { - this._setChannel(channel); + this._updateThreads(); + if (channel.isAutoswitch()) { + this._setThread(channel.getID()); } }, /** * @private - * @param {Object} message + * @param {mail.model.Message} message */ _onNewMessage: function (message) { var self = this; - if (_.contains(message.channel_ids, this.channel.id)) { - if (this.channel.type !== 'static' && this.thread.is_at_bottom()) { - this.call('chat_manager', 'markChannelAsSeen', this.channel); + if (_.contains(message.getThreadIDs(), this._thread.getID())) { + if (this._thread.getType() !== 'mailbox' && this._threadWidget.isAtBottom()) { + this._thread.markAsRead(); } - var should_scroll = this.thread.is_at_bottom(); + var shouldScroll = this._threadWidget.isAtBottom(); this._fetchAndRenderThread().then(function () { - if (should_scroll) { - self.thread.scroll_to({id: message.id}); + if (shouldScroll) { + self._threadWidget.scrollToMessage({ msgID: message.getID() }); } }); } - // Re-render sidebar to indicate that there is a new message in the corresponding channels - this._updateChannels(); - // Dump scroll position of channels in which the new message arrived - this.channelsScrolltop = _.omit(this.channelsScrolltop, message.channel_ids); + // Re-render sidebar to indicate that there is a new message in the corresponding threads + this._updateThreads(); + // Dump scroll position of threads in which the new message arrived + this._threadsScrolltop = _.omit(this._threadsScrolltop, message.getThreadIDs()); + }, + /** + * Called when opening a thread in discuss, due to discuss being open. + * All threads except document threads are valid for discuss app. + * + * @private + * @param {mail.model.Thread} + */ + _onOpenThreadInDiscuss: function (thread) { + if (thread.getType() !== 'document_thread') { + this._setThread(thread.getID()); + } }, /** * @private - * @param {Object} message + * @param {Object} messageData */ - _onPostMessage: function (message) { + _onPostMessage: function (messageData) { var self = this; - var options = this.selected_message ? {} : {channelID: this.channel.id}; - if (this.selected_message) { - message.subtype = this.selected_message.is_note ? 'mail.mt_note': 'mail.mt_comment'; - message.subtype_id = false; - message.message_type = 'comment'; + if (this._selectedMessage) { + messageData.subtype = this._selectedMessage.isNote() ? 'mail.mt_note': 'mail.mt_comment'; + messageData.subtype_id = false; + messageData.message_type = 'comment'; + messageData.content_subtype = 'html'; } - this.call('chat_manager', 'postMessage', message, options) + this._thread.postMessage(messageData) .then(function () { - if (self.selected_message) { - self._renderSnackbar('mail.chat.MessageSentSnackbar', {record_name: self.selected_message.record_name}, 5000); + if (self._selectedMessage) { + self._renderSnackbar('mail.discuss.MessageSentSnackbar', { + documentName: self._selectedMessage.getDocumentName() + }, 5000); self._unselectMessage(); } else { - self.thread.scroll_to(); + self._threadWidget.scrollToBottom(); } }) .fail(function () { @@ -1165,9 +1283,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _onPublicChannelsClick: function () { this.do_action({ - name: _t('Public Channels'), + name: _t("Public Channels"), type: 'ir.actions.act_window', - res_model: "mail.channel", + res_model: 'mail.channel', views: [[false, 'kanban'], [false, 'form']], domain: [['public', '!=', 'private']], }, { @@ -1176,41 +1294,41 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }, /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onRequestNotificationPermission: function (event) { + _onRequestNotificationPermission: function (ev) { var self = this; - event.preventDefault(); - this.$(".o_mail_annoying_notification_bar").slideUp(); + ev.preventDefault(); + this.$('.o_mail_annoying_notification_bar').slideUp(); var def = window.Notification && window.Notification.requestPermission(); if (def) { def.then(function (value) { if (value !== 'granted') { - self.call('bus_service', 'sendNotification', self, _t('Permission denied'), - _t('Odoo will not have the permission to send native notifications on this device.')); + self.call('bus_service', 'sendNotification', _t("Permission denied"), + _t("Odoo will not have the permission to send native notifications on this device.")); } else { - self.call('bus_service', 'sendNotification', self, _t('Permission granted'), - _t('Odoo has now the permission to send you native notifications on this device.')); + self.call('bus_service', 'sendNotification', _t("Permission granted"), + _t("Odoo has now the permission to send you native notifications on this device.")); } }); } }, /** * @private - * @param {OdooEvent} + * @param {OdooEvent} ev */ - _onSearch: function (event) { - event.stopPropagation(); + _onSearch: function (ev) { + ev.stopPropagation(); var session = this.getSession(); var result = pyeval.eval_domains_and_contexts({ - domains: event.data.domains, + domains: ev.data.domains, contexts: [session.user_context], }); this.domain = result.domain; - if (this.channel) { + if (this._thread) { // initially (when _onSearch is called manually), there is no - // channel set yet, so don't try to fetch and render the thread as - // this will be done as soon as the default channel is set + // thread set yet, so don't try to fetch and render the thread as + // this will be done as soon as the default thread is set this._fetchAndRenderThread(); } }, @@ -1221,33 +1339,33 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _onSelectAllClicked: function (ev) { var $button = $(ev.target); if (!$button.hasClass('disabled')) { - this.thread.toggleModerationCheckboxes(true); + this._threadWidget.toggleModerationCheckboxes(true); this._updateModerationButtons(); } }, /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onChannelSettingsClicked: function (event) { - var channelID = $(event.target).data("channel-id"); + _onChannelSettingsClicked: function (ev) { + var threadID = $(ev.target).data('thread-id'); this.do_action({ type: 'ir.actions.act_window', - res_model: "mail.channel", - res_id: channelID, + res_model: 'mail.channel', + res_id: threadID, views: [[false, 'form']], target: 'current' }); }, /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onUnpinChannel: function (event) { - event.stopPropagation(); - var channelID = $(event.target).data("channel-id"); - var channel = this.call('chat_manager', 'getChannel', channelID); - this.call('chat_manager', 'unsubscribe', channel); + _onUnpinChannel: function (ev) { + ev.stopPropagation(); + var channelID = $(ev.target).data('thread-id'); + var channel = this.call('mail_service', 'getChannel', channelID); + channel.unsubscribe(); }, /** * @private @@ -1256,7 +1374,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _onUnselectAllClicked: function (ev) { var $button = $(ev.target); if (!$button.hasClass('disabled')) { - this.thread.toggleModerationCheckboxes(false); + this._threadWidget.toggleModerationCheckboxes(false); this._updateModerationButtons(); } }, @@ -1264,7 +1382,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @private */ _onUnstarAllClicked: function () { - this.call('chat_manager', 'unstarAll'); + this.call('mail_service', 'unstarAll'); }, /** * Update the moderation buttons. diff --git a/addons/mail/static/src/js/discuss_mobile.js b/addons/mail/static/src/js/discuss_mobile.js index 531553e6804..867af05152c 100644 --- a/addons/mail/static/src/js/discuss_mobile.js +++ b/addons/mail/static/src/js/discuss_mobile.js @@ -1,7 +1,7 @@ -odoo.define('mail.chat_discuss_mobile', function (require) { +odoo.define('mail.DiscussMobile', function (require) { "use strict"; -var Discuss = require('mail.chat_discuss'); +var Discuss = require('mail.Discuss'); var config = require('web.config'); var core = require('web.core'); @@ -12,12 +12,15 @@ if (!config.device.isMobile) { return; } +/** + * Overrides Discuss module in mobile + */ Discuss.include({ template: 'mail.discuss_mobile', events: _.extend(Discuss.prototype.events, { 'click .o_mail_mobile_tab': '_onMobileTabClicked', - 'click .o_channel_inbox_item': '_onMobileInboxButtonClicked', - 'click .o_mail_channel_preview': '_onMobileChannelClicked', + 'click .o_mailbox_inbox_item': '_onMobileInboxButtonClicked', + 'click .o_mail_preview': '_onMobileThreadClicked', }), /** @@ -25,13 +28,13 @@ Discuss.include({ */ init: function () { this._super.apply(this, arguments); - this.currentState = this.defaultChannelID; + this._currentState = this._defaultThreadID; }, /** * @override */ start: function () { - this.$mainContent = this.$('.o_mail_chat_content'); + this._$mainContent = this.$('.o_mail_discuss_content'); return this._super.apply(this, arguments) .then(this._updateControlPanel.bind(this)); }, @@ -39,8 +42,8 @@ Discuss.include({ * @override */ on_attach_callback: function () { - if (this.channel && this._isInInboxTab()) { - this.thread.scroll_to({offset: this.channelsScrolltop[this.channel.id]}); + if (this._thread && this._isInInboxTab()) { + this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]); } }, /** @@ -48,7 +51,7 @@ Discuss.include({ */ on_detach_callback: function () { if (this._isInInboxTab()) { - this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop(); + this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop(); } }, @@ -61,7 +64,7 @@ Discuss.include({ * @returns {Boolean} true iff we currently are in the Inbox tab */ _isInInboxTab: function () { - return _.contains(['channel_inbox', 'channel_starred'], this.currentState); + return _.contains(['mailbox_inbox', 'mailbox_starred'], this._currentState); }, /** * @override @@ -71,18 +74,18 @@ Discuss.include({ var self = this; this._super.apply(this, arguments); _.each(['dm', 'public', 'private'], function (type) { - var selector = '.o_mail_chat_button_' + type; - self.$buttons.on('click', selector, self._onAddChannel.bind(self)); + var selector = '.o_mail_discuss_button_' + type; + self.$buttons.on('click', selector, self._onAddThread.bind(self)); }); }, /** - * Overrides to only store the channel state if we are in the Inbox tab, as - * this is the only tab in which we actually have a displayed channel + * Overrides to only store the thread state if we are in the Inbox tab, as + * this is the only tab in which we actually have a displayed thread * * @override * @private */ - _restoreChannelState: function () { + _restoreThreadState: function () { if (this._isInInboxTab()) { this._super.apply(this, arguments); } @@ -100,29 +103,31 @@ Discuss.include({ /** * @override * @private - * @param {Object} channel */ - _setChannel: function (channel) { - if (channel.type !== 'static') { - return this.call('chat_manager', 'detachChannel', channel.id); + _setThread: function (threadID) { + var thread = this.call('mail_service', 'getThread', threadID); + if (thread.getType() !== 'mailbox') { + this.call('mail_service', 'openThreadWindow', threadID); + return $.when(); } else { return this._super.apply(this, arguments); } }, /** - * Overrides to only store the channel state if we are in the Inbox tab, as - * this is the only tab in which we actually have a displayed channel + * Overrides to only store the thread state if we are in the Inbox tab, as + * this is the only tab in which we actually have a displayed thread * * @override * @private */ - _storeChannelState: function () { - if (this.channel && this._isInInboxTab()) { + _storeThreadState: function () { + if (this._thread && this._isInInboxTab()) { this._super.apply(this, arguments); } }, /** - * Overrides to toggle the visibility of the tabs when a message is unselected + * Overrides to toggle the visibility of the tabs when a message is + * unselected * * @override * @private @@ -135,74 +140,86 @@ Discuss.include({ * @override * @private */ - _updateChannels: function () { - return this._updateContent(this.currentState); + _updateThreads: function () { + return this._updateContent(this._currentState); }, /** * Redraws the content of the client action according to its current state. * * @private - * @param {string} type the channel's type to display (e.g. 'channel_inbox', - * 'channel_starred', 'dm'...). + * @param {string} type the thread's type to display (e.g. 'mailbox_inbox', + * 'mailbox_starred', 'dm'...). */ _updateContent: function (type) { var self = this; - var inInbox = type === 'channel_inbox' || type === 'channel_starred'; - if (!inInbox && this._isInInboxTab()) { + var inMailbox = type === 'mailbox_inbox' || type === 'mailbox_starred'; + if (!inMailbox && this._isInInboxTab()) { // we're leaving the inbox, so store the thread scrolltop - this._storeChannelState(); + this._storeThreadState(); } var previouslyInInbox = this._isInInboxTab(); - this.currentState = type; + this._currentState = type; // fetch content to display var def; - if (inInbox) { + if (inMailbox) { def = this._fetchAndRenderThread(); } else { - var allChannels = this.call('chat_manager', 'getChannels'); - var channels = _.where(allChannels, {type: type}); - def = this.call('chat_manager', 'getChannelsPreview', channels); + var allChannels = this.call('mail_service', 'getChannels'); + var channels = _.filter(allChannels, function (channel) { + return channel.getType() === type; + }); + def = this.call('mail_service', 'getChannelPreviews', channels); } - return $.when(def).then(function (channelsPreview) { + return $.when(def).then(function (previews) { // update content - if (inInbox) { + if (inMailbox) { if (!previouslyInInbox) { - self.$('.o_mail_chat_tab_pane').remove(); - self.$mainContent.append(self.thread.$el); - self.$mainContent.append(self.extendedComposer.$el); + self.$('.o_mail_discuss_tab_pane').remove(); + self._$mainContent.append(self._threadWidget.$el); + self._$mainContent.append(self._extendedComposer.$el); } - self._restoreChannelState(); + self._restoreThreadState(); } else { - self.thread.$el.detach(); - self.extendedComposer.$el.detach(); - var $content = $(QWeb.render("mail.chat.MobileTabPane", { - channels: channelsPreview, + self._threadWidget.$el.detach(); + self._extendedComposer.$el.detach(); + var $content = $(QWeb.render('mail.discuss.MobileTabPane', { + previews: previews, type: type, })); - self._prepareAddChannelInput($content.find('.o_mail_add_channel input'), type); - self.$mainContent.html($content); + self._prepareAddThreadInput($content.find('.o_mail_add_thread input'), type); + self._$mainContent.html($content); } // update control panel - self.$buttons.find('button').addClass('o_hidden'); - self.$buttons.find('.o_mail_chat_button_' + type).removeClass('o_hidden'); - self.$buttons.find('.o_mail_chat_button_mark_read').toggleClass('o_hidden', type !== 'channel_inbox'); - self.$buttons.find('.o_mail_chat_button_unstar_all').toggleClass('o_hidden', type !== 'channel_starred'); + self.$buttons.find('button') + .addClass('o_hidden'); + self.$buttons.find('.o_mail_discuss_button_' + type) + .removeClass('o_hidden'); + self.$buttons.find('.o_mail_discuss_button_mark_all_read') + .toggleClass('o_hidden', type !== 'mailbox_inbox'); + self.$buttons.find('.o_mail_discuss_button_unstar_all') + .toggleClass('o_hidden', type !== 'mailbox_starred'); - // update Inbox page buttons - if (inInbox) { - self.$('.o_mail_chat_mobile_inbox_buttons').removeClass('o_hidden'); - self.$('.o_channel_inbox_item').removeClass('btn-primary').addClass('btn-default'); - self.$('.o_channel_inbox_item[data-type=' + type + ']').removeClass('btn-default').addClass('btn-primary'); + // update Mailbox page buttons + if (inMailbox) { + self.$('.o_mail_discuss_mobile_mailboxes_buttons') + .removeClass('o_hidden'); + self.$('.o_mailbox_inbox_item') + .removeClass('btn-primary') + .addClass('btn-default'); + self.$('.o_mailbox_inbox_item[data-type=' + type + ']') + .removeClass('btn-default') + .addClass('btn-primary'); } else { - self.$('.o_mail_chat_mobile_inbox_buttons').addClass('o_hidden'); + self.$('.o_mail_discuss_mobile_mailboxes_buttons') + .addClass('o_hidden'); } // update bottom buttons self.$('.o_mail_mobile_tab').removeClass('active'); - // channel_inbox and channel_starred share the same tab - type = type === 'channel_starred' ? 'channel_inbox' : type; + // mailbox_inbox and mailbox_starred share the same tab + type = type === 'mailbox_starred' ? 'mailbox_inbox' : type; self.$('.o_mail_mobile_tab[data-type=' + type + ']').addClass('active'); }); }, @@ -212,45 +229,46 @@ Discuss.include({ //-------------------------------------------------------------------------- /** + * @override * @private */ - _onAddChannel: function () { - this.$('.o_mail_add_channel').show().find('input').focus(); + _onAddThread: function () { + this.$('.o_mail_add_thread').show().find('input').focus(); }, /** - * Switches to the clicked channel in the Inbox page (Inbox or Starred). + * Switches to the clicked thread in the Inbox page (Inbox or Starred). * * @private - * @param {MouseEvent} + * @param {MouseEvent} ev */ - _onMobileInboxButtonClicked: function (event) { - var channel = this.call('chat_manager', 'getChannel', $(event.currentTarget).data('type')); - this._setChannel(channel); - this._updateContent(this.channel.id); + _onMobileInboxButtonClicked: function (ev) { + var mailboxID = $(ev.currentTarget).data('type'); + this._setThread(mailboxID); + this._updateContent(this._thread.getID()); }, /** * Switches to another tab. * * @private - * @param {MouseEvent} + * @param {MouseEvent} ev */ - _onMobileTabClicked: function (event) { - var type = $(event.currentTarget).data('type'); - if (type === 'channel_inbox') { - var channel = this.call('chat_manager', 'getChannel', 'channel_inbox'); - this._setChannel(channel); + _onMobileTabClicked: function (ev) { + var type = $(ev.currentTarget).data('type'); + if (type === 'mailbox') { + var inbox = this.call('mail_service', 'getMailbox', 'inbox'); + this._setThread(inbox); } this._updateContent(type); }, /** - * Opens a channel in a chat windown (full screen in mobile). + * Opens a thread in a chat window (full screen in mobile). * * @private - * @param {MouseEvent} + * @param {MouseEvent} ev */ - _onMobileChannelClicked: function (event) { - var channelID = $(event.currentTarget).data("channel_id"); - this.call('chat_manager', 'detachChannel', channelID); + _onMobileThreadClicked: function (ev) { + var threadID = $(ev.currentTarget).data('preview-id'); + this.call('mail_service', 'openThreadWindow', threadID); }, }); diff --git a/addons/mail/static/src/js/emojis.js b/addons/mail/static/src/js/emojis.js new file mode 100644 index 00000000000..4fedb5647a6 --- /dev/null +++ b/addons/mail/static/src/js/emojis.js @@ -0,0 +1,149 @@ +odoo.define('mail.emojis', function (require) { +"use strict"; + +/** + * This module exports the list of all available emojis on the client side. + * An emoji object has the following properties: + * + * - {string[]} sources: the character representations of the emoji + * - {string} unicode: the unicode representation of the emoji + * - {string} description: the description of the emoji + */ + +/** + * This data represent all the available emojis that are supported on the web + * client: + * + * - key: this is the source representation of an emoji, i.e. its "character" + * representation. This is a string that can be easily typed by the + * user and then translated to its unicode representation (see value) + * - value: this is the unicode representation of an emoji, i.e. its "true" + * representation in the system. + */ +var data = { + ":)": "😊", + ":-)": "😊", // alternative (alt.) + "=)": "😊", // alt. + ":]": "😊", // alt. + ":D": "😃", + ":-D": "😃", // alt. + "=D": "😃", // alt. + "xD": "😆", + "XD": "😆", // alt. + "x'D": "😂", + ";)": "😉", + ";-)": "😉", // alt. + "B)": "😎", + "8)": "😎", // alt. + "B-)": "😎", // alt. + "8-)": "😎", // alt. + ";p": "😜", + ";P": "😜", // alt. + ":p": "😋", + ":P": "😋", // alt. + ":-p": "😋", // alt. + ":-P": "😋", // alt. + "=P": "😋", // alt. + "xp": "😝", + "xP": "😝", // alt. + "o_o": "😳", + ":|": "😐", + ":-|": "😐", // alt. + ":/": "😕", // alt. + ":-/": "😕", // alt. + ":(": "😞", + ":@": "😱", + ":O": "😲", + ":-O": "😲", // alt. + ":o": "😲", // alt. + ":-o": "😲", // alt. + ":'o": "😨", + "3:(": "😠", + ">:(": "😠", // alt. + "3:": "😠", // alt. + "3:)": "😈", + ">:)": "😈", // alt. + ":*": "😘", + ":-*": "😘", // alt. + "o:)": "😇", + ":'(": "😢", + ":'-(": "😭", + ":\"(": "😭", // alt. + "<3": "❤️", + ":heart": "❤️", // alt. + ":heart_eyes": "😍", + ":turban": "👳", + ":+1": "👍", + ":-1": "👎", + ":ok": "👌", + ":poop": "💩", + ":no_see": "🙈", + ":no_hear": "🙉", + ":no_speak": "🙊", + ":bug": "🐞", + ":kitten": "😺", + ":bear": "🐻", + ":snail": "🐌", + ":boar": "🐗", + ":clover": "🍀", + ":sunflower": "🌹", + ":fire": "🔥", + ":sun": "☀️", + ":partly_sunny:": "⛅️", + ":rainbow": "🌈", + ":cloud": "☁️", + ":zap": "⚡️", + ":star": "⭐️", + ":cookie": "🍪", + ":pizza": "🍕", + ":hamburger": "🍔", + ":fries": "🍟", + ":cake": "🎂", + ":cake_part": "🍰", + ":coffee": "☕️", + ":banana": "🍌", + ":sushi": "🍣", + ":rice_ball": "🍙", + ":beer": "🍺", + ":wine": "🍷", + ":cocktail": "🍸", + ":tropical": "🍹", + ":beers": "🍻", + ":ghost": "👻", + ":skull": "💀", + ":et": "👽", + ":alien": "👽", // alt. + ":party": "🎉", + ":trophy": "🏆", + ":key": "🔑", + ":pin": "📌", + ":postal_horn": "📯", + ":music": "🎵", + ":trumpet": "🎺", + ":guitar": "🎸", + ":soccer": "⚽️", + ":football": "🏈", + ":8ball": "🎱", + ":clapper": "🎬", + ":microphone": "🎤" +}; + +// list of emojis in a dictionary, indexed by emoji unicode +var emojiDict = {}; +_.each(data, function (unicode, source) { + if (!emojiDict[unicode]) { + emojiDict[unicode] = { + sources: [source], + unicode: unicode, + description: source, + }; + } else { + emojiDict[unicode].sources.push(source); + } +}); + +var emojis = _.values(emojiDict); + +return emojis; + +}); diff --git a/addons/mail/static/src/js/extended_chat_window.js b/addons/mail/static/src/js/extended_chat_window.js deleted file mode 100644 index a85740ac5e1..00000000000 --- a/addons/mail/static/src/js/extended_chat_window.js +++ /dev/null @@ -1,63 +0,0 @@ -odoo.define('mail.ExtendedChatWindow', function (require) { -"use strict"; - -var ChatWindow = require('mail.ChatWindow'); -var composer = require('mail.composer'); - -return ChatWindow.extend({ - template: "mail.ExtendedChatWindow", - events: _.extend({}, ChatWindow.prototype.events, { - "click .o_chat_window_expand": "on_click_expand", - }), - start: function () { - var self = this; - var def; - if (self.options.thread_less) { - this.$el.addClass('o_thread_less'); - this.$('.o_chat_search_input input') - .autocomplete({ - source: function (request, response) { - self.call('chat_manager', 'searchPartner', request.term, 10).done(response); - }, - select: function (event, ui) { - self.trigger('open_dm_session', ui.item.id); - }, - }) - .focus(); - } else if (!self.options.input_less) { - var basicComposer = new composer.BasicComposer(self, {mention_partners_restricted: true, isMini: true}); - basicComposer.on('post_message', self, function (message) { - self.call('chat_manager', 'postMessage', message, { - channelID: self.channel_id, - }); - }); - basicComposer.once('input_focused', self, function () { - var channel = self.call('chat_manager', 'getChannel', self.channel_id); - var commands = self.call('chat_manager', 'getCommands', channel); - var partners = self.call('chat_manager', 'getMentionPartnerSuggestions', channel); - basicComposer.mention_set_enabled_commands(commands); - basicComposer.mention_set_prefetched_partners(partners); - }); - def = basicComposer.replace(self.$('.o_chat_composer')); - } - return $.when(this._super(), def); - }, - // Override on_keydown to only prevent jquery's blockUI to cancel event, but without sending - // the message on ENTER keydown as this is handled by the BasicComposer - on_keydown: function (event) { - event.stopPropagation(); - }, - on_reverse_breadcrumb: function () { - this.call('chat_manager', 'getChatBus').trigger('discuss_open', false); - }, - on_click_expand: _.debounce(function (event) { - event.preventDefault(); - this.do_action('mail.mail_channel_action_client_chat', { - clear_breadcrumbs: false, - active_id: this.channel_id, - on_reverse_breadcrumb: this.on_reverse_breadcrumb - }); - }, 1000, true), -}); - -}); diff --git a/addons/mail/static/src/js/followers.js b/addons/mail/static/src/js/followers.js index 651165dde30..a28f72edb1c 100644 --- a/addons/mail/static/src/js/followers.js +++ b/addons/mail/static/src/js/followers.js @@ -31,7 +31,7 @@ var Followers = AbstractField.extend({ supportedFieldTypes: ['one2many'], // inherited - init: function(parent, name, record, options) { + init: function (parent, name, record, options) { this._super.apply(this, arguments); this.image = this.attrs.image || 'image_small'; @@ -40,7 +40,7 @@ var Followers = AbstractField.extend({ this.followers = []; this.subtypes = []; this.data_subtype = {}; - this.is_follower = undefined; + this._isFollower = undefined; var session = this.getSession(); this.partnerID = session.partner_id; @@ -84,7 +84,7 @@ var Followers = AbstractField.extend({ // private _displayButtons: function () { - if (this.is_follower) { + if (this._isFollower) { this.$('button.o_followers_follow_button').removeClass('o_followers_notfollow').addClass('o_followers_following'); this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', false); this.$('.o_followers_actions .dropdown-toggle').addClass('o_followers_following'); @@ -100,7 +100,7 @@ var Followers = AbstractField.extend({ this.$('.o_followers_title_box > button').prop('disabled', true); this.$('.o_followers_count') .html(this.value.res_ids.length) - .parent().attr("title", this._formatFollowers(this.value.res_ids.length)); + .parent().attr('title', this._formatFollowers(this.value.res_ids.length)); }, _displayFollowers: function () { var self = this; @@ -118,7 +118,7 @@ var Followers = AbstractField.extend({ // On mouse-enter it will show the edit_subtype pencil. if (record.is_editable) { - $follower_li.on('mouseenter mouseleave', function(e) { + $follower_li.on('mouseenter mouseleave', function (e) { $(e.currentTarget).find('.o_edit_subtype').toggleClass('hide', e.type === 'mouseleave'); }); } @@ -129,7 +129,7 @@ var Followers = AbstractField.extend({ this.$('.o_followers_title_box > button').prop('disabled', !$followers_list.children().length); this.$('.o_followers_count') .html(this.value.res_ids.length) - .parent().attr("title", this._formatFollowers(this.value.res_ids.length)); + .parent().attr('title', this._formatFollowers(this.value.res_ids.length)); }, _displaySubtypes:function (data, dialog, display_warning) { var old_parent_model; @@ -167,7 +167,7 @@ var Followers = AbstractField.extend({ view_mode: 'form', view_type: 'form', views: [[false, 'form']], - name: _t('Invite Follower'), + name: _t("Invite Follower"), target: 'new', context: { 'default_res_model': this.model, @@ -182,11 +182,11 @@ var Followers = AbstractField.extend({ _formatFollowers: function (count){ var str = ''; if (count <= 0) { - str = _t('No follower'); + str = _t("No follower"); } else if (count === 1){ - str = _t('One follower'); + str = _t("One follower"); } else { - str = ''+count+' '+_t('followers'); + str = ''+count+' '+_t("followers"); } return str; }, @@ -210,7 +210,7 @@ var Followers = AbstractField.extend({ return _.contains(self.value.res_ids, follower.id); }); var user_follower = _.filter(self.followers, function (rec) { return rec.is_uid; }); - self.is_follower = user_follower.length >= 1; + self._isFollower = user_follower.length >= 1; }); }, _reload: function () { @@ -258,17 +258,17 @@ var Followers = AbstractField.extend({ }); return def; }, - _updateSubscription: function (event, follower_id, is_channel) { + _updateSubscription: function (event, followerID, isChannel) { var ids = {}; var subtypes; - if (follower_id !== undefined) { + if (followerID !== undefined) { // Subtypes edited from the modal subtypes = this.dialog.$('input[type="checkbox"]'); - if (is_channel) { - ids.channel_ids = [follower_id]; + if (isChannel) { + ids.channel_ids = [followerID]; } else { - ids.partner_ids = [follower_id]; + ids.partner_ids = [followerID]; } } else { subtypes = this.$('.o_followers_actions input[type="checkbox"]'); @@ -286,7 +286,7 @@ var Followers = AbstractField.extend({ // If no more subtype followed, unsubscribe the follower if (!checklist.length) { this._unfollow(ids).fail(function () { - $(event.target).prop("checked", true); + $(event.target).prop('checked', true); }); } else { var kwargs = _.extend({}, ids); @@ -302,21 +302,33 @@ var Followers = AbstractField.extend({ } }, - // handlers - _onAddFollower: function (event) { - event.preventDefault(); + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onAddFollower: function (ev) { + ev.preventDefault(); this._inviteFollower(false); }, - _onAddChannel: function (event) { - event.preventDefault(); + /** + * @private + * @param {MouseEvent} ev + */ + _onAddChannel: function (ev) { + ev.preventDefault(); this._inviteFollower(true); }, /** - * @param {Event} event + * @private + * @param {MouseEvent} ev */ - _onEditSubtype: function (event) { + _onEditSubtype: function (ev) { var self = this; - var $currentTarget = $(event.currentTarget); + var $currentTarget = $(ev.currentTarget); var follower_id = $currentTarget.data('follower-id'); // id of model mail_follower this._rpc({ route: '/mail/read_subscription_data', @@ -327,13 +339,13 @@ var Followers = AbstractField.extend({ var is_channel = $currentTarget.data('oe-model') === 'mail.channel'; self.dialog = new Dialog(this, { size: 'medium', - title: _t('Edit Subscription of ') + $currentTarget.siblings('a').text(), + title: _t("Edit Subscription of ") + $currentTarget.siblings('a').text(), buttons: [ { text: _t("Apply"), classes: 'btn-primary', click: function () { - self._updateSubscription(event, res_id, is_channel); + self._updateSubscription(ev, res_id, is_channel); }, close: true }, @@ -349,16 +361,23 @@ var Followers = AbstractField.extend({ self.dialog.open(); }); }, + /** + * @private + */ _onFollowButtonClicked: function () { - if (!this.is_follower) { + if (!this._isFollower) { this._follow(); } else { this._unfollow({partner_ids: [this.partnerID]}); } }, - _onRedirect: function (event) { - event.preventDefault(); - var $target = $(event.target); + /** + * @private + * @param {MouseEvent} ev + */ + _onRedirect: function (ev) { + ev.preventDefault(); + var $target = $(ev.target); this.do_action({ type: 'ir.actions.act_window', view_type: 'form', @@ -368,18 +387,26 @@ var Followers = AbstractField.extend({ res_id: $target.data('oe-id'), }); }, - _onRemoveFollower: function (event) { - var res_model = $(event.target).parent().find('a').data('oe-model'); - var res_id = $(event.target).parent().find('a').data('oe-id'); - if (res_model === 'res.partner') { - return this._unfollow({partner_ids: [res_id]}); + /** + * @private + * @param {MouseEvent} ev + */ + _onRemoveFollower: function (ev) { + var resModel = $(ev.target).parent().find('a').data('oe-model'); + var resID = $(ev.target).parent().find('a').data('oe-id'); + if (resModel === 'res.partner') { + return this._unfollow({partner_ids: [resID]}); } else { - return this._unfollow({channel_ids: [res_id]}); + return this._unfollow({channel_ids: [resID]}); } }, - _onSubtypeClicked: function (event) { - event.stopPropagation(); - this._updateSubscription(event); + /** + * @private + * @param {MouseEvent} ev + */ + _onSubtypeClicked: function (ev) { + ev.stopPropagation(); + this._updateSubscription(ev); var $list = this.$('.o_subtypes_list'); if (!$list.hasClass('open')) { $list.addClass('open'); diff --git a/addons/mail/static/src/js/form_controller.js b/addons/mail/static/src/js/form_controller.js index b8cb5ab1c25..a165fe850ba 100644 --- a/addons/mail/static/src/js/form_controller.js +++ b/addons/mail/static/src/js/form_controller.js @@ -19,12 +19,12 @@ FormController.include({ /** * @private - * @param {OdooEvent} event + * @param {OdooEvent} ev * @param {string} event.data.id datapointID - * @param {integer[]} event.data.msgIDs list of message ids + * @param {integer[]} event.data.messageIDs list of message IDs */ - _onNewMessage: function (event) { - this.model.updateMessageIDs(event.data.id, event.data.msgIDs); + _onNewMessage: function (ev) { + this.model.updateMessageIDs(ev.data.id, ev.data.messageIDs); }, }); diff --git a/addons/mail/static/src/js/models/channel.js b/addons/mail/static/src/js/models/channel.js new file mode 100644 index 00000000000..e63cab2f907 --- /dev/null +++ b/addons/mail/static/src/js/models/channel.js @@ -0,0 +1,442 @@ +odoo.define('mail.model.Channel', function (require) { +"use strict"; + +var ThreadWithCache = require('mail.model.ThreadWithCache'); +var mailUtils = require('mail.utils'); + +var time = require('web.time'); + +/** + * This class represent channels in JS. In this context, the word channel + * has the same meaning of channel on the server, meaning that direct messages + * (DM) and livechats are also channels. + * + * Any piece of code in JS that make use of channels must ideally interact with + * such objects, instead of direct data from the server. + */ +var Channel = ThreadWithCache.extend({ + /** + * @override + * @param {string} [data.anonymous_name] + * @param {string} data.channel_type + * @param {boolean} data.group_based_subscription + * @param {boolean} [data.is_minimized=false] + * @param {boolean} data.is_moderator whether the current user is + * moderator of this channel. + * @param {string} [data.last_message_date] date in server-format + * @param {boolean} [data.mass_mailing] + * @param {integer} [data.message_unread_counter] + * @param {string} [data.public] either 'public' or 'private' + * @param {string} data.state + * @param {string} [data.uuid] + * @param {Object} options + * @param {boolean} [options.autoswitch=true] + * @param {Object[]} commands + */ + init: function (parent, data, options, commands) { + var self = this; + this._super.apply(this, arguments); + + // If set, autoswitch channel on joining this channel in discuss + // the default behaviour is to autoswitch on join. + // exception: receiving channel or chat session notifications + // + this._autoswitch = 'autoswitch' in options ? options.autoswitch : true; + this._chat = undefined; // FIXME: could be dropped when livechat and DM are moved out of this class + this._commands = undefined; + this._detached = data.is_minimized; + this._directPartnerID = undefined; + this._folded = data.state === 'folded'; + // if set: hide 'Leave channel' button + this._groupBasedSubscription = data.group_based_subscription; + this._isModerator = data.is_moderator; + this._lastMessageDate = undefined; + this._massMailing = data.mass_mailing; + // Deferred that is resolved on fetched members of this channel. + this._membersDef = undefined; + this._moderation = data.is_moderation; + // number of messages that are 'needaction', which is equivalent to the + // number of messages in this channel that are in inbox. + this._needactionCounter = data.message_needaction_counter || 0; + this._serverType = data.channel_type; + this._status = undefined; + this._throttleFetchSeen = _.throttle(this._fetchSeen.bind(this), 3000); + // unique identifier for this channel, which is required for some rpc + this._uuid = data.uuid; + + // * list of commands available for this channel + this._commands = _.filter(commands, function (command) { + return !command.channel_types || + _.contains(command.channel_types, self._serverType); + }); + if (this._type === 'channel') { + this._type = data.public !== 'private' ? 'public' : 'private'; + } + if ('anonymous_name' in data) { + this._name = data.anonymous_name; + } + if (data.last_message_date) { + this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date)); + } + this._chat = !this.getType().match(/^(public|private)$/); + if (data.message_unread_counter) { + this._unreadCounter = data.message_unread_counter; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Close the channel + * + * This operation is executed server-side and the chat window will be + * folded in all potential tabs from all browsers. + * + * @override + */ + close: function () { + this._super.apply(this, arguments); + this._rpc({ + model: 'mail.channel', + method: 'channel_fold', + kwargs: { uuid: this.getUUID(), state: 'closed' }, + }, { shadow: true }); + }, + /** + * Decrement the needaction counter of the channel + * Floor value at 0. + * + * @param {integer} [num=1] the amount to decrement at most + */ + decrementNeedactionCounter: function (num) { + num = _.isNumber(num) ? num : 1; + this._needactionCounter = Math.max(this._needactionCounter - 1, 0); + }, + /** + * Open the chat window for this channel + * (in all potential tabs from all browsers) + * + * @pverride + */ + detach: function () { + this._super.apply(this, arguments); + this._rpc({ + model: 'mail.channel', + method: 'channel_minimize', + args: [this.getUUID(), true], + }, { + shadow: true, + }); + }, + /** + * Force fetch members of the channel + * + * @returns {$.Promise} resolved with list of channel listeners + */ + forceFetchMembers: function () { + this._membersDef = undefined; + return this.getMentionPartnerSuggestions(); + }, + /** + * Folds/Minimize the channel + * (in all potential tabs from all browsers) + * + * @override + * @param {boolean} folded + */ + fold: function (folded) { + this._super.apply(this, arguments); + var args = { + uuid: this.getUUID(), + }; + if (_.isBoolean(folded)) { + args.state = folded ? 'folded' : 'open'; + } + this._rpc({ + model: 'mail.channel', + method: 'channel_fold', + kwargs: args, + }, {shadow: true}); + }, + /** + * Show the list of available commands on this channel (e.g. '/help') + * + * @override + * @returns {Array} list of commands + */ + getCommands: function () { + return this._commands; + }, + /** + * @returns {mail.model.Message|undefined} last message of channel, if any + */ + getLastMessage: function () { + return _.last(this._cache['[]'].messages); + }, + /** + * Get listeners of a channel + * + * @returns {$.Promise} resolved with list of channel listeners + */ + getMentionPartnerSuggestions: function () { + if (!this._membersDef) { + this._membersDef = this._rpc({ + model: 'mail.channel', + method: 'channel_fetch_listeners', + args: [this.getUUID()], + }, { + shadow: true + }) + .then(function (members) { + return members; + }); + } + return this._membersDef; + }, + /** + * @returns {integer} + */ + getNeedactionCounter: function () { + return this._needactionCounter; + }, + /** + * @override + */ + getPreview: function () { + var result = this._super.apply(this, arguments); + if (!this.isChat()) { + result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_small'; + } + var lastMessage = this.getLastMessage(); + return _.extend(result, { + author: lastMessage ? lastMessage.getDisplayedAuthor() : '', + body: lastMessage ? mailUtils.parseAndTransform(lastMessage.getBody(), mailUtils.inline) : '', + date: lastMessage ? lastMessage.getDate() : moment(), + isAuthor: this.hasMessages() && this.getLastMessage().isAuthor(), + }); + }, + /** + * Returns the title to display in thread window's headers. + * For channels, the title is prefixed with "#". + * + * @override + * @returns {string|Object} the name of the thread by default (see getName) + */ + getTitle: function () { + return "#" + this._super.apply(this, arguments); + }, + /** + * Get the UUID of the channel. + * This is a string that uniquely links this channel to a server channel. + * + * @returns {string} uuid of this channel + */ + getUUID: function () { + return this._uuid; + }, + /** + * Increment the needaction counter of this channel by 1 unit + */ + incrementNeedactionCounter: function () { + this._needactionCounter++; + }, + /** + * States whether the channel should be auto-selected on creation + * + * Note that this is not of the responsibility of the channel + * (see mail.model.Thread for more information) + * + * @override + * @returns {boolean} + */ + isAutoswitch: function () { + return this._autoswitch; + }, + /** + * All instances of this class are chanels + * + * @override + * @returns {boolean} + */ + isChannel: function () { + return true; + }, + /** + * States whether this channel is a chat or not. + * These types of channels are chat: + * - direct messages (DM) + * - livechat + * + * @override + * @returns {boolean} + */ + isChat: function () { + return this._chat; + }, + /** + * States whether the channel auto-subscribes some users in a group + * + * @returns {boolean} + */ + isGroupBasedSubscription: function () { + return this._groupBasedSubscription; + }, + /** + * States whether the channel is moderated or not. + * + * @override + * @returns {boolean} + */ + isModerated: function () { + return this._moderation; + }, + /** + * States whether the current user is moderator of this channel. + * + * @returns {boolean} + */ + isModerator: function () { + return this._isModerator; + }, + /** + * Marks this channel as read. + * The last seen message will be the last message. + * Resolved with the last seen message, only for non-mailbox channels + * + * @override + * @returns {$.Promise} resolved with last message ID + * seen in the channel, and when the channel has been marked as seen on + * the server. + */ + markAsRead: function () { + if (this._unreadCounter > 0) { + this.resetUnreadCounter(); + return this._throttleFetchSeen(); + } + return this._super.apply(this, arguments); + }, + /** + * Prepare and send a message to the server on this channel + * + * @override + * @param {Object} data data related to the new message + * @returns {$.Promise} resolved when the message has been sent to + * the server, with the object message that has been sent to the server. + */ + postMessage: function (data) { + var self = this; + return this._super.apply(this, arguments).then(function (messageData) { + _.extend(messageData, { + message_type: 'comment', + subtype: 'mail.mt_comment', + command: data.command, + }); + return self._rpc({ + model: 'mail.channel', + method: data.command ? 'execute_command' : 'message_post', + args: [self._id], + kwargs: messageData, + }).then(function () { + return messageData; + }); + }); + }, + /** + * Unsubscribes from channel + * + * @returns {$.Promise} resolve when unsubscribed + */ + unsubscribe: function () { + if (_.contains(['public', 'private'], this.getType())) { + // unfollow channel + return this._rpc({ + model: 'mail.channel', + method: 'action_unfollow', + args: [[this._id]], + }); + } else { + // unpin livechat + return this._rpc({ + model: 'mail.channel', + method: 'channel_pin', + args: [this.getUUID(), false], + }); + } + }, + /** + * Updates the internal state of the channel, and reflects the changes in + * the UI. + * + * Called by {mail.Manager.Notification} on receiving a channel window + * notification. A notification is received after a client-side action + * that changes the state of the channel window visually, so that cross tab + * channels are updated similarly. + * + * TODO: This method should be dropped if the state of windows are + * completely handled client-side, which should be the case in the near + * future. + * + * @param {Object} params + * @param {boolean} params.detached + * @param {boolean} params.folded + */ + updateWindowState: function (params) { + this._folded = 'folded' in params ? params.folded : this._folded; + this._detached = 'detached' in params ? params.detached : this._detached; + this._warnUpdatedWindowState(); + }, + /** + * Reset the needaction counter to 0. + */ + resetNeedactionCounter: function () { + this._needactionCounter = 0; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {$.Promise} resolved with ID of last seen message + */ + _fetchSeen: function () { + var self = this; + return this._rpc({ + model: 'mail.channel', + method: 'channel_seen', + args: [[this._id]], + }, { + shadow: true + }).then(function (lastSeenMessageID) { + self._lastSeenMessageID = lastSeenMessageID; + return lastSeenMessageID; + }); + }, + /** + * Get the domain to fetch all the messages in the current channel + * + * Note that with moderation, even though some messages are not really + * linked to a channel ('channel_ids'), we should nonetheless display them. + * These messages are fetched from their associated document + * ('mail.channel' with provided channel ID), and messages that are pending + * moderation. + * + * @override + * @private + * @returns {Array} + */ + _getThreadDomain: function () { + return ['|', '&', '&', + ['model', '=', 'mail.channel'], + ['res_id', 'in', [this._id]], + ['need_moderation', '=', true], + ['channel_ids', 'in', [this._id]]]; + }, +}); + +return Channel; + +}); diff --git a/addons/mail/static/src/js/models/dm.js b/addons/mail/static/src/js/models/dm.js new file mode 100644 index 00000000000..cd08dd7526d --- /dev/null +++ b/addons/mail/static/src/js/models/dm.js @@ -0,0 +1,94 @@ +odoo.define('mail.model.DM', function (require) { +"use strict"; + +var Channel = require('mail.model.Channel'); + +/** + * + * Any piece of code in JS that make use of DMs must ideally interact with + * such objects, instead of direct data from the server. + */ +var DM = Channel.extend({ + /** + * @override + * @param {Object} data + * @param {Object} data.direct_partner + * @param {integer} data.direct_partner[0].id + * @param {string} data.direct_partner[0].im_status + * @param {string} data.direct_partner[0].name + */ + init: function (parent, data) { + this._super.apply(this, arguments); + + this._type = 'dm'; + this._name = data.direct_partner[0].name; + this._directPartnerID = data.direct_partner[0].id; + this._status = data.direct_partner[0].im_status; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the direct partner ID linked to the DM, i.e. the partner ID of the + * user at the other end of the DM conversation. All DM do have a direct + * partner iD. + * + * @returns {integer} + */ + getDirectPartnerID: function () { + return this._directPartnerID; + }, + /** + * @override + */ + getPreview: function () { + var result = this._super.apply(this, arguments); + result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_small'; + return result; + }, + /** + * @returns {string} + */ + getStatus: function () { + return this._status; + }, + /** + * DM are chat + * + * @override + * @returns {boolean} + */ + isChat: function () { + return true; + }, + /** + * @param {string} newStatus + */ + setStatus: function (newStatus) { + this._status = newStatus; + }, + /** + * Unpin this DM + * + * @override + * @return {$.Promise} resolve when unpinned + */ + unsubscribe: function () { + return this._rpc({ + model: 'mail.channel', + method: 'channel_pin', + args: [this.getUUID(), false], + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + +}); + +return DM; + +}); diff --git a/addons/mail/static/src/js/models/document_thread.js b/addons/mail/static/src/js/models/document_thread.js new file mode 100644 index 00000000000..b9671107d9d --- /dev/null +++ b/addons/mail/static/src/js/models/document_thread.js @@ -0,0 +1,276 @@ +odoo.define('mail.model.DocumentThread', function (require) { +"use strict"; + +var Thread = require('mail.model.Thread'); + +var session = require('web.session'); + +/** + * These threads represent threads that are directly attached to documents. + * It is sometimes called the "chatter", but technically this is just the + * part of the chatter containing the thread, as the chatter also contains + * the activity and listener fields. + * + * Note that there are still some hacks in the handling of document threads, + * due to document threads are instantiated from fetched messages, either from + * mail.ThreadField or by fetching messages in Inbox. + * + * Also, because messages are not sent on the longpoll bus for this kind of + * threads, it stores the list of message IDs that it contains, which should + * be up-to-date whenever someone wants to get the messages in the document + * thread. + */ +var DocumentThread = Thread.extend({ + + /** + * @override + * @param {string} [data.messagesIDs] the list of message ids linked + * to the document (if not given, they will be fetched before fetching the + * messages) + * @param {string} data.model + * @param {integer} data.resID + */ + init: function (parent, data) { + data.id = data.resModel + '_' + data.resID; + data.type = 'document_thread'; + + this._super.apply(this, arguments); + + // ID of the related document + this._documentID = data.resID; + // model of the related document + this._documentModel = data.resModel; + // used to handle history of messages + this._messageIDs = data.messageIDs || []; + // list of loaded messages + this._messages = []; + + // if the messageIDs haven't been given, fetch them before fetching the + // messages + this._mustFetchMessageIDs = !_.isArray(data.messageIDs); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add this message to this document thread. + * + * @override + * @param {mail.model.Message} message + */ + addMessage: function (message) { + // update internal list of messages + this._messages.push(message); + this._messages = _.sortBy(this._messages, function (msg) { + return msg.getID(); + }); + // update message ids associated to this document thread + if (!_.contains(this._messageIDs, message.getID())) { + this._messageIDs.push(message.getID()); + } + }, + /** + * Get the model name of the document that is linked to this document thread + * + * @returns {string} + */ + getDocumentModel: function () { + return this._documentModel; + }, + /** + * Get the ID of the document that is linked to this document thread + * + * @returns {integer} + */ + getDocumentID: function () { + return this._documentID; + }, + /** + * Get he list of message IDs that this document threads contain. + * + * @returns {integer[]} + */ + getMessageIDs: function () { + return this._messageIDs; + }, + /** + * + * Get messages of the document thread + * + * @override + * @param {Object} options + * @param {boolean} [options.forceFetch] if true, fetch anyway, as user + * clicked on 'load more'. + * @returns {$.Promise} + */ + getMessages: function (options) { + var self = this; + return this._fetchMessages(options).then(function () { + self.call('mail_service', 'markMessagesAsRead', self._messageIDs); + return self._messages; + }); + }, + /** + * States whether the thread is linked to a document + * Document thread's are always linked to a document. + * + * @override + * @returns {boolean} + */ + isLinkedToDocument: function () { + return true; + }, + /** + * Overrides this method so that all the messages of this document thread + * are marked as read on the server. + * + * @override + * @returns {$.Promise} resolved when messages have been marked as read on + * the server. + */ + markAsRead: function () { + this._super.apply(this, arguments); + return this.call('mail_service', 'markMessagesAsRead', this._messageIDs); + }, + /** + * Post message for document thread + * + * @override + * @param {Object} data data related to the new message + * @return {$.Promise} resolved when the message has been sent to + * the server, with the object message that has been sent to the server. + */ + postMessage: function (data) { + var self = this; + var resModel = this.getDocumentModel(); + var resID = this.getDocumentID(); + return this._super.apply(this, arguments) + .then(function (messageData) { + _.extend(messageData, { + context: data.context, + message_type: data.message_type, + subtype: data.subtype, + subtype_id: data.subtype_id, + }); + return self._rpc({ + model: resModel, + method: 'message_post', + args: [resID], + kwargs: messageData, + }) + .then(function (messageID) { + return self._rpc({ + model: 'mail.message', + method: 'message_format', + args: [[messageID]], + }) + .then(function (messages) { + messages[0].model = resModel; + messages[0].res_id = resID; + self.call('mail_service', 'addMessage', messages[0]); + return messages[0]; + }); + }); + }); + }, + /** + * Set list of message IDs of this document thread + * + * Useful in order to handle message history of the document thread, + * in particular to fetch messages when necessary and/or display 'load more'. + * + * @param {integer[]} messageIDs + */ + setMessageIDs: function (messageIDs) { + this._mustFetchMessageIDs = false; + this._messageIDs = messageIDs; + }, + /** + * Set the name of this document thread + * + * This is useful if the name of the document related to the document thread + * has changed + * + * @param {string} newName + */ + setName: function (newName) { + this._name = newName; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Get most up to date messageIDs + * + * @private + * @returns {$.Promise} resolved when message IDs have been fetched and set + * in the model + */ + _fetchMessageIDs: function () { + var self = this; + var resID = this.getDocumentID(); + if (!resID || !this._mustFetchMessageIDs) { + return $.when(); + } + return this._rpc({ + model: this.getDocumentModel(), + method: 'read', + args: [[resID], ['message_ids']], + }).then(function (result) { + self.setMessageIDs(result[0].message_ids); + }); + }, + /** + * @override + * @private + * @param {Object} options + * @param {boolean} [options.forceFetch] + * @returns {$.Promise} resolved when messages have been fetched + document + * thread has updated messages + */ + _fetchMessages: function (options) { + var self = this; + return this._fetchMessageIDs().then(function () { + var messageIDs = self._messageIDs; + var loadedMessages = _.filter(self._messages, function (message) { + return _.contains(messageIDs, message.getID()); + }); + var loadedMessageIDs = _.map(loadedMessages, function (message) { + return message.getID(); + }); + + options = options || {}; + var shouldFetch = _.difference( + messageIDs.slice(0, self._FETCH_LIMIT), + loadedMessageIDs + ).length > 0; + if (options.forceFetch || shouldFetch) { + var idsToLoad = _.difference(messageIDs, loadedMessageIDs) + .slice(0, self._FETCH_LIMIT); + return self._rpc({ + model: 'mail.message', + method: 'message_format', + args: [idsToLoad], + context: session.user_context, + }) + .then(function (messagesData) { + _.each(messagesData, function (messageData) { + self.call('mail_service', 'addMessage', messageData, { silent: true }); + }); + }); + } else { + return $.when(); + } + + }); + }, + +}); + +return DocumentThread; + +}); diff --git a/addons/mail/static/src/js/models/mail_failure.js b/addons/mail/static/src/js/models/mail_failure.js new file mode 100644 index 00000000000..fe6241ff032 --- /dev/null +++ b/addons/mail/static/src/js/models/mail_failure.js @@ -0,0 +1,102 @@ +odoo.define('mail.model.MailFailure', function (require) { +"use strict"; + +var Class = require('web.Class'); +var core = require('web.core'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); +var time = require('web.time'); + +var _t = core._t; + +var MailFailure = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @param {mail.Manager} parent + * @param {Object} data + * @param {string} data.last_message_date + * @param {integer} data.message_id + * @param {string} data.model + * @param {string} data.model_name + * @param {string} data.module_icon + * @param {Array} data.notifications + * @param {string} data.record_name + * @param {integer} data.res_id + * @param {string} data.uuid + */ + init: function (parent, data) { + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + + this._documentID = data.res_id; + this._documentModel = data.model; + this._lastMessageDate = moment(); // by default: current datetime + this._messageID = data.message_id; + this._modelName = data.model_name; + this._moduleIcon = data.module_icon; + this._notifications = data.notifications; + this._recordName = data.record_name; + this._uuid = data.uuid; + + if (data.last_message_date) { + this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date)); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the document model that this mail failure is linked to. + * + * @returns {string} + */ + getDocumentModel: function () { + return this._documentModel; + }, + /** + * Get the document ID that this mail failure is linked to. + * + * @returns {integer} + */ + getDocumentID: function () { + return this._documentID; + }, + /** + * Get the ID of the message that this mail failure is related to. + * + * @returns {integer} + */ + getMessageID: function () { + return this._messageID; + }, + /** + * Get a valid object for the 'mail.preview' template + * + * @returns {Object} + */ + getPreview: function () { + var preview = { + body: _t("An error occured when sending an email"), + date: this._lastMessageDate, + documentID: this._documentID, + documentModel: this._documentModel, + id: 'mail_failure', + imageSRC: this._moduleIcon, + title: this._recordName, + }; + return preview; + }, + /** + * Tell whether the mail failure comes from a message in a document thread. + * + * @returns {boolean} + */ + isLinkedToDocumentThread: function () { + return !!(this._documentModel !== 'mail.channel' && this._documentID); + }, +}); + +return MailFailure; + +}); diff --git a/addons/mail/static/src/js/models/mail_model.js b/addons/mail/static/src/js/models/mail_model.js new file mode 100644 index 00000000000..8f081eb3464 --- /dev/null +++ b/addons/mail/static/src/js/models/mail_model.js @@ -0,0 +1,20 @@ +odoo.define('mail.Model', function (require) { +"use strict"; + +var Class = require('web.Class'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); + +var MailModel = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @param {mail.Manager} parent + */ + init: function (parent) { + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + }, +}); + +return MailModel; + +}); diff --git a/addons/mail/static/src/js/models/mailbox.js b/addons/mail/static/src/js/models/mailbox.js new file mode 100644 index 00000000000..bcb5ae609b7 --- /dev/null +++ b/addons/mail/static/src/js/models/mailbox.js @@ -0,0 +1,213 @@ +odoo.define('mail.model.Mailbox', function (require) { +"use strict"; + +var ThreadWithCache = require('mail.model.ThreadWithCache'); + +var core = require('web.core'); + +var _t = core._t; + +/** + * Mailboxes, called "static channels" in earlier version of Odoo, are threads + * that are not represented on the server. We can see them as "threads that are + * not conversations". For instance, the well-known "Inbox" folder contains a + * list of messages, but the inbox does not represent a conversation: Inbox is + * modeled as a mailbox. + */ +var Mailbox = ThreadWithCache.extend({ + + /** + * @override + * @param {Object} data + * @param {string} data.id the ID of the mailbox, without the 'mailbox_' + * prefix. + * @param {integer} [data.mailboxCounter=0] the initial mailbox counter of + * this mailbox. + */ + init: function (parent, data) { + // ID or mailboxes are always prefixed with 'mailbox_' + data.id = 'mailbox_' + data.id; + data.type = 'mailbox'; + this._super.apply(this, arguments); + this._mailboxCounter = data.mailboxCounter || 0; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Decrement the counter of this mailbox. It floors to 0. + * + * @param {integer} [num=1] how many, at most, the counter is decremented + */ + decrementMailboxCounter: function (num) { + num = _.isNumber(num) ? num : 1; + this._mailboxCounter = Math.max(this._mailboxCounter - num, 0); + }, + /** + * Get the local messages of the mailbox (by local messages, we mean + * messages that have already been fetched from the server). + * + * It is possible to filter on local messages that are specific to a + * document with the `options` parameter. + * + * @param {Object} [options] + * @param {string} [options.documentModel] model of the document that the + * local messages of inbox must be linked to. + * @param {integer} [options.documentID] ID of the document that the local + * messages of inbox must be linked to. + */ + getLocalMessages: function (options) { + var localMessages = this._cache['[]'].messages; + if (!options) { + return localMessages; + } + if (options.documentModel && options.documentID) { + return _.filter(localMessages, function (localMessage) { + return localMessage.getDocumentModel() === options.documentModel && + localMessage.getDocumentID() === options.documentID; + }); + } + }, + /** + * Get the mailbox counter of this mailbox. + * + * @returns {integer} + */ + getMailboxCounter: function () { + return this._mailboxCounter; + }, + /** + * Get the preview of the messages from the inbox mailbox. + * If there are multiple messages associated to a similar document or + * channel, only keep the lastest message of this document/channel in the + * previews. + * + * @returns {$.Promise} + */ + getMessagePreviews: function () { + var self = this; + + return this.getMessages().then(function (messages) { + // pick only last message of chatter + // items = list of objects + // { unreadCounter: integer, message: mail.model.Message } + var items = []; + _.each(messages, function (message) { + var unreadCounter = 1; + var similarItem = _.find(items, function (item) { + return self._areMessagesFromSameDocumentThread(item.message, message) || + self._areMessagesFromSameChannel(item.message, message); + }); + if (similarItem) { + unreadCounter = similarItem.unreadCounter + 1; + var index = _.findIndex(items, similarItem); + items[index] = { + unreadCounter: unreadCounter, + message: message, + }; + } else { + items.push({ + unreadCounter: unreadCounter, + message: message, + }); + } + }); + return _.map(items, function (item) { + return _.extend(item.message.getPreview(), { + unreadCounter: item.unreadCounter, + }); + }); + }); + }, + /** + * Increment the counter of this mailbox + * + * @param {integer} [num=1] how many, the counter is incremented + */ + incrementMailboxCounter: function (num) { + num = _.isNumber(num) ? num : 1; + this._mailboxCounter = Math.max(this._mailboxCounter + num, 0); + }, + /** + * Marks all messages from the mailbox as read. At the moment, this method + * makes only sense for 'Inbox'. + * + * @param {Array} domain + * @return {$.Promise} resolved when all messages have been marked as read + * on the server + */ + markAllMessagesAsRead: function (domain) { + if (this._id === 'mailbox_inbox' && this.getMailboxCounter() > 0) { + return this._rpc({ + model: 'mail.message', + method: 'mark_all_as_read', + kwargs: { + channel_ids: [], + domain: domain, + }, + }); + } + return $.when(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Tell whether the two messages originate from the same channel. + * If any one of them does not originate from a channel, returns false. + * + * @private + * @param {mail.model.Message} message1 + * @param {mail.model.Message} message2 + * @returns {boolean} + */ + _areMessagesFromSameChannel: function (message1, message2) { + return message1.originatesFromChannel() && + message2.originatesFromChannel() && + message1.getOriginChannelID() === message2.getOriginChannelID(); + }, + /** + * Tell whether the two messages are linked to the same document thread. + * If any one of them is not linked to a document, returns false. + * + * @private + * @param {mail.model.Message} message1 + * @param {mail.model.Message} message2 + * @returns {boolean} + */ + _areMessagesFromSameDocumentThread: function (message1, message2) { + return message1.isLinkedToDocumentThread() && + message2.isLinkedToDocumentThread() && + message1.getDocumentModel() === message2.getDocumentModel() && + message1.getDocumentID() === message2.getDocumentID(); + }, + /** + * Get the domain to fetch all the messages in the current mailbox + * + * @override + * @private + * @returns {Array} + * @throws on missing domain for the provided mailbox (mailboxes should + * always have a domain on messages to fetch) + */ + _getThreadDomain: function () { + if (this._id === 'mailbox_inbox') { + return [['needaction', '=', true]]; + } else if (this._id === 'mailbox_starred') { + return [['starred', '=', true]]; + } else if (this._id === 'mailbox_moderation') { + return [['need_moderation', '=', true]]; + } else { + throw _t(_.str("Missing domain for mailbox with ID '%s'", this._id)); + } + }, + +}); + +return Mailbox; + +}); diff --git a/addons/mail/static/src/js/models/messages/abstract_message.js b/addons/mail/static/src/js/models/messages/abstract_message.js new file mode 100644 index 00000000000..4cdb2273008 --- /dev/null +++ b/addons/mail/static/src/js/models/messages/abstract_message.js @@ -0,0 +1,409 @@ +odoo.define('mail.model.AbstractMessage', function (require) { +"use strict"; + +var mailUtils = require('mail.utils'); + +var Class = require('web.Class'); +var core = require('web.core'); +var session = require('web.session'); +var time = require('web.time'); + +var _t = core._t; + +/** + * This is an abstract class for modeling messages in JS. + * The purpose of this interface is to make im_livechat compatible with + * mail.widget.Thread, as this widget was designed to work with messages that + * are instances of mail.model.Messages. + * + * Ideally, im_livechat should also handle mail.model.Message, but this is not + * feasible for the moment, as mail.model.Message requires mail.Manager to work, + * and this module should not leak outside of the backend, hence the use of + * mail.model.AbstractMessage as a work-around. + */ +var AbstractMessage = Class.extend({ + + /** + * @param {Widget} parent + * @param {Object} data + * @param {Array} [data.attachment_ids=[]] + * @param {Array} [data.author_id] + * @param {string} [data.body = ""] + * @param {string} [data.date] the server-format date time of the message. + * If not provided, use current date time for this message. + * @param {integer} data.id + * @param {boolean} [data.is_discussion = false] + * @param {boolean} [data.is_notification = false] + * @param {string} [data.message_type = undefined] + */ + init: function (parent, data) { + this._attachmentIDs = data.attachment_ids || []; + this._body = data.body || ""; + // by default: current datetime + this._date = data.date ? moment(time.str_to_datetime(data.date)) : moment(); + this._id = data.id; + this._isDiscussion = data.is_discussion; + this._isNotification = data.is_notification; + // if set, the message should display the author of the message + this._displayAuthor = false; + this._serverAuthorID = data.author_id; + this._type = data.message_type || undefined; + + this._processAttachmentURL(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Set whether the message should display the author or not + * + * @param {boolean} bool if set, display the author of this message + */ + displayAuthor: function (bool) { + this._displayAuthor = bool; + }, + /** + * Get the list of files attached to this message. + * Note that attachments are stored with server-format + * + * @return {Object[]} + */ + getAttachments: function () { + return this._attachmentIDs; + }, + /** + * Get the server ID (number) of the author of this message + * If there are no author, return -1; + * + * @return {integer} + */ + getAuthorID: function () { + if (!this.hasAuthor()) { + return -1; + } + return this._serverAuthorID[0]; + }, + /** + * Get the relative url of the avatar to display next to the message + * + * @abstract + * @return {string} + */ + getAvatarSource: function () {}, + /** + * Get the body content of this message + * + * @return {string} + */ + getBody: function () { + return this._body; + }, + /** + * @return {moment} + */ + getDate: function () { + return this._date; + }, + /** + * Get the date day of this message + * + * @return {string} + */ + getDateDay: function () { + var date = this.getDate().format('YYYY-MM-DD'); + if (date === moment().format('YYYY-MM-DD')) { + return _t("Today"); + } else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) { + return _t("Yesterday"); + } + return this.getDate().format('LL'); + }, + /** + * Get the name of the author, if there is an author of this message + * If there are no author of this message, returns 'null' + * + * @return {string} + */ + getDisplayedAuthor: function () { + return this.hasAuthor() ? this._getAuthorName() : null; + }, + /** + * Get the server ID (number) of this message + * + * @override + * @return {integer} + */ + getID: function () { + return this._id; + }, + /** + * Get the list of images attached to this message. + * Note that attachments are stored with server-format + * + * @return {Object[]} + */ + getImageAttachments: function () { + return _.filter(this.getAttachments(), function (attachment) { + return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image'; + }); + }, + /** + * Get the list of non-images attached to this message. + * Note that attachments are stored with server-format + * + * @return {Object[]} + */ + getNonImageAttachments: function () { + return _.difference(this.getAttachments(), this.getImageAttachments()); + }, + /** + * Get the time elapsed between sent message and now + * + * @return {string} + */ + getTimeElapsed: function () { + return mailUtils.timeFromNow(this.getDate()); + }, + /** + * Get the type of message (e.g. 'comment', 'email', 'notification', ...) + * By default, messages are of type 'undefined' + * + * @override + * @return {string|undefined} + */ + getType: function () { + return this._type; + }, + /** + * State whether this message contains some attachments. + * + * @override + * @return {boolean} + */ + hasAttachments: function () { + return this.getAttachments().length > 0; + }, + /** + * State whether this message has an author + * + * @return {boolean} + */ + hasAuthor: function () { + return !!(this._serverAuthorID && this._serverAuthorID[0]); + }, + /** + * State whether this message contains some customer email data + * By default, messages do not have any customer email data + * + * @return {boolean} + */ + hasCustomerEmailData: function () { + return false; + }, + /** + * State whether this message has an email of its sender. + * By default, messages do not have any email of its sender. + * + * @return {string} + */ + hasEmailFrom: function () { + return false; + }, + /** + * State whether this image contains images attachments + * + * @return {boolean} + */ + hasImageAttachments: function () { + return _.some(this.getAttachments(), function (attachment) { + return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image'; + }); + }, + /** + * State whether this image contains non-images attachments + * + * @return {boolean} + */ + hasNonImageAttachments: function () { + return _.some(this.getAttachments(), function (attachment) { + return !(attachment.mimetype && attachment.mimetype.split('/')[0] === 'image'); + }); + }, + /** + * State whether this message originates from a channel. + * By default, messages do not originate from a channel. + * + * @override + * @return {boolean} + */ + originatesFromChannel: function () { + return false; + }, + /** + * State whether this message has a subject + * By default, messages do not have any subject. + * + * @return {boolean} + */ + hasSubject: function () { + return false; + }, + /** + * By default, messages do not have any subtype description + * + * @return {boolean} + */ + hasSubtypeDescription: function () { + return false; + }, + /** + * State whether this message contains some tracking values + * By default, messages do not have any tracking values. + * + * @return {boolean} + */ + hasTrackingValues: function () { + return false; + }, + /** + * State whether this message is a discussion + * + * @return {boolean} + */ + isDiscussion: function () { + return this._isDiscussion; + }, + /** + * State whether this message is linked to a document + * By default, messages are not linked to a document. + * + * @return {boolean} + */ + isLinkedToDocument: function () { + return false; + }, + /** + * State whether this message is needaction + * By default, messages are not needaction. + * + * @return {boolean} + */ + isNeedaction: function () { + return false; + }, + /** + * State whether this message is a note (i.e. a message from "Log note") + * + * @return {boolean} + */ + isNote: function () { + return this._isNote; + }, + /** + * State whether this message is a notification + * + * User notifications are defined as either + * - notes + * - pushed to user Inbox or email through classic notification process + * - not linked to any document, meaning model and res_id are void + * + * This is useful in order to display white background for user + * notifications in chatter + * + * @returns {boolean} + */ + isNotification: function () { + return this._isNotification; + }, + /** + * State whether this message is starred + * By default, messages are not starred. + * + * @return {boolean} + */ + isStarred: function () { + return false; + }, + /** + * State whether this message is a system notification + * By default, messages are not system notifications + * + * @override + * @return {boolean} + */ + isSystemNotification: function () { + return false; + }, + /** + * States whether the current message needs moderation in general. + * By default, messages do not require any moderation. + * + * @returns {boolean} + */ + needsModeration: function () { + return false; + }, + /** + * State whether this message should display the author + * + * @return {boolean} + */ + shouldDisplayAuthor: function () { + return this._displayAuthor; + }, + /** + * State whether this message should redirect to the author + * when clicking on the author of this message. + * + * Do not redirect on author clicked of self-posted messages. + * + * @return {boolean} + */ + shouldRedirectToAuthor: function () { + return !this._isAuthor(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Get the name of the author of this message. + * If there are no author of this messages, returns '' (empty string). + * + * @private + * @returns {string} + */ + _getAuthorName: function () { + if (!this.hasAuthor()) { + return ""; + } + return this._serverAuthorID[1]; + }, + /** + * State whether the current user is the author of this message + * + * @private + * @return {boolean} + */ + _isAuthor: function () { + return this.hasAuthor() && (this.getAuthorID() === session.partner_id); + }, + /** + * Compute url of attachments of this message + * + * @private + */ + _processAttachmentURL: function () { + _.each(this.getAttachments(), function (attachment) { + attachment.url = '/web/content/' + attachment.id + '?download=true'; + }); + }, + +}); + +return AbstractMessage; + +}); diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js new file mode 100644 index 00000000000..06b776ea580 --- /dev/null +++ b/addons/mail/static/src/js/models/messages/message.js @@ -0,0 +1,780 @@ +odoo.define('mail.model.Message', function (require) { +"use strict"; + +var emojis = require('mail.emojis'); +var AbstractMessage = require('mail.model.AbstractMessage'); +var mailUtils = require('mail.utils'); + +var core = require('web.core'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); +var session = require('web.session'); + +var _t = core._t; + +/** + * This is the main class for the modeling messages in JS. + * Such messages are stored in the mail manager, and any piece of JS code whose + * logic relies on threads must ideally interact with such objects. + */ +var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @override + * @param {web.Manager} parent + * @param {Object} data + * @param {string} [data.body = ""] + * @param {(string|integer)[]} [data.channel_ids] + * @param {Object[]} [data.customer_email_data] + * @param {string} [data.customer_email_status] + * @param {string} [data.email_from] + * @param {string} [data.info] + * @param {string} [data.model] + * @param {string} [data.moderation_status='accepted'] + * @param {string} [data.module_icon] + * @param {Array} [data.needaction_partner_ids = []] + * @param {string} [data.record_name] + * @param {integer} [data.res_id] + * @param {Array} [data.starred_partner_ids = []] + * @param {string} [data.subject] + * @param {string} [data.subtype_description] + * @param {Object[]} [data.tracking_value_ids] + * @param {Object[]} emojis + */ + init: function (parent, data, emojis) { + this._super.apply(this, arguments); + + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + + this._customerEmailData = data.customer_email_data || []; + this._customerEmailStatus = data.customer_email_status; + this._documentModel = data.model; + this._documentName = data.record_name; + this._documentID = data.res_id; + this._emailFrom = data.email_from; + this._info = data.info; + this._moduleIcon = data.module_icon; + this._needactionPartnerIDs = data.needaction_partner_ids || []; + this._starredPartnerIDs = data.starred_partner_ids || []; + this._subject = data.subject; + this._subtypeDescription = data.subtype_description; + this._threadIDs = data.channel_ids || []; + this._trackingValueIDs = data.tracking_value_ids; + + this._moderationStatus = data.moderation_status || 'accepted'; + + this._processBody(emojis); + this._processMailboxes(); + this._processModeration(); + this._processDocumentThread(); + this._processTrackingValues(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {Object} data + */ + addCustomerEmailData: function (data) { + this._customerEmailData.push(data); + }, + /** + * Get the name of the author of this message + * If there are no author, return "". + * + * @return {string} + */ + getAuthorName: function () { + return this._getAuthorName(); + }, + /** + * Get the relative url of the avatar to display next to the message + * + * @override + * @return {string} + */ + getAvatarSource: function () { + if (this._isOdoobotAuthor()) { + return '/mail/static/src/img/odoo_o.png'; + } else if (this.hasAuthor()) { + return '/web/image/res.partner/' + this.getAuthorID() + '/image_small'; + } else if (this.getType() === 'email') { + return '/mail/static/src/img/email_icon.png'; + } + return '/mail/static/src/img/smiley/avatar.jpg'; + }, + /** + * Get the customer email data of this email, if any. + * If this message has no such data, returns 'undefined' + * + * @return {Object[]|undefined} + */ + getCustomerEmailData: function () { + if (!this.hasCustomerEmailData()) { + return undefined; + } + return this._customerEmailData; + }, + /** + * Get the customer email status of this email, if any. + * If this message has no such data, returns 'undefined' + * + * @return {string|undefined} + */ + getCustomerEmailStatus: function () { + if (!this.hasCustomerEmailData()) { + return undefined; + } + return this._customerEmailStatus; + }, + /** + * Get the text to display for the author of the message + * + * Rule of precedence for the displayed author: + * + * author name > sender email > "anonymous" + * + * @override + * @return {string} + */ + getDisplayedAuthor: function () { + var res = this._super.apply(this, arguments); + if (res) { + return res; + } else { + return this.hasEmailFrom() ? this.getEmailFrom() : + _t("Anonymous"); + } + }, + /** + * Get the ID of the document that this message is linked. + * + * @override + * @return {integer} + */ + getDocumentID: function () { + return this._documentID; + }, + /** + * Get the model of the document that this message is linked. + * If this message is not linked to a document, returns "". + * + * @override + * @return {string} + */ + getDocumentModel: function () { + return this._documentModel; + }, + /** + * Get the name of the document that this message is linked. + * If this message is not linked to a document, returns "". + * + * It uses the name of an existing document thread, in order to have the + * updated document name. If there is no document thread yet, use the + * currently aware document name. + * + * As a result, this method should ideally be called internally, in order + * to avoid having different names for messages linked to a same modified + * document. + * + * @override + * @return {string} + */ + getDocumentName: function () { + if (!this.isLinkedToDocumentThread()) { + return ""; + } + var model = this.getDocumentModel(); + var id = this.getDocumentID(); + var documentThread = this.call('mail_service', 'getDocumentThread', model, id); + if (documentThread) { + this._documentName = documentThread.getName(); + } + return this._documentName; + }, + /** + * Get the email of the sender of this message. + * If this email has no sender email, returns "". + * + * @return {string} + */ + getEmailFrom: function () { + if (!this.hasEmailFrom()) { + return ""; + } + return this._emailFrom; + }, + /** + * Get the ID of the channel that this message originates from. + * If this message does not originate from a channel, returns `-1`. + * + * @override + * @return {integer} + */ + getOriginChannelID: function () { + if (!this.originatesFromChannel()) { + return -1; + } + return this._documentID; + }, + /** + * Get the name of the channel that this message originates from. + * If this message does not originate from a channel, returns "". + * + * @override + * @returns {string} + */ + getOriginChannelName: function () { + if (!this.originatesFromChannel()) { + return ""; + } + var originChannelID = this.getOriginChannelID(); + var channel = originChannelID && this.call( + 'mail_service', + 'getChannel', + originChannelID); + if (!channel) { + return ""; + } + return channel.getName(); + }, + /** + * Returns the information required to render the preview of this channel. + * + * @return {Object} + */ + getPreview: function () { + var id, title; + if (this.isLinkedToDocumentThread()) { + id = this.getDocumentModel() + '_' + this.getDocumentID(); + title = this.getDocumentName(); + } else { + id = 'mailbox_inbox'; + title = this.hasSubject() ? this.getSubject() : this.getDisplayedAuthor(); + } + return { + author: this.getDisplayedAuthor(), + body: mailUtils.parseAndTransform(this.getBody(), mailUtils.inline), + date: this.getDate(), + documentModel: this.getDocumentModel(), + documentID: this.getDocumentID(), + id: id, + imageSRC: this._getModuleIcon() || this.getAvatarSource(), + status: this.status, + title: title, + }; + }, + /** + * Get the subject of this message + * If this message has no subject, returns "". + * + * @override + * @return {string} + */ + getSubject: function () { + if (!this.hasSubject()) { + return ""; + } + return this._subject; + }, + /** + * @override + * @return {string} + */ + getSubtypeDescription: function () { + return this._subtypeDescription; + }, + /** + * Get the list of thread IDs that this message is linked to + * If this message is not linked to a thread, returns 'undefined' + * + * @return {string[]|undefined} list of thread IDs, if any + */ + getThreadIDs: function () { + return this._threadIDs; + }, + /** + * Get the tracking values of this message + * If this message has no tracking values, returns 'undefined' + * + * @override + * @return {Object[]|undefined} + */ + getTrackingValues: function () { + if (!this.hasTrackingValues()) { + return undefined; + } + return this._trackingValueIDs; + }, + /** + * @override + * @return {string} + */ + getURL: function () { + return session.url('/mail/view?message_id=' + this._id); + }, + /** + * State whether this message contains some customer email data + * + * @override + * @return {boolean} + */ + hasCustomerEmailData: function () { + return !!(this._customerEmailData && (this._customerEmailData.length > 0)); + }, + /** + * State whether this message has an email of its sender. + * + * @override + * @return {string} + */ + hasEmailFrom: function () { + return !!(this._emailFrom); + }, + /** + * State whether this message has a subject + * + * @return {boolean} + */ + hasSubject: function () { + return !!(this._subject); + }, + /** + * @override + * @return {boolean} + */ + hasSubtypeDescription: function () { + return !!(this._subtypeDescription); + }, + /** + * State whether this message contains some tracking values + * + * @override + * @return {boolean} + */ + hasTrackingValues: function () { + return !!(this._trackingValueIDs && (this._trackingValueIDs.length > 0)); + }, + /** + * State whether the current user is the author of this message + * + * @return {boolean} + */ + isAuthor: function () { + return this._isAuthor(); + }, + /** + * State whether this message is linked to a document thread (not channel) + * + * Usually, if this is true, then this message comes from a document thread, + * but the document model could be a channel. In that case, the document + * resID tells the channel that this message originally comes from. + * + * To detect whether the message comes from a channel, see the method + * 'originatesFromChannel'. + * + * @override + * @return {boolean} + */ + isLinkedToDocumentThread: function () { + return !!(this._documentModel !== 'mail.channel' && this._documentID); + }, + /** + * States whether the current message needs moderation in general. + * + * @override + * @returns {boolean} + */ + needsModeration: function () { + return this._moderationStatus === 'pending_moderation'; + }, + /** + * States whether the current message needs moderation by the current user. + * Such a message should be in the moderation mailbox. + * + * @returns {boolean} + */ + needsModerationByUser: function () { + return _.contains(this._threadIDs, 'mailbox_moderation'); + }, + /** + * State whether this message is needaction + * + * @override + * @returns {boolean} + */ + isNeedaction: function () { + return _.contains(this._threadIDs, 'mailbox_inbox'); + }, + /** + * State whether this message is starred + * + * @override + * @returns {boolean} + */ + isStarred: function () { + return _.contains(this._threadIDs, 'mailbox_starred'); + }, + /** + * State whether this message is a system notification + * + * @override + * @returns {boolean} + */ + isSystemNotification: function () { + return ( + this.getType() === 'notification' && + this._documentModel === 'mail.channel' + ) || this._isTransient(); + }, + /** + * States whether the message originates from a channel or not + * + * @returns {boolean} + */ + originatesFromChannel: function () { + return this._documentModel === 'mail.channel'; + }, + /** + * Unregister thread with ID `threadID` from this message + * + * @param {string|integer} threadID ID of thread + */ + removeThread: function (threadID) { + this._threadIDs = _.without(this._threadIDs, threadID); + }, + /** + * Update the moderation status of the message, so that it is now accepted + * or rejected. When the message is accepted, it may be linked to more + * threads, which is the case for relay channels on moderated channels. + * + * @param {string} newModerationStatus ['accepted', 'rejected'] + * @param {Object} [options] + * @param {Object} [options.additionalThreadIDs] contains additional thread + * IDs to be registered on the message. + */ + setModerationStatus: function (newModerationStatus, options) { + var self = this; + this._moderationStatus = newModerationStatus; + if (newModerationStatus === 'accepted' && options) { + _.each(options.additionalThreadIDs, function (threadID) { + self._addThread(threadID); + }); + } + this._warnMessageModerated(); + }, + /** + * Set whether the message is starred or not. + * If it is starred, the message is moved to the "Starred" mailbox. + * Note that this function only applies it locally, the server is not aware + * + * @param {boolean} starred if set, the message is starred + */ + setStarred: function (starred) { + if (starred) { + this._addThread('mailbox_starred'); + } else { + this.removeThread('mailbox_starred'); + } + }, + /** + * State whether this message should display the subject + * + * @return {boolean} + */ + shouldDisplaySubject: function () { + return this.hasSubject() && + this.getType() !== 'notification' && + !this.originatesFromChannel(); + }, + /** + * State whether this message should redirect to the author + * when clicking on the author of this message. + * + * Do not redirect on author clicked of self-posted or Odoobot messages + * (note: Odoobot is the default author of transient messages) + * + * @override + * @return {boolean} + */ + shouldRedirectToAuthor: function () { + return this._super.apply(this, arguments) && !this._isOdoobotAuthor(); + }, + /** + * Toggle the star status of the message + * + * It relies on the star status of the message from the date of the server. + * The star status is updated from a 'toggle_star' notification on the + * longpoll bus + * + * @see {mail.Manager.Notification} for the receipt of 'toggle_star' + * notification after this rpc. + * + * @return {$.Promise} + */ + toggleStarStatus: function () { + return this._rpc({ + model: 'mail.message', + method: 'toggle_message_starred', + args: [[this._id]], + }); + }, + /** + * Update the customer email status + * + * @param {string} newCustomerEmailStatus + */ + updateCustomerEmailStatus: function (newCustomerEmailStatus) { + this._customerEmailStatus = newCustomerEmailStatus; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Register a thread to the message + * Useful when you mark a message as 'to do'. + * This message will be available in 'Starred' mailbox. + * + * @private + * @param {string|integer} threadID + */ + _addThread: function (threadID) { + if (!this._threadIDs) { + this._threadIDs = []; + } + this._threadIDs.push(threadID); + this._threadIDs = _.uniq(this._threadIDs); + }, + /** + * Get the name of the author of this message. + * If Odoobot is the author (default author for transient messages), + * returns 'Odoobot'. + * + * @override + * @private + * @returns {string} + */ + _getAuthorName: function () { + if (this._isOdoobotAuthor()) { + return "Odoobot"; + } + return this._super.apply(this, arguments); + }, + /** + * @private + * @return {string} + */ + _getModuleIcon: function () { + return this._moduleIcon; + }, + /** + * State if the author of this message is Odoobot + * This is the default author for transient messages. + * + * @private + * @return {boolean} + */ + _isOdoobotAuthor: function () { + return this._serverAuthorID === this.call('mail_service', 'getOdoobotID'); + }, + /** + * State whether the message is transient or not + * + * @private + * @return {boolean} + */ + _isTransient: function () { + return this._info === 'transient_message'; + }, + /** + * Convert the server-format body of the message to the client-format. + * Basically, it processes emojis and url. + * + * @private + */ + _processBody: function () { + var self = this; + + _.each(emojis, function (emoji) { + var unicode = emoji.unicode; + var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|)", 'g'); + var originalBody = self.body; + self._body = self._body.replace(regexp, + ' ' + unicode + ' '); + // Idiot-proof limit. If the user had the amazing idea of + // copy-pasting thousands of emojis, the image rendering can lead + // to memory overflow errors on some browsers (e.g. Chrome). Set an + // arbitrary limit to 200 from which we simply don't replace them + // (anyway, they are already replaced by the unicode counterpart). + if (_.str.count(self._body, 'o_mail_emoji') > 200) { + self._body = originalBody; + } + }); + + // add anchor tags to urls + self._body = mailUtils.parseAndTransform(self._body, mailUtils.addLink); + }, + /** + * If the message is linked to a document, link this message to the related + * document thread. If there is no document thread yet, create it + */ + _processDocumentThread: function () { + if (!this.isLinkedToDocumentThread()) { + return; + } + var resModel = this.getDocumentModel(); + var resID = this.getDocumentID(); + var documentThread = this.call('mail_service', 'getOrAddDocumentThread', { + resModel: resModel, + resID: resID, + name: this.getDocumentName(), + }); + this._threadIDs.push(documentThread.getID()); + }, + /** + * Set the appropriate mailboxes to this message based on server data + * + * @private + */ + _processMailboxes: function () { + if (_.contains(this._needactionPartnerIDs, session.partner_id)) { + this._setNeedaction(true); + } + if (_.contains(this._starredPartnerIDs, session.partner_id)) { + this.setStarred(true); + } + if ( + this.originatesFromChannel() && + _.contains( + this.call('mail_service', 'getModeratedChannelIDs'), + this.getOriginChannelID() + ) && + this.needsModeration() + ) { + this._setModeratedByUser(true); + } + }, + /** + * Do some extra processing at message init, related to the + * moderated status of the message. + * + * If the message needs moderation, it is not yet linked to the + * moderated channel server-side. Therefore, it is not registered + * in the list of threadIDs, as it is built on server-side information + * at message initialisation (using data.channel_ids). + * + * Since the web client uses the list of thread IDs to show visually the + * message in a thread, we should hack the response of the server so that + * it assumes the message really belongs to this thread. + * + * @private + */ + _processModeration: function () { + if (this.needsModeration()) { + // the message is not linked to the moderated channel on the + // server, therefore this message has not this channel in + // channel_ids. Here, just to show this message in the channel + // visually, it links this message to the channel + this._threadIDs.push(this.getOriginChannelID()); + } + }, + /** + * Process the tracking values on message creation, which + * basically format date to the local only once by message + * + * Cannot be done in preprocess, since it alter the original value + * + * @private + */ + _processTrackingValues: function () { + if (this.hasTrackingValues()) { + _.each(this.getTrackingValues(), function (trackingValue) { + if (trackingValue.field_type === 'datetime') { + if (trackingValue.old_value) { + trackingValue.old_value = + moment + .utc(trackingValue.old_value) + .local() + .format('LLL'); + } + if (trackingValue.new_value) { + trackingValue.new_value = + moment + .utc(trackingValue.new_value) + .local() + .format('LLL'); + } + } else if (trackingValue.field_type === 'date') { + if (trackingValue.old_value) { + trackingValue.old_value = + moment(trackingValue.old_value) + .local() + .format('LL'); + } + if (trackingValue.new_value) { + trackingValue.new_value = + moment(trackingValue.new_value) + .local() + .format('LL'); + } + } + }); + } + }, + /** + * Set whether the message is moderated by current user or not. + * If it is moderated by the current user, the message is moved to the + * "Moderation" mailbox. Note that this function only applies it locally, + * the server is not aware + * + * @private + * @param {boolean} moderated if set, the message is moderated by user + */ + _setModeratedByUser: function (moderated) { + if (moderated) { + this._addThread('mailbox_moderation'); + } else { + this.removeThread('mailbox_moderation'); + } + }, + /** + * Set whether the message is needaction or not. + * If it is needaction, the message is moved to the "Inbox" mailbox. + * Note that this function only applies it locally, the server is not aware + * + * @private + * @param {boolean} needaction if set, the message is needaction + */ + _setNeedaction: function (needaction) { + if (needaction) { + this._addThread('mailbox_inbox'); + } else { + this.removeThread('mailbox_inbox'); + } + }, + /** + * @private + */ + _warnMessageModerated: function () { + var mailBus = this.call('mail_service', 'getMailBus'); + if (this.needsModerationByUser()) { + var moderationBox = this.call('mail_service', 'getMailbox', 'moderation'); + moderationBox.decrementMailboxCounter(); + moderationBox.removeMessage(this.getID()); + mailBus.trigger('update_moderation_counter'); + } + if (this._moderationStatus !== 'accepted') { + this.call('mail_service', 'removeMessageFromThreads', this); + } + mailBus.trigger('update_message', this); + }, + +}); + +return Message; + +}); diff --git a/addons/mail/static/src/js/models/thread.js b/addons/mail/static/src/js/models/thread.js new file mode 100644 index 00000000000..de2c6628d67 --- /dev/null +++ b/addons/mail/static/src/js/models/thread.js @@ -0,0 +1,446 @@ +odoo.define('mail.model.Thread', function (require) { +"use strict"; + +var emojis = require('mail.emojis'); +var mailUtils = require('mail.utils'); + +var Class = require('web.Class'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); + +/** + * This is the super class modeling threads in JS. + * Such threads are stored in the mail manager, and any piece of JS code whose + * logic relies on threads must ideally interact with such objects. + * + * In particular, channels and mailboxes are two different kinds of threads. + */ +var Thread = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @param {mail.Manager} parent + * @param {Object} data + * @param {string} [data.channel_type] + * @param {integer|string} data.id + * @param {string} data.name + * @param {string} [data.type] + */ + init: function (parent, data) { + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + + this._id = data.id; + + // threads are not detached by default + this._detached = false; + // threads are unfolded by default + this._folded = false; + // if this._massMailing is set, display subject on messages, use + // extended composer and show "Send by messages by email" on discuss + // sidebar + this._massMailing = false; + this._name = data.name; + // on 1st request to getPreview, fetch data if incomplete. Otherwise it + // means that there is no message in this channel. + this._previewed = false; + this._type = data.type || data.channel_type; + // any new message that has not been read yet + this._unreadCounter = 0; + // max number of fetched messages from the server + this._FETCH_LIMIT = 30; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a message to this thread. + * + * @abstract + * @param {mail.model.Message} message + */ + addMessage: function (message) {}, + /** + * Updates the _detached state of the thread. Must be overriden to reflect + * the new state in the interface. + */ + close: function () { + this._detached = false; + this._folded = false; + this._warnUpdatedWindowState(); + }, + /** + * Updates the _detached state of the thread. Must be overriden to reflect + * the new state in the interface. + * + * @param {Object} [options={}] + * @param {boolean} [options.keepFoldState=false] if set, keep the fold state + * of this thread. Otherwise unfold it while detaching it. + * @param {boolean} [options.passively=false] if set, if the thread window + * will be created passively. + */ + detach: function (options) { + options = options || {}; + this._detached = true; + this._folded = options.keepFoldState ? this._folded : false; + this._warnUpdatedWindowState({ + passively: options.passively, + }); + }, + /** + * Updates the _folded state of the thread. Must be overriden to reflect + * the new state in the interface. + */ + fold: function (folded) { + this._detached = true; + this._folded = folded; + this._warnUpdatedWindowState(); + }, + /** + * Get the list of available commands for the thread. + * By default, threads do not have any available command. + * + * @returns {Array} + */ + getCommands: function () { + return []; + }, + /** + * Get the ID of the thread + * + * @returns {integer|string} string for mailboxes (e.g. 'mailbox_inbox') + */ + getID: function () { + return this._id; + }, + /** + * Get the listeners of the thread. + * By default, a thread has not listener. + * + * @abstract + * @returns {$.Promise} + */ + getMentionPartnerSuggestions: function () { + return $.when([]); + }, + /** + * Get the list of messages in this thread. + * By default, a thread has no messages. + * + * Note that this method only returns some messages, as we do not want to + * fetch all messages of a thread at once, just to read the last message. + * + * As a result, this method fetches only some messages of the thread, + * starting from the last message in the thread. At most, it fetches + * `this.LIMIT` number of messages at a time. + * + * @abstract + * @returns {$.Promise} + */ + getMessages: function () { + return $.when([]); + }, + /** + * Get the name of this thread + * + * @returns {string|Object} object for lazy translated names with _lt + */ + getName: function () { + return this._name; + }, + /** + * Returns the information required to render the preview of this channel. + * + * @returns {Object} a valid object for the rendering of previews + * (@see mail.Preview template) + */ + getPreview: function () { + return { + id: this.getID(), + imageSRC: '/mail/static/src/img/smiley/avatar.jpg', + isChat: this.isChat(), + status: this.getStatus(), + title: this.getName(), + unreadCounter: this.getUnreadCounter(), + }; + }, + /** + * Get the status of the thread (e.g. 'online', 'offline', etc.) + * + * @returns {string} + */ + getStatus: function () { + return this._status; + }, + /** + * Returns the title to display in thread window's headers. + * + * @returns {string|Object} the name of the thread by default (see @getName) + */ + getTitle: function () { + return this.getName(); + }, + /** + * @returns {string} + */ + getType: function () { + return this._type; + }, + /** + * @returns {integer} + */ + getUnreadCounter: function () { + return this._unreadCounter; + }, + /** + * State whether this channel has been previewed + * + * A channel that has been previewed means that it had the necessary data + * to display its preview format. A channel needs its meta data and the + * last message in order to build its preview format. + * + * This is useful in order to not fetch preview info on this channel more + * than once on channels that have no message at all. + * + * Any received message updates the last_message, so a channel should + * always have all the necessary information to display its preview after + * the 1st time. + * + * @returns {boolean} + */ + hasBeenPreviewed: function () { + return this._previewed; + }, + /** + * State whether there are unread messages in this thread + * + * @returns {boolean} + */ + hasUnreadMessages: function () { + return this._unreadCounter !== 0; + }, + /** + * Increments the needaction counter of this thread + * FIXME: this method makes only sense for channels. Not sure, but I think + * this method is necessary at this level because of the model + * im_support.SupportChannel + * + * @abstract + */ + incrementNeedactionCounter: function () {}, + /** + * Increment the unread counter of this thread by 1 unit + */ + incrementUnreadCounter: function () { + this._unreadCounter++; + this._warnNewUnreadCounter(); + }, + /** + * States whether the thread should be auto-selected on creation + * + * Note that this is not of the responsibility of the thread: it only + * provides guidance for the object that uses threads (e.g. mail.Discuss + * must listen on threads and auto-select the thread if autoswitch is set). + * + * By default, threads are not in autoswitch mode. + * + * @returns {boolean} + */ + isAutoswitch: function () { + return false; + }, + /** + * States whether this thread is a channel or not. A thread is a channel if + * it is an instance of mail.model.Channel (direct or indirect). + * By default, any thread is not a channel + * + * @returns {boolean} + */ + isChannel: function () { + return false; + }, + /** + * States whether this thread is a chat or not. In particular, public and + * private channels are not chat, but DMs and Livechats are chats. Chats + * are threads used for communication between two users. + * By default, any thread is not a chat + * + * @returns {boolean} + */ + isChat: function () { + return false; + }, + /** + * States whether this thread is detached or not. + * A thread that is detached must have a thread window linked to itself. + * + * @return {boolean} + */ + isDetached: function () { + return this._detached; + }, + /** + * States whether this thread is folded or not. + * + * @return {boolean} + */ + isFolded: function () { + return this._folded; + }, + /** + * States whether the thread is linked to a document + * By default, threads are not linked to a document. + * + * @returns {boolean} + */ + isLinkedToDocument: function () { + return false; + }, + /** + * States whether this thread has the mass mailing setting active or not. + * This is a server-side setting, that determine the type of composer that + * is used (basic or extended composer). + * + * @return {boolean} + */ + isMassMailing: function () { + return this._massMailing; + }, + /** + + * States whether the thread is moderated or not. + * By default, threads are not moderated. + * + * @returns {boolean} + */ + isModerated: function () { + return false; + }, + /** + * States whether the current user is moderator of this thread. + * By default, the current user is not moderator of this thread. + * + * @returns {boolean} + */ + isModerator: function () { + return false; + }, + /** + * Mark this channel as previewed + * + * This is useful in order to not fetch preview info on this channel + * is the server has no preview in the first place. + * + * Note: preview fetch is useful only when the channel contains messages + * that have not been fetched at all. After that, this channel instance + * is updated regularly so that the most up-to-date info are available + * to make the preview of this channel. + */ + markAsPreviewed: function () { + this._previewed = true; + }, + /** + * Mark the thread as read, which resets the unread counter to 0. + * + * @returns {$.Promise} resolved + */ + markAsRead: function () { + if (this._unreadCounter > 0) { + this._unreadCounter = 0; + this._warnNewUnreadCounter(); + } + return $.when(); + }, + /** + * Post a message on the thread. + * + * This method must be completed by concrete threads, + * As it currently only pre-process the messages at the moment. + * + * @abstract + * @param {Object} data + * @returns {$.Promise} resolved with the message object to be sent + * to the server + */ + postMessage: function (data) { + // This message will be received from the mail composer as html content + // subtype but the urls will not be linkified. If the mail composer + // takes the responsibility to linkify the urls we end up with double + // linkification a bit everywhere. Ideally we want to keep the content + // as text internally and only make html enrichment at display time but + // the current design makes this quite hard to do. + var body = mailUtils.parseAndTransform(_.str.trim(data.content), mailUtils.addLink); + body = this._generateEmojis(body); + var messageData = { + partner_ids: data.partner_ids, + body: body, + attachment_ids: data.attachment_ids, + }; + if ('subject' in data) { + messageData.subject = data.subject; + } + return $.when(messageData); + }, + /** + * @private + */ + resetUnreadCounter: function () { + this._unreadCounter = 0; + this._warnNewUnreadCounter(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @abstract + * @private + * @return {$.Promise} + */ + _fetchMessages: function () { + return $.when(); + }, + /** + * Replace character representations of emojis by their unicode + * representation in the provided HTML string. Note that the provided html + * string is altered by this function. + * + * @param {string} htmlString + * @returns {string} + */ + _generateEmojis: function (htmlString) { + _.each(emojis, function (emoji) { + _.each(emoji.sources, function (source) { + var escapedSource = String(_.escape(source)).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1'); + var regexp = new RegExp("(\\s|^)(" + escapedSource + ")(?=\\s|$)", 'g'); + htmlString = htmlString.replace(regexp, '$1' + emoji.unicode); + }); + }); + return htmlString; + }, + /** + * Warn other mail components that the window state of this thread has + * changed (it has become closed, folded, unfolded, detached, etc.) + * + * @private + * @param {Object} [options={}] + */ + _warnUpdatedWindowState: function (options) { + options = options || {}; + this.call('mail_service', 'updateThreadWindow', this.getID(), options); + }, + /** + * Warn on the chat bus that the unread counter has been updated + * + * @private + */ + _warnNewUnreadCounter: function () { + this.call('mail_service', 'getMailBus') + .trigger('update_thread_unread_counter', this); + }, +}); + +return Thread; + +}); diff --git a/addons/mail/static/src/js/models/thread_with_cache.js b/addons/mail/static/src/js/models/thread_with_cache.js new file mode 100644 index 00000000000..f25a19005f2 --- /dev/null +++ b/addons/mail/static/src/js/models/thread_with_cache.js @@ -0,0 +1,204 @@ +odoo.define('mail.model.ThreadWithCache', function (require) { +"use strict"; + +var Thread = require('mail.model.Thread'); + +var session = require('web.session'); + +/** + * This is a version of a thread that is handling a cache. + * Basically, this cache is an object indexed by stringified indexes. + * These indexes represent the domain of messages that match this domain. + * + * Any threads that are instances of this Class can be used with a search view, + * in order to make searches on messages in a thread. + */ +var ThreadWithCache = Thread.extend({ + /** + * @override + * @param {integer} [data.seen_message_id=undefined] + */ + init: function (parent, data) { + this._super.apply(this, arguments); + + this._cache = { + '[]': { + allHistoryLoaded: false, + loaded: false, + messages: [], + } + }; + this._lastSeenMessageID = data.seen_message_id; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add the message to this thread + * + * @override + * @private + * @param {mail.model.Message} message + * @param {Array} domain + */ + addMessage: function (message, domain) { + var cache = this._getCache(domain); + var index = _.sortedIndex(cache.messages, message, function (msg) { + return msg.getID(); + }); + if (cache.messages[index] !== message) { + cache.messages.splice(index, 0, message); + } + }, + /** + * Get the last seen message for a given thread + * + * @return {integer|null} last seen message ID (if any) + */ + getLastSeenMessageID: function () { + return this._lastSeenMessageID || null; + }, + /** + * @override + * @param {Array} [domain] + * @param {boolean} [loadMore=false] + */ + getMessages: function (domain, loadMore) { + if (loadMore) { + return this._fetchMessages(domain, true); + } + var cache = this._getCache(domain); + if (cache.loaded) { + return $.when(cache.messages); + } else { + return this._fetchMessages(domain); + } + }, + /** + * @private + * @return {boolean} true if there is at least a message, false otherwise + */ + hasMessages: function () { + return !!(_.last(this._cache['[]'].messages)); + }, + /** + * Invalidate the caches of the thread + */ + invalidateCaches: function () { + this._cache = _.pick(this._cache, '[]'); + }, + /** + * State whether all messages have been loaded or not + * + * @param {Array} domain + * @return {boolean} + */ + isAllHistoryLoaded: function (domain) { + return this._getCache(domain).allHistoryLoaded; + }, + /** + * Remove message with ID `messageID` from this thread + * + * This is equivalent to removing this message from all the caches + * + * @param {integer} messageID + */ + removeMessage: function (messageID) { + _.each(this._cache, function (cache) { + cache.messages = _.reject(cache.messages, function (message) { + return message.getID() === messageID; + }); + }); + }, + /** + * Set 'messageID' as the ID of the last message seen in this thread + * + * @param {integer} messageID + */ + setLastSeenMessageID: function (messageID) { + this._lastSeenMessageID = messageID; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Gets messages from thread + * + * @override + * @private + * @param {Array} [pDomain] filter on the messages of the channel + * @param {boolean} [loadMore] Whether it should load more message + * @return {$.Promise} resolved with list of messages + */ + _fetchMessages: function (pDomain, loadMore) { + var self = this; + + var domain = this._getThreadDomain(); + + var cache = this._getCache(pDomain); + if (pDomain) { + domain = domain.concat(pDomain || []); + } + if (loadMore) { + var minMessageID = cache.messages[0].getID(); + domain = [['id', '<', minMessageID]].concat(domain); + } + return this._rpc({ + model: 'mail.message', + method: 'message_fetch', + args: [domain], + kwargs: { + limit: this._FETCH_LIMIT, + context: session.user_context + }, + }).then(function (messages) { + if (!cache.allHistoryLoaded) { + cache.allHistoryLoaded = messages.length < self._FETCH_LIMIT; + } + cache.loaded = true; + _.each(messages, function (message) { + self.call('mail_service', 'addMessage', message, { + silent: true, + domain: pDomain, + }); + }); + cache = self._getCache(pDomain || []); + return cache.messages; + }); + }, + /** + * Get thread content from the cache + * Useful to get cached messages. + * + * @private + * @param {Array} domain + * @return {Object|undefined} + */ + _getCache: function (domain) { + var stringifiedDomain = JSON.stringify(domain || []); + if (!this._cache[stringifiedDomain]) { + this._cache[stringifiedDomain] = { + allHistoryLoaded: false, + loaded: false, + messages: [], + }; + } + return this._cache[stringifiedDomain]; + }, + /** + * Get the domain to fetch all the messages in the current thread + * + * @abstract + * @private + * @returns {Array} + */ + _getThreadDomain: function () {}, +}); + +return ThreadWithCache; + +}); diff --git a/addons/mail/static/src/js/services/chat_manager.js b/addons/mail/static/src/js/services/chat_manager.js deleted file mode 100644 index 8a90fc57482..00000000000 --- a/addons/mail/static/src/js/services/chat_manager.js +++ /dev/null @@ -1,2016 +0,0 @@ -odoo.define('mail.ChatManager', function (require) { -"use strict"; - -var utils = require('mail.utils'); - -var AbstractService = require('web.AbstractService'); -var Bus = require('web.Bus'); -var config = require('web.config'); -var core = require('web.core'); -var session = require('web.session'); -var time = require('web.time'); -var web_client = require('web.web_client'); - -var _t = core._t; -var _lt = core._lt; - -var LIMIT = 30; // max number of fetched messages from the server -var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers -var ODOOBOT_ID = "ODOOBOT"; // default author_id for messages - -var emojiUnicodes={ - ":)":"😊", ":-)":"😊","=)":"😊", ":]":"😊", - ":D":"😃",":-D":"😃","=D":"😃", - "xD":"😆","XD":"😆", - "x'D":"😂", - ";)":"😉",";-)":"😉", - "B)":"😎","8)":"😎","B-)":"😎","8-)":"😎", - ";p":"😜",";P":"😜", - ":p":"😋",":P":"😋",":-p":"😋",":-P":"😋","=P":"😋", - "xp":"😝","xP":"😝", - "o_o":"😳", - ":|":"😐",":-|":"😐", - ":/":"😕",":-/":"😕", - ":(":"😞", - ":@":"😱", - ":O":"😲",":-O":"😲",":o":"😲",":-o":"😲", - ":'o":"😨", - "3:(":"😠",">:(":"😠","3:":"😠", - "3:)":"😈",">:)":"😈", - ":*":"😘",":-*":"😘", - "o:)":"😇", - ":'(":"😢", - ":'-(":"😭",":\"(":"😭", - "<3":"❤️",":heart":"❤️", - ":heart_eyes":"😍", - ":turban":"👳", - ":+1":"👍", - ":-1":"👎", - ":ok":"👌", - ":poop":"💩", - ":no_see":"🙈", - ":no_hear":"🙉", - ":no_speak":"🙊", - ":bug":"🐞", - ":kitten":"😺", - ":bear":"🐻", - ":snail":"🐌", - ":boar":"🐗", - ":clover":"🍀", - ":sunflower":"🌹", - ":fire":"🔥", - ":sun":"☀️", - ":partly_sunny:":"⛅️", - ":rainbow":"🌈", - ":cloud":"☁️", - ":zap":"⚡️", - ":star":"⭐️", - ":cookie":"🍪", - ":pizza":"🍕", - ":hamburger":"🍔", - ":fries":"🍟", - ":cake":"🎂", - ":cake_part":"🍰", - ":coffee":"☕️", - ":banana":"🍌", - ":sushi":"🍣", - ":rice_ball":"🍙", - ":beer":"🍺", - ":wine":"🍷", - ":cocktail":"🍸", - ":tropical":"🍹", - ":beers":"🍻", - ":ghost":"👻", - ":skull":"💀", - ":et":"👽",":alien":"👽", - ":party":"🎉", - ":trophy":"🏆", - ":key":"🔑", - ":pin":"📌", - ":postal_horn":"📯", - ":music":"🎵", - ":trumpet":"🎺", - ":guitar":"🎸", - ":soccer":"⚽️", - ":football":"🏈", - ":8ball":"🎱", - ":clapper":"🎬", - ":microphone":"🎤" - }; - -/** - * This service handles everything about chat channels and messages. - * - * There are basically two points of entry: - * - * 1. Calling a public method by means of 'this.call' - * 2. Receiving events on busBus (e.g. 'notification') - */ -var ChatManager = AbstractService.extend({ - name: 'chat_manager', - dependencies: ['ajax', 'bus_service'], - CHANNEL_SEEN_THROTTLE: 3000, - /** - * @override - */ - init: function () { - var self = this; - this._super.apply(this, arguments); - - this.messages = []; - this.channels = []; - this.messageFailures = []; - this.channelsPreviewDef; - this.channelDefs = {}; - this.unreadConversationCounter = 0; - this.emojis = []; - this.needactionCounter = 0; - this.starredCounter = 0; - this.moderationCounter = 0; - this.moderatedChannelIDs = []; - this.mentionPartnerSuggestions = []; - this.cannedResponses = []; - this.commands = []; - this.discussMenuID; - this.globalUnreadCounter = 0; - this.pinnedDmPartners = []; // partner_ids we have a pinned DM with - this.discussOpen = false; - - this.chatBus = new Bus(this); - this.busBus = this.call('bus_service', 'getBus'); - - this.chatBus.on('discuss_open', null, function (open) { - self.discussOpen = open; - }); - - this.busBus.on('notification', this, this._onNotification); - this.busBus.on('window_focus', this, this._onWindowFocus); - - // TODO create private fonction on prototype - this.channelSeen = _.throttle(function (channel) { - return self._rpc({ - model: 'mail.channel', - method: 'channel_seen', - args: [[channel.id]], - }, { - shadow: true - }); - }, self.CHANNEL_SEEN_THROTTLE); - - this._isReady = this._initMessaging(); - - // Add static channels - this._addChannel({ - id: "channel_inbox", - name: _lt("Inbox"), - type: "static", - }, { displayNeedactions: true }); - this._addChannel({ - id: "channel_starred", - name: _lt("Starred"), - type: "static" - }); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Closes the chat window of a corresponding channel. - * This operation is executed server-side and the chat window will be - * folded in all potential tabs from all browsers. - * - * @param {integer|string} channelID - * @return {$.Promise} - */ - closeChatSession: function (channelID) { - var channel = this.getChannel(channelID); - return this._rpc({ - model: 'mail.channel', - method: 'channel_fold', - kwargs: {uuid : channel.uuid, state : 'closed'}, - }, {shadow: true}); - }, - /** - * Creates a channel - * - * @param {integer|string} name id of partner (in case of dm) or name - * @param {string} type ['dm', 'public', 'private'] - * @return {$.Promise} - */ - createChannel: function (name, type) { - var method = type === "dm" ? "channel_get" : "channel_create"; - var args = type === "dm" ? [[name]] : [name, type]; - var context = _.extend({isMobile: config.device.isMobile}, session.user_context); - return this._rpc({ - model: 'mail.channel', - method: method, - args: args, - kwargs: {context: context}, - }) - .then(this._addChannel.bind(this)); - }, - /** - * Open the chat window for a given channel - * (in all potential tabs from all browsers) - * - * @param {integer} channelID - * @return {$.Promise} - */ - detachChannel: function (channelID) { - var channel = this.getChannel(channelID); - return this._rpc({ - model: 'mail.channel', - method: 'channel_minimize', - args: [channel.uuid, true], - }, { - shadow: true, - }); - }, - /** - * Folds/Minimize the chat window - * (in all potential tabs from all browsers) - * - * @param {integer} channelID - * @param {boolean} folded - * @return {$.Promise} - */ - foldChannel: function (channelID, folded) { - var args = { - uuid: this.getChannel(channelID).uuid, - }; - if (_.isBoolean(folded)) { - args.state = folded ? 'folded' : 'open'; - } - return this._rpc({ - model: 'mail.channel', - method: 'channel_fold', - kwargs: args, - }, {shadow: true}); - }, - /** - * Returns the list of canned responses - * A canned response is a pre-formatted text that is triggered with - * some keystrokes, such as with ':'. - * - * @return {Array} array of Objects (mail.shortcode) - */ - getCannedResponses: function () { - return this.cannedResponses; - }, - /** - * Returns a channel corresponding to the given id. - * - * @param {string|integer} id e.g. 'channel_inbox', 'channel_starred' - * @return {Object|undefined} the channel, if it exists - */ - getChannel: function (id) { - return _.findWhere(this.channels, {id: id}); - }, - /** - * Returns a list of channels - * - * @return {Object[]} list of channels - */ - getChannels: function () { - return _.clone(this.channels); - }, - /** - * Returns the content that will be shown in the mail navbar dropdown - * - * @param {Object[]} channels - * @return {$.Promise} resolved with array of preview msgs - */ - getChannelsPreview: function (channels) { - var self = this; - var channelsPreview = _.map(channels, function (channel) { - var info; - if ((channel.channel_ids && _.contains(channel.channel_ids, "channel_inbox")) || channel.id === "mail_failure") { - // this is a hack to preview messages and mail failure - // map inbox(mail_message) data with existing channel/chat template - info = _.pick(channel, - 'id', 'body', 'avatar_src', 'res_id', 'model', 'module_icon', - 'subject','date', 'record_name', 'status', 'displayed_author', - 'email_from', 'unread_counter'); - info.last_message = { - body: info.body, - date: info.date, - displayed_author: info.displayed_author || info.email_from, - }; - info.name = info.record_name || info.subject || info.displayed_author; - info.image_src = info.module_icon || info.avatar_src; - info.message_id = info.id; - if (channel.id !== "mail_failure") { - info.id = 'channel_inbox'; - } - return info; - } - info = _.pick(channel, 'id', 'is_chat', 'name', 'status', 'unread_counter'); - info.last_message = channel.last_message || _.last(channel.cache['[]'].messages); - if (!info.is_chat) { - info.image_src = '/web/image/mail.channel/'+channel.id+'/image_small'; - } else if (channel.direct_partner_id) { - info.image_src = '/web/image/res.partner/'+channel.direct_partner_id+'/image_small'; - } else { - info.image_src = '/mail/static/src/img/smiley/avatar.jpg'; - } - return info; - }); - var missingChannels = _.where(channelsPreview, {last_message: undefined}); - if (!this.channelsPreviewDef) { - if (missingChannels.length) { - var missingChannelIDs = _.pluck(missingChannels, 'id'); - this.channelsPreviewDef = this._rpc({ - model: 'mail.channel', - method: 'channel_fetch_preview', - args: [missingChannelIDs], - }, { - shadow: true, - }); - } else { - this.channelsPreviewDef = $.when(); - } - } - return this.channelsPreviewDef.then(function (channels) { - _.each(missingChannels, function (channelPreview) { - var channel = _.findWhere(channels, {id: channelPreview.id}); - if (channel) { - channelPreview.last_message = self._addMessage(channel.last_message); - } - }); - // sort channels: 1. unread, 2. chat, 3. date of last msg - channelsPreview.sort(function (c1, c2) { - return Math.min(1, c2.unread_counter) - Math.min(1, c1.unread_counter) || - c2.is_chat - c1.is_chat || - !!c2.last_message - !!c1.last_message || - (c2.last_message && c2.last_message.date.diff(c1.last_message.date)); - }); - - // generate last message preview (inline message body and compute date to display) - _.each(channelsPreview, function (channel) { - if (channel.last_message) { - channel.last_message_preview = utils.parse_and_transform(channel.last_message.body, utils.inline); - channel.last_message_date = channel.last_message.date.fromNow(); - } - }); - return channelsPreview; - }); - }, - /** - * @return {web.Bus} the chat bus - */ - getChatBus: function () { - return this.chatBus; - }, - /** - * Show the list of available commands next to a message (e.g. star) - * - * @param {Object} channel - * @return {Array} list of commands - */ - getCommands: function (channel) { - var commands = _.filter(this.commands, function (command) { - return !command.channel_types || _.contains(command.channel_types, channel.server_type); - }); - return commands; - }, - /** - * Returns the record id of ir.ui.menu for Discuss - * - * @return {integer} record id - */ - getDiscussMenuID: function () { - return this.discussMenuID; - }, - /** - * Gets direct message channel - * - * @param {integer} partnerID - * @return {Object|undefined} channel - */ - getDmFromPartnerID: function (partnerID) { - return _.findWhere(this.channels, {direct_partner_id: partnerID}); - }, - /** - * Returns list of emojis Objects - * - * @return {Object[]} list of emojis - * ['id', 'source', 'unicode_source', 'substitution', 'description'] - */ - getEmojis: function () { - return this.emojis; - }, - /** - * Get the last seen message for a given channel - * - * @param {Object} channel - * @return {Object|undefined} last seen Message Object (if any) - */ - getLastSeenMessage: function (channel) { - var result; - if (channel.last_seen_message_id) { - var messages = channel.cache['[]'].messages; - var msg = _.findWhere(messages, {id: channel.last_seen_message_id}); - if (msg) { - var i = _.sortedIndex(messages, msg, 'id') + 1; - while (i < messages.length && - (messages[i].is_author || messages[i].is_system_notification)) { - msg = messages[i]; - i++; - } - result = msg; - } - } - return result; - }, - /** - * Returns a list of mail failures - * - * @return {Object[]} list of channels - */ - getMessageFailures: function () { - return this.messageFailures; - }, - /** - * Get all listeners of a channel. - * - * @param {Object} channel - * @return {$.Promise>|Array} Two cases: - * - * 1. 'Channel' provided - * => Promise resolved with a list containing a list of members - * (list is cached in channel.membersDeferred) - * 2. No 'channel' provided - * => list containing a list of members (cached by chat_manager) - */ - getMentionPartnerSuggestions: function (channel) { - if (!channel) { - return this.mentionPartnerSuggestions; - } - if (!channel.membersDeferred) { - channel.membersDeferred = this._rpc({ - model: 'mail.channel', - method: 'channel_fetch_listeners', - args: [channel.uuid], - }, { - shadow: true - }) - .then(function (members) { - return [members]; - }); - } - return channel.membersDeferred; - }, - /** - * Gets message from its id - * - * @param {integer} msgID - * @return {Object|undefined} Message Object (if any) - */ - getMessage: function (msgID) { - return _.findWhere(this.messages, {id: msgID}); - }, - /** - * Gets messages from channel or ids or record (model and res_id): - * - * 1. From channel if we have 'channelID' in options - * 2. From ids if we have 'ids' in options - * 3. From model if we have 'model' and 'res_id' in options - * - * Rule of precedence: - * - * 'channelID' < 'ids' < 'model' and 'res_id' - * - * If we have none of the cases above, we return an empty list. - * - * @param {Object} [options] - * @param {integer|string} [options.channelID] - * @param {Array} [options.domain] - * @param {integer[]} [options.ids] - * @param {boolean} [options.loadMore] - * @param {string} [options.model] - * @param {integer} [options.res_id] - * @return {$.Promise} list of messages - */ - getMessages: function (options) { - var channel; - var self = this; - - if ('channelID' in options && options.loadMore) { - // get channel messages, force load more - channel = this.getChannel(options.channelID); - return this._fetchFromChannel(channel, {domain: options.domain || {}, loadMore: true}); - } - if ('channelID' in options) { - // channel message, check in cache first - channel = this.getChannel(options.channelID); - var channelCache = this._getChannelCache(channel, options.domain); - if (channelCache.loaded) { - return $.when(channelCache.messages); - } else { - return this._fetchFromChannel(channel, {domain: options.domain}); - } - } - if ('ids' in options) { - // get messages from their ids (chatter is the main use case) - return this._fetchDocumentMessages(options.ids, options).then(function (result) { - self.markAsRead(options.ids); - return result; - }); - } - if ('model' in options && 'res_id' in options) { - // get messages for a chatter, when it doesn't know the ids (use - // case is when using the full composer) - var domain = [['model', '=', options.model], ['res_id', '=', options.res_id]]; - return this._rpc({ - model: 'mail.message', - method: 'message_fetch', - args: [domain], - kwargs: {limit: LIMIT}, - }) - .then(function (msgs) { - return _.map(msgs, self._addMessage.bind(self)); - }); - } - return $.when([]); - }, - /** - * Returns the number of messages that the user needs to moderate - * - * @return {integer} - */ - getModerationCounter: function () { - return this.moderationCounter; - }, - /** - * Returns the number of messages received from followed channels - * + all messages where the current user is notified. - * - * @return {integer} needaction counter - */ - getNeedactionCounter: function () { - return this.needactionCounter; - }, - /** - * Gets the number of starred message - * - * @return {integer} starred counter - */ - getStarredCounter: function () { - return this.starredCounter; - }, - /** - * Gets the number of conversation which contains unread messages - * - * @return {integer} unread conversation counter - */ - getUnreadConversationCounter: function () { - return this.unreadConversationCounter; - }, - /** - * States whether all messages have been loaded or not - * - * @param {Object} channel - * @param {Array} domain - * @return {boolean} - */ - isAllHistoryLoaded: function (channel, domain) { - return this._getChannelCache(channel, domain).all_history_loaded; - }, - /** - * @return {boolean} - */ - isModerator: function () { - return this._isModerator; - }, - /** - * @return {$.Promise} - */ - isReady: function () { - return this._isReady; - }, - /** - * join an existing channel - * See @createChannel to join a new channel - * - * @param {integer} channelID - * @param {Object} [options] - * @return {$.Promise} resolved with channel object - */ - joinChannel: function (channelID, options) { - var self = this; - if (channelID in this.channelDefs) { - // prevents concurrent calls to channel_join_and_get_info - return this.channelDefs[channelID]; - } - var channel = this.getChannel(channelID); - if (channel) { - // channel already joined - this.channelDefs[channelID] = $.when(channel); - } else { - this.channelDefs[channelID] = this._rpc({ - model: 'mail.channel', - method: 'channel_join_and_get_info', - args: [[channelID]], - }) - .then(function (result) { - return self._addChannel(result, options); - }); - } - return this.channelDefs[channelID]; - }, - /** - * Marks all messages from a channel as read - * - * @param {Object} channel - * @param {Array} domain - * @return {$.Promise} - */ - markAllAsRead: function (channel, domain) { - if ((channel.id === "channel_inbox" && this.needactionCounter) || - (channel && channel.needaction_counter)) { - return this._rpc({ - model: 'mail.message', - method: 'mark_all_as_read', - kwargs: { - channel_ids: channel.id !== "channel_inbox" ? [channel.id] : [], - domain: domain, - }, - }); - } - return $.when(); - }, - /** - * Mark messages as read - * - * @param {Array} msgIDs list of messages ids - * @return {$.Promise} - */ - markAsRead: function (msgIDs) { - var self = this; - var ids = _.filter(msgIDs, function (id) { - var message = _.findWhere(self.messages, {id: id}); - // If too many messages, not all are fetched, and some might not be found - return !message || message.is_needaction; - }); - if (ids.length) { - return this._rpc({ - model: 'mail.message', - method: 'set_message_done', - args: [ids], - }); - } else { - return $.when(); - } - }, - /** - * Marks a channel as seen. - * The seen message will be the last message. - * Resolved with the last seen message, only for non-static channels - * - * @param {Object} channel - * @return {$.Promise} last message id seen in the channel - */ - markChannelAsSeen: function (channel) { - if (channel.unread_counter > 0 && channel.type !== 'static') { - this._updateChannelUnreadCounter(channel, 0); - return this.channelSeen(channel); - } - return $.when(); - }, - /** - * Opens the chat window in discuss. - * - * @param {integer} partnerID - * @return {$.Promise} resolved with the dm channel - */ - openAndDetachDm: function (partnerID) { - return this._rpc({ - model: 'mail.channel', - method: 'channel_get_and_minimize', - args: [[partnerID]], - }) - .then(this._addChannel.bind(this)); - }, - /** - * Open the channel: - * - * 1. If discuss is opened, asks discuss to open the channel - * 2. Otherwise, asks the chat_window_manager to detach the channel - * - * @param {Object} channel - */ - openChannel: function (channel) { - this.chatBus.trigger(this.discussOpen ? 'open_channel' : 'detach_channel', channel); - }, - /** - * Prepares and sends a message to the server: - * - * 1. Either the message is posted on a channel - * 2. Or the message is posted in a model's record (chatter) - * - * Rule of precedence: - * - * 'channelID' < 'model' & 'res_id' - * - * If options as none of these parameters, do nothing and return - * a promise no resolved item. - * - * @param {Object} data data related to the new message - * @param {Object} options - * @param {string|integer} [options.channelID] - * @param {string} [options.model] - * @param {integer} [options.res_id] - * @return {$.Promise} - */ - postMessage: function (data, options) { - var self = this; - options = options || {}; - - // This message will be received from the mail composer as html content subtype - // but the urls will not be linkified. If the mail composer takes the responsibility - // to linkify the urls we end up with double linkification a bit everywhere. - // Ideally we want to keep the content as text internally and only make html - // enrichment at display time but the current design makes this quite hard to do. - var body = utils.parse_and_transform(_.str.trim(data.content), utils.add_link); - - var msg = { - partner_ids: data.partner_ids, - body: body, - attachment_ids: data.attachment_ids, - }; - - // Replace emojis by their unicode character - _.each(emojiUnicodes, function (unicode, key) { - var escapedKey = String(key).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1'); - var regexp = new RegExp("(\\s|^)(" + escapedKey + ")(?=\\s|$)", "g"); - msg.body = msg.body.replace(regexp, "$1" + unicode); - }); - if ('subject' in data) { - msg.subject = data.subject; - } - if ('channelID' in options) { - // post a message in a channel or execute a command - return this._rpc({ - model: 'mail.channel', - method: data.command ? 'execute_command' : 'message_post', - args: [options.channelID], - kwargs: _.extend(msg, { - message_type: 'comment', - subtype: 'mail.mt_comment', - command: data.command, - }), - }); - } - if ('model' in options && 'res_id' in options) { - // post a message in a chatter - _.extend(msg, { - context: data.context, - message_type: data.message_type, - subtype: data.subtype, - subtype_id: data.subtype_id, - }); - - return this._rpc({ - model: options.model, - method: 'message_post', - args: [options.res_id], - kwargs: msg, - }) - .then(function (msgID) { - return self._rpc({ - model: 'mail.message', - method: 'message_format', - args: [[msgID]], - }) - .then(function (msgs) { - msgs[0].model = options.model; - msgs[0].res_id = options.res_id; - self._addMessage(msgs[0]); - }); - }); - } - return $.when(); - }, - /** - * Special redirection handling for given model and id - * - * If the model is res.partner, and there is a user associated with this - * partner which isn't the current user, open the DM with this user. - * Otherwhise, open the record's form view, if this is not the current user's. - * - * @param {string} resModel model to open - * @param {integer} resID record to open - * @param {function} [dmRedirectionCallback] only used if 'res.partner' - */ - redirect: function (resModel, resID, dmRedirectionCallback) { - var self = this; - var redirectToDocument = function (resModel, resID, viewID) { - web_client.do_action({ - type:'ir.actions.act_window', - view_type: 'form', - view_mode: 'form', - res_model: resModel, - views: [[viewID || false, 'form']], - res_id: resID, - }); - }; - if (resModel === 'res.partner') { - var domain = [['partner_id', '=', resID]]; - this._rpc({ - model: 'res.users', - method: 'search', - args: [domain], - }) - .then(function (userIDs) { - if (userIDs.length && userIDs[0] !== session.uid && dmRedirectionCallback) { - self.createChannel(resID, 'dm').then(dmRedirectionCallback); - } else { - redirectToDocument(resModel, resID); - } - }); - } else { - this._rpc({ - model: resModel, - method: 'get_formview_id', - args: [[resID], session.user_context], - }) - .then(function (viewID) { - redirectToDocument(resModel, resID, viewID); - }); - } - }, - /** - * Removes all messages from the current model except 'needaction'. - * We want to keep it in inbox. - * - * @param {string} model - */ - removeChatterMessages: function (model) { - this.messages = _.reject(this.messages, function (message) { - return (!message.channel_ids || message.channel_ids.length === 0) && message.model === model; - }); - }, - /** - * Search among prefetched partners, using the string 'searchVal' - * - * @param {string} searchVal - * @param {integer} limit max number of found partners in the response - * @return {$.Promise} list of found partners (matching 'searchVal') - */ - searchPartner: function (searchVal, limit) { - var def = $.Deferred(); - var values = []; - // search among prefetched partners - var searchRegexp = new RegExp(_.str.escapeRegExp(utils.unaccent(searchVal)), 'i'); - _.each(this.mentionPartnerSuggestions, function (partners) { - if (values.length < limit) { - values = values.concat(_.filter(partners, function (partner) { - return session.partner_id !== partner.id && searchRegexp.test(partner.name); - })).splice(0, limit); - } - }); - if (!values.length) { - // extend the research to all users - def = this._rpc({ - model: 'res.partner', - method: 'im_search', - args: [searchVal, limit || 20], - }, { - shadow: true, - }); - } else { - def = $.when(values); - } - return def.then(function (values) { - var autocompleteData = _.map(values, function (value) { - return { id: value.id, value: value.name, label: value.name }; - }); - return _.sortBy(autocompleteData, 'label'); - }); - }, - /** - * Stars or unstars message - * - * @param {integer} msgID - * @return {$.Promise} - */ - toggleStarStatus: function (msgID) { - return this._rpc({ - model: 'mail.message', - method: 'toggle_message_starred', - args: [[msgID]], - }); - }, - /** - * Unstars all messages from all channels - * - * @return {$.Promise} - */ - unstarAll: function () { - return this._rpc({ - model: 'mail.message', - method: 'unstar_all', - args: [[]] - }); - }, - /** - * Unsubscribes from channel - * - * @param {Object} channel - * @param {integer|string} [channel.id] mandatory if channel is of type 'public' or 'private' - * @param {string} [channel.type] - * @param {string} [channel.uuid] mandatory if channel is not of type 'public' or 'private' - * @return {$.Promise} - */ - unsubscribe: function (channel) { - if (_.contains(['public', 'private'], channel.type)) { - return this._rpc({ - model: 'mail.channel', - method: 'action_unfollow', - args: [[channel.id]], - }); - } else { - return this._rpc({ - model: 'mail.channel', - method: 'channel_pin', - args: [channel.uuid, false], - }); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Adds a channel and returns it. - * Simply returns the channel if already exists. - * - * @private - * @param {Object} data - * @param {string|integer} data.id id of channel or 'channel_inbox', 'channel_starred', ... - * @param {string|Object} data.name name of channel, e.g. 'general' - * @param {string} data.type type of channel, e.g. 'static' - * @param {string} [data.state] e.g. 'open', 'folded' - * @param {Object|integer} [options=undefined] - * @param {boolean} [options.silent] - * @return {Object} the newly or already existing channel - */ - _addChannel: function (data, options) { - options = typeof options === "object" ? options : {}; - var channel = this.getChannel(data.id); - if (channel) { - if (channel.is_folded !== (data.state === "folded")) { - channel.is_folded = (data.state === "folded"); - this.call('chat_window_manager', 'toggleFoldChat', channel); - } - } else { - channel = this._makeChannel(data, options); - this.channels.push(channel); - if (data.last_message) { - channel.last_message = this._addMessage(data.last_message); - } - // In case of a static channel (Inbox, Starred), the name is translated thanks to _lt - // (lazy translate). In this case, channel.name is an object, not a string. - this.channels = _.sortBy(this.channels, function (channel) { - return _.isString(channel.name) ? channel.name.toLowerCase() : ''; - }); - if (!options.silent) { - this.chatBus.trigger("new_channel", channel); - } - if (channel.is_detached) { - this.call('chat_window_manager', 'openChat', channel); - } - } - return channel; - }, - /** - * Adds a channel to a message - * Usefull when you mark a message as 'to do'. - * The message will be available in 'Starred' channel. - * - * @private - * @param {Object} message - * @param {string} channelID - */ - _addChannelToMessage: function (message, channelID) { - if (!message.channel_ids) { - message.channel_ids = []; - } - message.channel_ids.push(channelID); - message.channel_ids = _.uniq(message.channel_ids); - }, - /** - * Creates a new message - * - * @private - * @param {Object} data message data - * @param {integer} data.id - * @param {Object} [options] - * @param {Array} [options.domain] - * @param {boolean} [options.increment_unread] whether we should increment - * the unread_counter of channel. - * @param {boolean} [options.silent] whether it should inform in the chatBus - * of the newly created message. - * @return {Object} message object - */ - _addMessage: function (data, options) { - var self = this; - options = options || {}; - var msg = _.findWhere(this.messages, { id: data.id }); - - if (!msg) { - msg = this._makeMessage(data); - // Keep the array ordered by id when inserting the new message - this.messages.splice(_.sortedIndex(this.messages, msg, 'id'), 0, msg); - _.each(msg.channel_ids, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - // update the channel's last message (displayed in the channel - // preview, in mobile) - if (!channel.last_message || msg.id > channel.last_message.id) { - channel.last_message = msg; - } - self._addToCache(msg, []); - if (options.domain && options.domain !== []) { - self._addToCache(msg, options.domain); - } - if (channel.hidden) { - channel.hidden = false; - self.chatBus.trigger('new_channel', channel); - } - if (channel.type !== 'static' && !msg.is_author && !msg.is_system_notification) { - if (options.increment_unread) { - self._updateChannelUnreadCounter(channel, channel.unread_counter+1); - } - if (channel.is_chat && options.show_notification) { - if (!self.discussOpen && !config.device.isMobile) { - // automatically open chat window - self.call('chat_window_manager', 'openChat', channel, { passively: true }); - } - var query = {is_displayed: false}; - self.chatBus.trigger('anyone_listening', channel, query); - self._notifyIncomingMessage(msg, query); - } - } - } - }); - if (!options.silent) { - this.chatBus.trigger('new_message', msg); - } - } else if (options.domain && options.domain !== []) { - this._addToCache(msg, options.domain); - } else if (data.moderation_status === 'accepted') { - msg.channel_ids = data.channel_ids; - msg.needsModeration = false; - if (msg.isModerator) { - this.moderationCounter--; - this._removeMessageFromChannel("channel_moderation", msg); - this.chatBus.trigger('update_moderation_counter'); - } - this.chatBus.trigger('update_message', msg); - } - return msg; - }, - /** - * Stores message to channelCache. - * - * @private - * @param {Object} message - * @param {Array} domain - */ - _addToCache: function (message, domain) { - var self = this; - _.each(message.channel_ids, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - var channelCache = self._getChannelCache(channel, domain); - var index = _.sortedIndex(channelCache.messages, message, 'id'); - if (channelCache.messages[index] !== message) { - channelCache.messages.splice(index, 0, message); - } - } - }); - }, - /** - * Gets messages from their ids - * This method is used when the chatter linked to a record need to be loaded. - * - * @private - * @param {Array} msgIDs message ids to load - * @param {Object} options - * @return {$.Promise} resolved with fetched messages - */ - _fetchDocumentMessages : function (msgIDs, options) { - var self = this; - var loadedMsgs = _.filter(this.messages, function (message) { - return _.contains(msgIDs, message.id); - }); - var loadedMsgIDs = _.pluck(loadedMsgs, 'id'); - - options = options || {}; - if (options.forceFetch || _.difference(msgIDs.slice(0, LIMIT), loadedMsgIDs).length) { - var idsToLoad = _.difference(msgIDs, loadedMsgIDs).slice(0, LIMIT); - return this._rpc({ - model: 'mail.message', - method: 'message_format', - args: [idsToLoad], - context: session.user_context, - }) - .then(function (msgs) { - var processedMsgs = []; - _.each(msgs, function (msg) { - processedMsgs.push(self._addMessage(msg, {silent: true})); - }); - return _.sortBy(loadedMsgs.concat(processedMsgs), function (msg) { - return msg.id; - }); - }); - } else { - return $.when(loadedMsgs); - } - }, - /** - * Gets messages from channel - * - * @private - * @param {Object} channel - * @param {integer|string} channel.id string for static channels, e.g. 'channel_inbox' - * @param {Object} [option={}] - * @param {Array} [options.domain] filter on the messages of the channel - * @param {boolean} [options.loadMore] Whether it should load more message - * @return {$.Promise} resolved with list of messages - */ - _fetchFromChannel: function (channel, options) { - var self = this; - options = options || {}; - var domain = - (channel.id === 'channel_inbox') ? [['needaction', '=', true]] : - (channel.id === 'channel_starred') ? [['starred', '=', true]] : - (channel.id === 'channel_moderation') ? [['need_moderation', '=', true]] : - ['|', - '&', '&', - ['model', '=', 'mail.channel'], - ['res_id', 'in', [channel.id]], - ['need_moderation', '=', true], - ['channel_ids', 'in', [channel.id]] - ]; - var cache = this._getChannelCache(channel, options.domain); - - if (options.domain) { - domain = domain.concat(options.domain || []); - } - if (options.loadMore) { - var minMessageID = cache.messages[0].id; - domain = [['id', '<', minMessageID]].concat(domain); - } - - return this._rpc({ - model: 'mail.message', - method: 'message_fetch', - args: [domain], - kwargs: {limit: LIMIT, context: session.user_context}, - }) - .then(function (msgs) { - if (!cache.all_history_loaded) { - cache.all_history_loaded = msgs.length < LIMIT; - } - cache.loaded = true; - - _.each(msgs, function (msg) { - self._addMessage(msg, { - channel_id: channel.id, - silent: true, - domain: options.domain, - }); - }); - var channelCache = self._getChannelCache(channel, options.domain || []); - return channelCache.messages; - }); - }, - /** - * Gets channel content from the cache. - * Usefull to get cached messages from a channel. - * - * @private - * @param {Object} channel - * @param {Object[]} channel.cache - * @param {Array} domain - * @return {Object|undefined} - */ - _getChannelCache: function (channel, domain) { - var stringifiedDomain = JSON.stringify(domain || []); - if (!channel.cache[stringifiedDomain]) { - channel.cache[stringifiedDomain] = { - all_history_loaded: false, - loaded: false, - messages: [], - }; - } - return channel.cache[stringifiedDomain]; - }, - /** - * @private - * @returns {$.Promise} - */ - _initMessaging: function () { - var self = this; - return session.is_bound.then(function (){ - var context = _.extend({isMobile: config.device.isMobile}, session.user_context); - return self._rpc({ - route: '/mail/init_messaging', - params: {context: context}, - }); - }).then(function (result) { - _.each(result.channel_slots, function (channels) { - _.each(channels, self._addChannel.bind(self)); - }); - self.messageFailures = result.mail_failures; - self.needactionCounter = result.needaction_inbox_counter || 0; - self.starredCounter = result.starred_counter || 0; - self.moderationCounter = result.moderation_counter; - self.moderatedChannelIDs = result.moderation_channel_ids; - self._isModerator = result.is_moderator; - - //if user is moderator then add moderation channel - if (self._isModerator) { - self._addChannel({ - id: "channel_moderation", - name: _lt("Moderate Messages"), - type: "static" - }); - } - self.commands = _.map(result.commands, function (command) { - return _.extend({ id: command.name }, command); - }); - self.mentionPartnerSuggestions = result.mention_partner_suggestions; - self.discussMenuID = result.menu_id; - - // Shortcodes: canned responses - _.each(result.shortcodes, function (s) { - self.cannedResponses.push(_.pick(s, ['id', 'source', 'substitution'])); - }); - // emojis - - var lastAdded = null; - _.each(emojiUnicodes, function (unicode, key) { - if (lastAdded != unicode){ - lastAdded = unicode; - self.emojis.push({source:key, unicode_source:unicode, description:key}); - } - }); - - self.busBus.start_polling(); - }); - }, - /** - * Clear cache of a channel - * - * @private - * @param {Array} channelIDs must be int or string - */ - _invalidateCaches: function (channelIDs) { - var self = this; - _.each(channelIDs, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - channel.cache = { '[]': channel.cache['[]']}; - } - }); - }, - /** - * Creates channel object - * - * @private - * @param {Object} data - * @param {string} [data.anonymous_name] - * @param {string} data.channel_type - * @param {Object[]} [data.direct_partner] - * @param {boolean} [data.group_based_subscription] - * @param {integer|string} data.id - * @param {boolean} data.is_moderator - * @param {boolean} data.is_minimized - * @param {string} [data.last_message_date] - * @param {boolean} data.mass_mailing - * @param {integer} [data.message_needaction_counter] - * @param {integer} [data.message_unread_counter] - * @param {boolean} data.moderation whether this channel is moderated or not - * @param {string} data.name - * @param {string} [data.public] - * @param {integer} data.seen_message_id - * @param {string} [data.state] - * @param {string} [data.type] - * @param {string} data.uuid - * @param {Object} options - * @param {boolean} [options.autoswitch] - * @param {boolean} options.displayNeedactions - * @param {boolean} [options.hidden] - * @return {Object} channel - */ - _makeChannel: function (data, options) { - var channel = { - id: data.id, - name: data.name, - server_type: data.channel_type, - type: data.type || data.channel_type, - all_history_loaded: false, - uuid: data.uuid, - is_detached: data.is_minimized, - is_folded: data.state === "folded", - autoswitch: 'autoswitch' in options ? options.autoswitch : true, - hidden: options.hidden, - display_needactions: options.displayNeedactions, - mass_mailing: data.mass_mailing, - isModerated: data.moderation, - isModerator: data.is_moderator, - group_based_subscription: data.group_based_subscription, - needaction_counter: data.message_needaction_counter || 0, - unread_counter: 0, - last_seen_message_id: data.seen_message_id, - cache: {'[]': { - all_history_loaded: false, - loaded: false, - messages: [], - }}, - }; - if (channel.type === "channel") { - channel.type = data.public !== "private" ? "public" : "private"; - } - if (_.size(data.direct_partner) > 0) { - channel.type = "dm"; - channel.name = data.direct_partner[0].name; - channel.direct_partner_id = data.direct_partner[0].id; - channel.status = data.direct_partner[0].im_status; - this.pinnedDmPartners.push(channel.direct_partner_id); - this.busBus.update_option('bus_presence_partner_ids', this.pinnedDmPartners); - } else if ('anonymous_name' in data) { - channel.name = data.anonymous_name; - } - if (data.last_message_date) { - channel.last_message_date = moment(time.str_to_datetime(data.last_message_date)); - } - channel.is_chat = !channel.type.match(/^(public|private|static)$/); - if (data.message_unread_counter) { - this._updateChannelUnreadCounter(channel, data.message_unread_counter); - } - return channel; - }, - /** - * Creates message object - * - * @private - * @param {Object} data - * @param {integer[]} [data.attachment_ids=[]] - * @param {integer[]} [data.author_id] - * @param {string} [data.body=""] - * @param {Array} data.channel_ids contains integers and strings - * @param {string} data.customer_email_status - * @param {string} data.date - * @param {string} data.email_from - * @param {integer} data.id - * @param {string} [data.info] - * @param {boolean} data.is_discussion - * @param {boolean} data.is_note - * @param {boolean} data.is_notification - * @param {string} data.message_type - * @param {string} [data.moderation_status] - * @param {string} [data.model] - * @param {boolean} data.module_icon src url of the module icon - * @param {string} data.record_name - * @param {integer} data.res_id - * @param {string} data.subject - * @param {string} data.subtype_description - * @param {integer[]} data.tracking_value_ids - * @return {Object} message - */ - _makeMessage: function (data) { - var self = this; - var msg = { - id: data.id, - author_id: data.author_id, - body: data.body || "", - date: moment(time.str_to_datetime(data.date)), - message_type: data.message_type, - subtype_description: data.subtype_description, - is_author: data.author_id && data.author_id[0] === session.partner_id, - isModerator: data.model === 'mail.channel' && - _.contains(this.moderatedChannelIDs, data.res_id) && - data.moderation_status === 'pending_moderation', - is_note: data.is_note, - is_discussion: data.is_discussion, - is_notification: data.is_notification, - is_system_notification: (data.message_type === 'notification' && data.model === 'mail.channel') - || data.info === 'transient_message', - attachment_ids: data.attachment_ids || [], - subject: data.subject, - email_from: data.email_from, - customer_email_status: data.customer_email_status, - customer_email_data: data.customer_email_data, - record_name: data.record_name, - tracking_value_ids: data.tracking_value_ids, - channel_ids: data.channel_ids, - model: data.model, - res_id: data.res_id, - url: session.url("/mail/view?message_id=" + data.id), - module_icon:data.module_icon, - }; - _.each(emojiUnicodes, function (value, key) { - //add o_mail_emoji class on each unicode to manage size and font - var unicode = String(value); - var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|)", "g"); - var msg_bak = msg.body; - msg.body = msg.body.replace(regexp, ' '+unicode+' '); - // Idiot-proof limit. If the user had the amazing idea of copy-pasting thousands of emojis, - // the image rendering can lead to memory overflow errors on some browsers (e.g. Chrome). - // Set an arbitrary limit to 200 from which we simply don't replace them (anyway, they are - // already replaced by the unicode counterpart). - if (_.str.count(msg.body, 'o_mail_emoji') > 200) { - msg.body = msg_bak; - } - }); - function propertyDescr(channel) { - return { - enumerable: true, - get: function () { - return _.contains(msg.channel_ids, channel); - }, - set: function (bool) { - if (bool) { - self._addChannelToMessage(msg, channel); - } else { - msg.channel_ids = _.without(msg.channel_ids, channel); - } - } - }; - } - - Object.defineProperties(msg, { - is_starred: propertyDescr("channel_starred"), - is_needaction: propertyDescr("channel_inbox"), - needsModerationByUser: propertyDescr("channel_moderation"), - }); - - if (_.contains(data.needaction_partner_ids, session.partner_id)) { - msg.is_needaction = true; - } - if (_.contains(data.starred_partner_ids, session.partner_id)) { - msg.is_starred = true; - } - if (data.moderation_status === 'pending_moderation') { - msg.needsModeration = true; - msg.needsModerationByUser = msg.isModerator; - // the message is not linked to the moderated channel on the - // server, therefore this message has not this channel in - // channel_ids. Here, just to show this message in the channel - //visually, it links this message to the channel - msg.channel_ids.push(msg.res_id); - } - if (msg.model === 'mail.channel') { - var realChannels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred', 'channel_moderation'); - var origin = realChannels.length === 1 ? realChannels[0] : undefined; - var channel = origin && this.getChannel(origin); - if (channel) { - msg.origin_id = origin; - msg.origin_name = channel.name; - } - } - - // Compute displayed author name or email - if ((!msg.author_id || !msg.author_id[0]) && msg.email_from) { - msg.mailto = msg.email_from; - } else { - msg.displayed_author = (msg.author_id === ODOOBOT_ID) && "OdooBot" || - msg.author_id && msg.author_id[1] || - msg.email_from || _t('Anonymous'); - } - - // Don't redirect on author clicked of self-posted or OdooBot messages - msg.author_redirect = !msg.is_author && msg.author_id !== ODOOBOT_ID; - - // Compute the avatar_url - if (msg.author_id === ODOOBOT_ID) { - msg.avatar_src = "/mail/static/src/img/odoo_o.png"; - } else if (msg.author_id && msg.author_id[0]) { - msg.avatar_src = "/web/image/res.partner/" + msg.author_id[0] + "/image_small"; - } else if (msg.message_type === 'email') { - msg.avatar_src = "/mail/static/src/img/email_icon.png"; - } else { - msg.avatar_src = "/mail/static/src/img/smiley/avatar.jpg"; - } - - // add anchor tags to urls - msg.body = utils.parse_and_transform(msg.body, utils.add_link); - - // Compute url of attachments - _.each(msg.attachment_ids, function (a) { - a.url = '/web/content/' + a.id + '?download=true'; - }); - - // format date to the local only once by message - // can not be done in preprocess, since it alter the original value - if (msg.tracking_value_ids && msg.tracking_value_ids.length) { - var format; - _.each(msg.tracking_value_ids, function (f) { - if (f.field_type === 'datetime') { - format = 'LLL'; - if (f.old_value) { - f.old_value = moment.utc(f.old_value).local().format(format); - } - if (f.new_value) { - f.new_value = moment.utc(f.new_value).local().format(format); - } - } else if (f.field_type === 'date') { - format = 'LL'; - if (f.old_value) { - f.old_value = moment(f.old_value).local().format(format); - } - if (f.new_value) { - f.new_value = moment(f.new_value).local().format(format); - } - } - }); - } - - return msg; - }, - /** - * @private - * @param {Object} data key, value to decide activity created or deleted - */ - _manageActivityUpdateNotification: function (data) { - this.chatBus.trigger('activity_updated', data); - }, - /** - * @private - * @param {Object} data - */ - _manageAuthorNotification: function (data) { - this._addMessage(data.message); - }, - /** - * @private - * @param {Object} message - * @param {Array} message.channel_ids list of integers and strings, - * where strings for static channels, e.g. 'channel_inbox'. - */ - _manageChannelNotification: function (message) { - var self = this; - var def; - var channelAlreadyInCache = true; - if (message.channel_ids.length === 1) { - channelAlreadyInCache = !!this.getChannel(message.channel_ids[0]); - def = this.joinChannel(message.channel_ids[0], {autoswitch: false}); - } else { - def = $.when(); - } - def.then(function () { - // don't increment unread if channel wasn't in cache yet as - // its unread counter has just been fetched - self._addMessage(message, { - show_notification: true, - increment_unread: channelAlreadyInCache - }); - self._invalidateCaches(message.channel_ids); - }); - }, - /** - * @private - * @param {Object} data - * @param {integer|string} data.id string for static channels, e.g. 'channel_inbox' - * @param {integer} [data.last_message_id] mandatory if 'id' refers to an - * existing channel. - */ - _manageChannelSeenNotification: function (data) { - var channel = this.getChannel(data.id); - if (channel) { - channel.last_seen_message_id = data.last_message_id; - if (channel.unread_counter) { - this._updateChannelUnreadCounter(channel, 0); - } - } - }, - /** - * Controls the session of the chat window - * - * @private - * @param {Object} chatSession - * @param {string} chatSession.channel_type - * @param {string|integer} chatSession.id string for static channels, e.g. 'channel_inbox' - * @param {string} chatSession.info - * @param {boolean} chatSession.is_minimized - * @param {string} chatSession.name - * @param {string} chatSession.state - */ - _manageChatSessionNotification: function (chatSession) { - var channel; - if ((chatSession.channel_type === "channel") && (chatSession.state === "open")) { - this._addChannel(chatSession, {autoswitch: false}); - if (!chatSession.is_minimized && chatSession.info !== 'creation') { - web_client.do_notify(_t("Invitation"), _t("You have been invited to: ") + chatSession.name); - } - } - // partner specific change (open a detached window for example) - if ((chatSession.state === "open") || (chatSession.state === "folded")) { - channel = chatSession.is_minimized && this.getChannel(chatSession.id); - if (channel) { - channel.is_detached = true; - channel.is_folded = (chatSession.state === "folded"); - this.call('chat_window_manager', 'openChat', channel); - } - } else if (chatSession.state === "closed") { - channel = this.getChannel(chatSession.id); - if (channel) { - channel.is_detached = false; - this.call('chat_window_manager', 'closeChat', channel, {keep_open_if_unread: true}); - } - } - }, - /** - * @private - * @param {Object} data - * @param {Object[]} [data.message_ids] - */ - _manageDeletionNotification: function (data) { - var self = this; - _.each(data.message_ids, function (msgID) { - var message = _.findWhere(self.messages, { id: msgID }); - if (message) { - if (message.isModerator) { - self._removeMessageFromChannel("channel_moderation", message); - self.moderationCounter--; - } - message.needsModeration = false; - self._removeMessageFromChannel(message.res_id, message); - self.chatBus.trigger('update_message', message); - } - }); - this.chatBus.trigger('update_moderation_counter'); - }, - /** - * Add or remove failure when receiving a failure update message - * @private - * @param {Object} data - */ - _manageMessageFailureNotification: function (data) { - var self = this; - _.each(data.elements, function (updateMessage) { - - var isAddFailure = _.some(updateMessage.notifications, function(notif) { - return notif[0] === 'exception' || notif[0] === 'bounce'; - }); - var res = _.find(self.messageFailures, {'message_id': updateMessage.message_id}); - if (res) { - var index = _.findIndex(self.messageFailures, res); - if (isAddFailure) { - self.messageFailures[index] = updateMessage; - } else { - self.messageFailures.splice(index, 1); - } - } else if (isAddFailure) { - self.messageFailures.push(updateMessage); - } - var message = _.findWhere(self.messages, { id: updateMessage.message_id }); - if (message) { - if (isAddFailure) { - message.customer_email_status = "exception"; - } else{ - message.customer_email_status = "sent"; - } - self._updateMessageNotificationStatus(updateMessage, message); - self.chatBus.trigger('update_message', message); - } - }); - this.chatBus.trigger('update_needaction', this.needactionCounter); - }, - /** - * Updates channel_inbox when a message has marked as read. - * - * @private - * @param {Object} data - * @param {integer[]} [data.channel_ids] - * @param {integer[]} [data.message_ids] - * @param {string} [data.type] - */ - _manageMarkAsReadNotification: function (data) { - var self = this; - _.each(data.message_ids, function (msgID) { - var message = _.findWhere(self.messages, { id: msgID }); - if (message) { - self._invalidateCaches(message.channel_ids); - self._removeMessageFromChannel("channel_inbox", message); - self.chatBus.trigger('update_message', message, data.type); - } - }); - if (data.channel_ids) { - _.each(data.channel_ids, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - channel.needaction_counter = Math.max(channel.needaction_counter - data.message_ids.length, 0); - } - }); - } else { // if no channel_ids specified, this is a 'mark all read' in the inbox - _.each(this.channels, function (channel) { - channel.needaction_counter = 0; - }); - } - this.needactionCounter = Math.max(this.needactionCounter - data.message_ids.length, 0); - this.chatBus.trigger('update_needaction', this.needactionCounter); - }, - /** - * @private - * @param {Object} data notification data - * @param {Object} data.message - */ - _manageModeratorNotification: function (data) { - this.moderationCounter++; - this._addMessage(data.message); - this.chatBus.trigger('update_moderation_counter'); - }, - /** - * @private - * @param {Object} message - * @param {integer[]} message.channel_ids - */ - _manageNeedactionNotification: function (message) { - var self = this; - message = this._addMessage(message, { - channel_id: 'channel_inbox', - increment_unread: true, - show_notification: true, - }); - this._invalidateCaches(message.channel_ids); - if (message.channel_ids.length !== 0) { - this.needactionCounter++; - } - _.each(message.channel_ids, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - channel.needaction_counter++; - } - }); - this.chatBus.trigger('update_needaction', this.needactionCounter); - }, - /** - * @private - * @param {Object} data structure depending on the type - * @param {integer} data.id - */ - _managePartnerNotification: function (data) { - if (data.info === "unsubscribe") { - var channel = this.getChannel(data.id); - if (channel) { - var msg; - if (_.contains(['public', 'private'], channel.type)) { - msg = _.str.sprintf(_t('You unsubscribed from %s.'), channel.name); - } else { - msg = _.str.sprintf(_t('You unpinned your conversation with %s.'), channel.name); - } - this._removeChannel(channel); - this.chatBus.trigger("unsubscribe_from_channel", data.id); - web_client.do_notify(_t("Unsubscribed"), msg); - } - } else if (data.type === 'toggle_star') { - this._manageToggleStarNotification(data); - } else if (data.type === 'mark_as_read') { - this._manageMarkAsReadNotification(data); - } else if (data.type === 'moderator') { - this._manageModeratorNotification(data); - } else if (data.type === 'author') { - this._manageAuthorNotification(data); - } else if (data.type === 'deletion') { - this._manageDeletionNotification(data); - } else if (data.info === 'channel_seen') { - this._manageChannelSeenNotification(data); - } else if (data.info === 'transient_message') { - this._manageTransientMessageNotification(data); - } else if (data.type === 'activity_updated') { - this._manageActivityUpdateNotification(data); - } else if (data.type === 'mail_failure') { - this._manageMessageFailureNotification(data); - } else { - this._manageChatSessionNotification(data); - } - }, - /** - * @private - * @param {Object} data partner infos - * @param {integer} data.id - * @param {string} data.im_status - */ - _managePresenceNotification: function (data) { - var dm = this.getDmFromPartnerID(data.id); - if (dm) { - dm.status = data.im_status; - this.chatBus.trigger('update_dm_presence', dm); - } - }, - /** - * @private - * @param {Object} data - * @param {integer[]} data.message_ids - * @param {boolean} data.starred - * @param {string} data.type - */ - _manageToggleStarNotification: function (data) { - var self = this; - _.each(data.message_ids, function (msgID) { - var message = _.findWhere(self.messages, { id: msgID }); - if (message) { - self._invalidateCaches(message.channel_ids); - message.is_starred = data.starred; - if (!message.is_starred) { - self._removeMessageFromChannel("channel_starred", message); - } else { - self._addToCache(message, []); - var channelStarred = self.getChannel('channel_starred'); - channelStarred.cache = _.pick(channelStarred.cache, "[]"); - } - self.chatBus.trigger('update_message', message); - } - }); - - if (data.starred) { // increase starred counter if message is marked as star - this.starredCounter += data.message_ids.length; - } else { // decrease starred counter if message is remove from star if unstar_all then it will set to 0. - this.starredCounter -= data.message_ids.length; - } - - this.chatBus.trigger('update_starred', this.starredCounter); - }, - /** - * @private - * @param {Object} data - * @param {string} data.author_id - */ - _manageTransientMessageNotification: function (data) { - var lastMessage = _.last(this.messages); - data.id = (lastMessage ? lastMessage.id : 0) + 0.01; - data.author_id = data.author_id || ODOOBOT_ID; - this._addMessage(data); - }, - /** - * shows a popup to notify a new received message. - * This will also rename the odoo tab browser if - * the user is not in it. - * - * @private - * @param {Object} msg message received - * @param {Array} msg.author_id contains [integer, string] - * @param {string} msg.body - * @param {Object} options - * @param {boolean} options.is_displayed - */ - _notifyIncomingMessage: function (msg, options) { - if (this.busBus.is_odoo_focused() && options.is_displayed) { - // no need to notify - return; - } - var title = _t('New message'); - if (msg.author_id[1]) { - title = _.escape(msg.author_id[1]); - } - var content = utils.parse_and_transform(msg.body, utils.strip_html) - .substr(0, PREVIEW_MSG_MAX_SIZE); - - if (!this.busBus.is_odoo_focused()) { - this.globalUnreadCounter++; - var tabTitle = _.str.sprintf(_t("%d Messages"), this.globalUnreadCounter); - web_client.set_title_part("_chat", tabTitle); - } - - this.call('bus_service', 'sendNotification', web_client, title, content); - }, - /** - * Removes channel - * - * @private - * @param {Object} [channel] - * @param {integer} [channel.direct_partner_id] mandatory if type is 'dm' - * @param {integer|string} channel.id string for static channels, e.g. 'channel_inbox' - * @param {string} [channel.type] - */ - _removeChannel: function (channel) { - if (!channel) { return; } - if (channel.type === 'dm') { - var index = this.pinnedDmPartners.indexOf(channel.direct_partner_id); - if (index > -1) { - this.pinnedDmPartners.splice(index, 1); - this.busBus.update_option('bus_presence_partner_ids', this.pinnedDmPartners); - } - } - this.channels = _.without(this.channels, channel); - delete this.channelDefs[channel.id]; - }, - /** - * Removes a message from a channel. - * it will also remove cached message from the channel - * - * @private - * @param {integer|string} channelID string for static channels, e.g. 'channel_inbox' - * @param {Object} message - */ - _removeMessageFromChannel: function (channelID, message) { - message.channel_ids = _.without(message.channel_ids, channelID); - var channel = _.findWhere(this.channels, { id: channelID }); - _.each(channel.cache, function (cache) { - cache.messages = _.without(cache.messages, message); - }); - }, - /** - * Increments or decrements unreadConversationCounter - * - * @private - * @param {Object} channel - * @param {integer} counter - */ - _updateChannelUnreadCounter: function (channel, counter) { - if (channel.unread_counter > 0 && counter === 0) { - this.unreadConversationCounter = Math.max(0, this.unreadConversationCounter-1); - } else if (channel.unread_counter === 0 && counter > 0) { - this.unreadConversationCounter++; - } - channel.unread_counter = counter; - this.chatBus.trigger("update_channel_unread_counter", channel); - }, - /** - * Update the message notification status of message based on update_message - * - * @private - * @param {Object} updateMessage - * @param {Object[]} updateMessage.notifications - */ - _updateMessageNotificationStatus: function (updateMessage, message) { - _.each(updateMessage.notifications, function (notif, id, list) { - var partnerName = notif[1]; - var notifStatus = notif[0]; - var res = _.find(message.customer_email_data, function(entry){ - return entry[0] === parseInt(id); - }); - if (res) { - res[2] = notifStatus; - } else { - message.customer_email_data.push([parseInt(id), partnerName, notifStatus]); - } - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Notification handlers - * Sometimes, the web client receives unsubscribe notification and an extra - * notification on that channel. This is then followed by an attempt to - * rejoin the channel that we just left. The next few lines remove the - * extra notification to prevent that situation to occur. - * - * @private - * @param {Array} notifications - */ - _onNotification: function (notifications) { - var self = this; - var unsubscribedNotif = _.find(notifications, function (notif) { - return notif[1].info === "unsubscribe"; - }); - if (unsubscribedNotif) { - notifications = _.reject(notifications, function (notif) { - return notif[0][1] === "mail.channel" && notif[0][2] === unsubscribedNotif[1].id; - }); - } - _.each(notifications, function (notification) { - var model = notification[0][1]; - if (model === 'ir.needaction') { - // new message in the inbox - self._manageNeedactionNotification(notification[1]); - } else if (model === 'mail.channel') { - // new message in a channel - self._manageChannelNotification(notification[1]); - } else if (model === 'res.partner') { - // channel joined/left, message marked as read/(un)starred, chat open/closed - self._managePartnerNotification(notification[1]); - } else if (model === 'bus.presence') { - // update presence of users - self._managePresenceNotification(notification[1]); - } - }); - }, - /** - * Global unread counter and notifications - * - * @private - */ - _onWindowFocus: function () { - this.globalUnreadCounter = 0; - web_client.set_title_part("_chat"); - }, - -}); - -core.serviceRegistry.add('chat_manager', ChatManager); - -return ChatManager; - -}); diff --git a/addons/mail/static/src/js/services/chat_window_manager.js b/addons/mail/static/src/js/services/chat_window_manager.js deleted file mode 100644 index fd45b336c23..00000000000 --- a/addons/mail/static/src/js/services/chat_window_manager.js +++ /dev/null @@ -1,534 +0,0 @@ -odoo.define('mail.ChatWindowManager', function (require) { -"use strict"; - -var ExtendedChatWindow = require('mail.ExtendedChatWindow'); - -var AbstractService = require('web.AbstractService'); -var config = require('web.config'); -var core = require('web.core'); -var utils = require('web.utils'); -var web_client = require('web.web_client'); - -var _t = core._t; -var QWeb = core.qweb; - -//---------------------------------------------------------------- -var CHAT_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows - -/** - * This service handles chat channels that are displayed in "chat window" mode - */ -var ChatWindowManager = AbstractService.extend({ - name: 'chat_window_manager', - dependencies: ['chat_manager'], - - /** - * @override - */ - init: function () { - var self = this; - - this._super.apply(this, arguments); - - this.chatSessions = []; - this.newChatSession; - this.displayState = { - chatWindowsHidden: false, // chat windows aren't displayed when the client action is open - hiddenSessions: [], - hiddenUnreadCounter: 0, // total number of unread msgs in hidden chat windows - nbSlots: 0, - spaceLeft: 0, - windowsDropdownIsOpen: false, // used to keep dropdown open when closing chat windows - }; - this.call('chat_manager', 'isReady').then(function () { - var channels = self.call('chat_manager', 'getChannels'); - _.each(channels, function (channel) { - if (channel.is_detached) { - self.openChat(channel); - } - }); - }); - - var chatBus = this.call('chat_manager', 'getChatBus'); - chatBus.on('update_message', this, this._onUpdateMessage); - chatBus.on('new_message', this, this._onNewMessage); - chatBus.on('anyone_listening', this, this._onAnyoneListening); - chatBus.on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel); - chatBus.on('update_channel_unread_counter', this, this._onUpdateChannelUnreadCounter); - chatBus.on('update_dm_presence', this, this._onUpdateDmPresence); - chatBus.on('detach_channel', this, this._onDetachChannel); - chatBus.on('discuss_open', this, this._onDiscussOpen); - - core.bus.on('resize', this, _.debounce(this._repositionWindows.bind(this), 100)); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @param {Object} chatSession - * @param {integer} chatSession.id - * @param {Object} options - */ - closeChat: function (chatSession, options) { - var session = _.find(this.chatSessions, {id: chatSession.id}); - if (session) { - this._closeChat(session, options); - } - }, - /** - * @param {Object} session - * @param {integer} session.id - * @param {boolean} [session.is_chat] true for dm? => server_type="chat" & type="dm"... - * @param {boolean} [session.mass_mailing] - * @param {string} [session.status] e.g. 'offline' - * @param {Object} [options] - * @param {boolean} [options.passively] if set to true, open the chat window - * without focusing the input and marking messages as read if it is not - * open yet, and do nothing otherwise - */ - openChat: function (session, options) { - var self = this; - if (!session) { - this._openChatWithoutSession(); - return; - } - options = options || {}; - var chatSession = _.findWhere(this.chatSessions, {id: session.id}); - if (!chatSession) { - var prefix = !session.is_chat ? "#" : ""; - var windowOptions = { - autofocus: !options.passively, - input_less: session.mass_mailing, - status: session.status, - }; - chatSession = { - id: session.id, - uuid: session.uuid, - name: session.name, - keep_unread: options.passively, // don't automatically mark unread messages as seen - window: new ExtendedChatWindow(this, session.id, prefix + session.name, session.is_folded, session.unread_counter, windowOptions), - }; - chatSession.window.on("close_chat_session", null, function () { - self._closeChat(chatSession); - self.call('chat_manager', 'closeChatSession', chatSession.id); - }); - chatSession.window.on("toggle_star_status", null, function (messageID) { - self.call('chat_manager', 'toggleStarStatus', messageID); - }); - - chatSession.window.on("fold_channel", null, function (channelID, folded) { - self.call('chat_manager', 'foldChannel', channelID, folded); - }); - - chatSession.window.on("post_message", null, function (message, channelID) { - self.call('chat_manager', 'postMessage', message, { - channelID: channelID - }).then(function () { - chatSession.window.thread.scroll_to(); - }); - }); - chatSession.window.on("redirect", null, function (resModel, resID) { - self.call('chat_manager', 'redirect', resModel, resID, self.openChat.bind(self)); - }); - chatSession.window.on("redirect_to_channel", null, function (channelID) { - var session = _.findWhere(self.chatSessions, {id: channelID}); - if (!session) { - self.call('chat_manager', 'joinChannel', channelID).then(function (channel) { - self.call('chat_manager', 'detachChannel', channelID); - }); - } else { - session.window.toggle_fold(false); - } - }); - - var removeNewChat = false; - if (options.passively) { - this.chatSessions.push(chatSession); // simply insert the window to the left - } else if (this.newChatSession && this.newChatSession.partner_id && this.newChatSession.partner_id === session.direct_partner_id) { - // the window takes the place of the 'newChatSession' window - this.chatSessions[_.indexOf(this.chatSessions, this.newChatSession)] = chatSession; - removeNewChat = true; - } else { - this._addChatSession(chatSession); // add session such that window is visible - } - - chatSession.window.appendTo($('body')) - .then(function () { - self._repositionWindows({remove_new_chat: removeNewChat}); - return self.call('chat_manager', 'getMessages', {channelID: chatSession.id}); - }).then(function (messages) { - chatSession.window.render(messages); - chatSession.window.thread.scroll_to(); - setTimeout(function () { - chatSession.window.thread.$el.on("scroll", null, _.debounce(function () { - if (!chatSession.keep_unread && chatSession.window.thread.is_at_bottom()) { - self.call('chat_manager', 'markChannelAsSeen', session); - } - }, 100)); - }, 0); // setTimeout to prevent to execute handler on first scroll_to, which is asynchronous - if (options.passively) { - // mark first unread messages as seen when focusing the window, then on scroll to bottom as usual - chatSession.window.$('.o_mail_thread, .o_chat_composer').one('click', function () { - self.call('chat_manager', 'markChannelAsSeen', session); - }); - } else if (!self.displayState.chatWindowsHidden && !session.is_folded) { - self.call('chat_manager', 'markChannelAsSeen', session); - } - }); - } else if (!options.passively) { - if (chatSession.window.is_hidden) { - this._makeSessionVisible(chatSession); - } else if (session.is_folded !== chatSession.window.folded) { - chatSession.window.toggle_fold(session.is_folded); - } - } - }, - /** - * Called when unfolding the chat window - * - * @param {Object} channel - * @param {integer} channel.id - * @param {boolean} [channel.is_folded] - */ - toggleFoldChat: function (channel) { - var session = _.find(this.chatSessions, {id: channel.id}); - if (session) { - session.window.toggle_fold(channel.is_folded); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add chatSession such that it will be the left-most visible window - * - * @private - * @param {Object} chatSession - */ - _addChatSession: function (chatSession) { - this._computeAvailableSlots(this.chatSessions.length+1); - this.chatSessions.splice(this.displayState.nbSlots-1, 0, chatSession); - }, - /** - * @private - * @param {Object} chatSession - * @param {boolean} [chatSession.keep_unread] - * @param {Object} chatSession.window - * @param {Object} [options] - * @param {boolean} [options.keep_open_if_unread] - */ - _closeChat: function (chatSession, options) { - if (options && options.keep_open_if_unread && chatSession.keep_unread) { - return; - } - this.chatSessions = _.without(this.chatSessions, chatSession); - chatSession.window.destroy(); - this._repositionWindows(); - }, - /** - * @private - */ - _closeNewChat: function () { - this.chatSessions = _.without(this.chatSessions, this.newChatSession); - this._repositionWindows({remove_new_chat: true}); - }, - /** - * @private - * @param {integer} nbWindows - */ - _computeAvailableSlots: function (nbWindows) { - if (config.device.isMobile) { - this.displayState.nbSlots = 1; // one chat window full screen in mobile - return; - } - var width = window.innerWidth; - var nbSlots = Math.floor(width/CHAT_WINDOW_WIDTH); - var spaceLeft = width - (Math.min(nbSlots, nbWindows)*CHAT_WINDOW_WIDTH); - if (nbSlots < nbWindows && spaceLeft < 50) { - nbSlots--; // leave at least 50px for the hidden windows dropdown button - spaceLeft += CHAT_WINDOW_WIDTH; - } - this.displayState.nbSlots = nbSlots; - this.displayState.spaceLeft = spaceLeft; - }, - /** - * @private - */ - _destroyNewChat: function () { - this.newChatSession.window.destroy(); - this.newChatSession = undefined; - }, - /** - * @private - * @param {Object} session - * @param {Widget} session.window - */ - _makeSessionVisible: function (session) { - utils.swap(this.chatSessions, session, this.chatSessions[this.displayState.nbSlots-1]); - this._repositionWindows(); - session.window.toggle_fold(false); - }, - /** - * @private - */ - _openChatWithoutSession: function () { - var self = this; - if (!this.newChatSession) { - this.newChatSession = { - id: '_open', - window: new ExtendedChatWindow(web_client, undefined, _t('New message'), false, 0, {thread_less: true}), - }; - this.newChatSession.window.on("close_chat_session", null, this._closeNewChat.bind(this)); - this.newChatSession.window.on('open_dm_session', null, function (partner_id) { - self.newChatSession.partner_id = partner_id; - var dm = self.call('chat_manager', 'getDmFromPartnerID', partner_id); - if (!dm) { - self.call('chat_manager', 'openAndDetachDm', partner_id); - } else { - var dmSession = _.findWhere(self.chatSessions, {id: dm.id}); - if (!dmSession) { - self.call('chat_manager', 'detachChannel', dm.id); - } else { - self._closeChat(dmSession); - dm.is_folded = false; - self.openChat(dm); - } - } - }); - this._addChatSession(this.newChatSession); - this.newChatSession.window.appendTo($('body')).then(this._repositionWindows.bind(this)); - } else { - if (this.newChatSession.window.is_hidden) { - this._makeSessionVisible(this.newChatSession); - } else if (this.newChatSession.window.folded) { - this.newChatSession.window.toggle_fold(false); - } - } - }, - /** - * @private - * @param {Object} message - * @param {integer[]} message.channel_ids - * @param {boolean} scrollBottom - */ - _updateSessions: function (message, scrollBottom) { - var self = this; - _.each(this.chatSessions, function (session) { - if (_.contains(message.channel_ids, session.id)) { - var messageVisible = !self.displayState.chatWindowsHidden && !session.window.folded && - !session.window.is_hidden && session.window.thread.is_at_bottom(); - if (messageVisible && !session.keep_unread) { - var channel = self.call('chat_manager', 'getChannel', session.id); - self.call('chat_manager', 'markChannelAsSeen', channel); - } - self.call('chat_manager', 'getMessages', {channelID: session.id}) - .then(function (messages) { - session.window.render(messages); - if (scrollBottom && messageVisible) { - session.window.thread.scroll_to(); - } - }); - } - }); - }, - /** - * @private - */ - _renderHiddenSessionsDropdown: function () { - var $dropdown = $(QWeb.render("mail.ChatWindowsDropdown", { - sessions: this.displayState.hiddenSessions, - open: this.displayState.windowsDropdownIsOpen, - unread_counter: this.displayState.hiddenUnreadCounter, - widget: {isMobile: config.device.isMobile}, - })); - return $dropdown; - }, - /** - * @private - */ - _repositionHiddenSessionsDropdown: function () { - // Unfold dropdown to the left if there is enough place - var $dropdownUL = this.displayState.$hiddenWindowsDropdown.children('ul'); - if (this.displayState.spaceLeft > $dropdownUL.width() + 10) { - $dropdownUL.addClass('dropdown-menu-right'); - } - }, - /** - * @private - * @param {Object} [options] - * @param {boolean} [options.remove_new_chat] - */ - _repositionWindows: function (options) { - var self = this; - if (options && options.remove_new_chat) { - this._destroyNewChat(); - } - if (this.displayState.chatWindowsHidden) { - return; - } - this._computeAvailableSlots(this.chatSessions.length); - var hiddenSessions = []; - var hiddenUnreadCounter = 0; - var nbSlots = this.displayState.nbSlots; - _.each(this.chatSessions, function (session, index) { - if (index < nbSlots) { - session.window.$el.css({right: CHAT_WINDOW_WIDTH*index, bottom: 0}); - session.window.do_show(); - } else { - hiddenSessions.push(session); - hiddenUnreadCounter += session.window.unread_msgs; - session.window.do_hide(); - } - }); - this.displayState.hiddenSessions = hiddenSessions; - this.displayState.hiddenUnreadCounter = hiddenUnreadCounter; - - if (this.displayState.$hiddenWindowsDropdown) { - this.displayState.$hiddenWindowsDropdown.remove(); - } - if (hiddenSessions.length) { - this.displayState.$hiddenWindowsDropdown = this._renderHiddenSessionsDropdown(); - var $hiddenWindowsDropdown = this.displayState.$hiddenWindowsDropdown; - $hiddenWindowsDropdown.css({right: CHAT_WINDOW_WIDTH * nbSlots, bottom: 0}) - .appendTo($('body')); - this._repositionHiddenSessionsDropdown(); - this.displayState.windowsDropdownIsOpen = false; - - $hiddenWindowsDropdown.on('click', '.o_chat_header', function (event) { - var sessionID = $(event.currentTarget).data('session-id'); - var session = _.findWhere(hiddenSessions, {id: sessionID}); - if (session) { - self._makeSessionVisible(session); - } - }); - $hiddenWindowsDropdown.on('click', '.o_chat_window_close', function (event) { - var sessionID = $(event.currentTarget).closest('.o_chat_header').data('session-id'); - var session = _.findWhere(hiddenSessions, {id: sessionID}); - if (session) { - session.window.on_click_close(event); - self.displayState.windowsDropdownIsOpen = true; // keep the dropdown open - } - }); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Object} channel - * @param {integer|string} channel.id - * @param {Object} query - */ - _onAnyoneListening: function (channel, query) { - _.each(this.chatSessions, function (session) { - if (channel.id === session.id && session.window.thread.is_at_bottom() && !session.window.is_hidden) { - query.is_displayed = true; - } - }); - }, - /** - * @private - * @param {Object} channel - * @param {integer} channel.id - */ - _onDetachChannel: function (channel) { - var chatSession = _.findWhere(this.chatSessions, {id: channel.id}); - if (!chatSession || chatSession.window.folded) { - this.call('chat_manager', 'detachChannel', channel.id); - } else if (chatSession.window.is_hidden) { - this._makeSessionVisible(chatSession); - } else { - chatSession.window.focus_input(); - } - }, - /** - * @private - * @param {boolean} open - */ - _onDiscussOpen: function (open) { - this.displayState.chatWindowsHidden = open; - if (open) { - $('body').addClass('o_no_chat_window'); - } else { - $('body').removeClass('o_no_chat_window'); - this._repositionWindows(); - } - }, - /** - * @private - * @param {Object} message - */ - _onNewMessage: function (message) { - this._updateSessions(message, true); - }, - /** - * @private - * @param {Object} channel - * @param {integer} channel.unread_counter - */ - _onUpdateChannelUnreadCounter: function (channel) { - var self = this; - this.displayState.hiddenUnreadCounter = 0; - _.each(self.chatSessions, function (session) { - if (channel.id === session.id) { - session.window.update_unread(channel.unread_counter); - if (channel.unread_counter === 0) { - session.keep_unread = false; - } - } - if (session.window.is_hidden) { - self.displayState.hiddenUnreadCounter += session.window.unread_msgs; - } - }); - if (self.displayState.$hiddenWindowsDropdown) { - self.displayState.$hiddenWindowsDropdown.html(self._renderHiddenSessionsDropdown().html()); - self._repositionHiddenSessionsDropdown(); - } - }, - /** - * @private - * @param {Object} channel - * @param {string|integer} channel.id - * @param {string} channel.status e.g. 'offline' - */ - _onUpdateDmPresence: function (channel) { - _.each(this.chatSessions, function (session) { - if (channel.id === session.id) { - session.window.update_status(channel.status); - } - }); - }, - /** - * @private - * @param {Object} message - */ - _onUpdateMessage: function (message) { - this._updateSessions(message, false); - }, - /** - * @private - * @param {string|integer} channelID - */ - _onUnsubscribeFromChannel: function (channelID) { - var self = this; - _.each(self.chatSessions, function (session) { - if (channelID === session.id) { - self._closeChat(session); - } - }); - }, - -}); - -core.serviceRegistry.add('chat_window_manager', ChatWindowManager); - -return ChatWindowManager; - -}); diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js new file mode 100644 index 00000000000..841c2c5cbfd --- /dev/null +++ b/addons/mail/static/src/js/services/mail_manager.js @@ -0,0 +1,1246 @@ +odoo.define('mail.Manager', function (require) { +"use strict"; + +/** + * This service is responsible for anything related to mail in JS. + * In particular, it stores all threads and messages that have been fetched + * from the server. + * + * As this service does quite a lot of stuffs, it is split into many different + * files. For instance, the handling of notification in the longpoll is in + * mail_notification_manager.js. This file contains the core functionnality + * of the mail service. + * + * The main point of entry of this service is to call public methods by mean + * of a service call. + * + * @example + * + * To get all threads that have been fetched from the server: + * + * this.call('mail_service', 'getThreads'); + * + * To get a particular thread with server ID 1: + * + * this.call('mail_service', 'getThread', 1); + */ +var AbstractService = require('web.AbstractService'); + +var Channel = require('mail.model.Channel'); +var DM = require('mail.model.DM'); +var Mailbox = require('mail.model.Mailbox'); +var MailFailure = require('mail.model.MailFailure'); +var Message = require('mail.model.Message'); +var mailUtils = require('mail.utils'); + +var Bus = require('web.Bus'); +var config = require('web.config'); +var core = require('web.core'); +var session = require('web.session'); + +var _t = core._t; +var _lt = core._lt; + +var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers + +var MailManager = AbstractService.extend({ + name: 'mail_service', + dependencies: ['ajax', 'bus_service', 'local_storage'], + _ODOOBOT_ID: "ODOOBOT", // default authorID for transient messages + + /** + * @override + */ + start: function () { + this._busBus = this.call('bus_service', 'getBus'); + this._cannedResponses = []; + this._mailBus = new Bus(this); + this._commands = []; + this._discussMenuID = undefined; + this._discussOpen = false; + this._isModerator = false; + this._mailFailures = []; + // list of employees for chatter mentions + this._mentionPartnerSuggestions = []; + this._messages = []; + this._moderatedChannelIDs = []; + // # of message received when odoo is out of focus + this._outOfFocusUnreadMessageCounter = 0; + // partner_ids we have a pinned DM with + this._pinnedDmPartners = []; + // all threads, including channels, DM, mailboxes, document threads, ... + this._threads = []; + + // listen on buses + this._mailBus + .on('discuss_open', this, this._onDiscussOpen) + .on('window_focus', this, this._onWindowFocus); + + this._initializeFromServer(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a message + * + * @param {Object} data message data + * @param {integer} data.id + * @param {string} [data.moderation_status] + * @param {Object} [options] + * @param {Array} [options.domain] + * @param {boolean} [options.incrementUnread] whether we should increment + * the unread_counter of channel. + * @param {boolean} [options.silent] whether it should inform in the mailBus + * of the newly created message. + * @returns {mail.model.Message} message object + */ + addMessage: function (data, options) { + options = options || {}; + var message = this.getMessage(data.id); + if (!message) { + message = this._makeMessage(data); + // Keep the array ordered by ID when inserting the new message + var index = _.sortedIndex(this._messages, message, function (msg) { + return msg.getID(); + }); + this._messages.splice(index, 0, message); + this._addNewMessagePostprocessThread(message, options); + this._addMessageToThreads(message, []); + if (!options.silent) { + this._mailBus.trigger('new_message', message); + } + } + if (data.moderation_status === 'accepted') { + message.setModerationStatus('accepted', { + additionalThreadIDs: data.channel_ids + }); + } + if (options.domain && options.domain !== []) { + this._addMessageToThreads(message, options.domain); + } + return message; + }, + /** + * Creates a channel, can be either a true channel or a DM based on `type` + * + * @param {integer|string} name id of partner (in case of dm) or name + * @param {string} type ['dm', 'public', 'private'] + * @returns {$.Promise} resolved with ID of the newly created + * channel + */ + createChannel: function (name, type) { + if (type === 'dm') { + return this._createDM(name); + } else { + return this._createChannel(name, type); + } + }, + /** + * Returns the list of canned responses + * A canned response is a pre-formatted text that is triggered with + * some keystrokes, such as with ':'. + * + * @returns {Array} array of Objects (mail.shortcode) + */ + getCannedResponses: function () { + return this._cannedResponses; + }, + /** + * Returns a channel corresponding to the given id. + * + * @param {integer} threadID + * @returns {mail.model.Channel|undefined} the channel, if it exists + */ + getChannel: function (threadID) { + return _.find(this._threads, function (thread) { + return thread.isChannel() && (thread.getID() === threadID); + }); + }, + /** + * Returns a list of channels + * + * @returns {mail.model.Channel[]} list of channels + */ + getChannels: function () { + return _.filter(this._threads, function (thread) { + return thread.isChannel(); + }); + }, + /** + * Returns the content that will be shown in the mail navbar dropdown + * + * @param {mail.model.Channel[]} channels + * @returns {$.Promise} resolved with list of channel previews + */ + getChannelPreviews: function (channels) { + var self = this; + return this._getChannelPreviews(channels).then(function (previews) { + var sortedPreviews = self._sortPreviews(previews); + return sortedPreviews; + }); + }, + /** + * @returns {web.Bus} the mail bus + */ + getMailBus: function () { + return this._mailBus; + }, + /** + * Returns the record id of ir.ui.menu for Discuss + * + * @returns {integer} record id + */ + getDiscussMenuID: function () { + return this._discussMenuID; + }, + /** + * Gets direct message channel + * + * @param {integer} partnerID + * @returns {Object|undefined} channel + */ + getDmFromPartnerID: function (partnerID) { + return _.find(this._threads, function (thread) { + return thread.getType() === 'dm' && + thread.getDirectPartnerID() === partnerID; + }); + }, + /** + * @param {string} mailboxID + * @returns {mail.model.Mailbox} the mailbox, if any + */ + getMailbox: function (mailboxID) { + return _.find(this._threads, function (thread) { + return thread.getID() === 'mailbox_' + mailboxID; + }); + }, + /** + * Returns a list of mail failures + * + * @returns {mail.model.MailFailure[]} list of mail failures + */ + getMailFailures: function () { + return this._mailFailures; + }, + /** + * Get partners as mentions from a chatter + * Typically all employees as partner suggestions. + * + * @returns {Array} + */ + getMentionPartnerSuggestions: function () { + return this._mentionPartnerSuggestions; + }, + /** + * Gets message from its ID + * + * @param {integer} messageID + * @returns {mail.model.Message|undefined} the matched message (if any) + */ + getMessage: function (messageID) { + return _.find(this._messages, function (message) { + return message.getID() === messageID; + }); + }, + /** + * Returns the previews to display in the systray's messaging menu. + * + * @param {string} [filter=undefined] specify 'chat' or 'channels' to only + * get previews for that type of threads + * @returns {$.Promise} resolved with list of objects that have a + * valid format for rendering a messaging menu preview items. + */ + getSystrayPreviews: function (filter) { + var self = this; + var channelDef = this._getSystrayChannelPreviews(filter); + var inboxDef = this._getSystrayInboxPreviews(filter); + var failureDef = this._getSystrayMailFailurePreviews(filter); + + return $.when(channelDef, inboxDef, failureDef) + .then(function (previewsChannel, previewsInbox, previewsFailure) { + // order: failures > inbox > channel, each group must be sorted + previewsChannel = self._sortPreviews(previewsChannel); + previewsInbox = self._sortPreviews(previewsInbox); + previewsFailure = self._sortPreviews(previewsFailure); + return _.union(previewsFailure, previewsInbox, previewsChannel); + }); + }, + /** + * Get the list of channel IDs where the current user is a moderator + * + * @returns {integer[]} + */ + getModeratedChannelIDs: function () { + return this._moderatedChannelIDs || []; + }, + /** + * Get the Odoobot ID, which is the default authorID for transient messages + * + * @returns {string} + */ + getOdoobotID: function () { + return this._ODOOBOT_ID; + }, + /** + * Returns thread matching provided ID, if any + * + * @param {string|integer} threadID + * @returns {mail.model.Thread|undefined} the thread, if it exists + */ + getThread: function (threadID) { + return _.find(this._threads, function (thread) { + return thread.getID() === threadID; + }); + }, + /** + * Returns a list of threads + * + * @returns {mail.model.Thread[]} list of threads + */ + getThreads: function () { + return this._threads; + }, + /** + * States whether the current user is a moderator or not + * + * @returns {boolean} + */ + isModerator: function () { + return this._isModerator; + }, + /** + * States whether the mail manager is ready or not + * This is the case when it has fetched the initial state from the server, + * by means of the route 'mail/init_messaging' + * + * @returns {$.Promise} + */ + isReady: function () { + return this._isReady; + }, + /** + * Join an existing channel + * See @createChannel to join a new channel + * + * @param {integer} channelID + * @param {Object} [options] options to be passed on channel add + * @returns {$.Promise} resolved with channelID joined + */ + joinChannel: function (channelID, options) { + var def; + var channel = this.getChannel(channelID); + if (channel) { + // channel already joined + def = $.when(channelID); + } else { + def = this._joinAndAddChannel(channelID, options); + } + return def; + }, + /** + * Mark messages as read + * + * @param {Array} messageIDs list of messages IDs + * @returns {$.Promise} resolved when messages have been marked as read on + * the server. + */ + markMessagesAsRead: function (messageIDs) { + var self = this; + var ids = _.filter(messageIDs, function (id) { + var message = self.getMessage(id); + // If too many messages, not all are fetched, + // and some might not be found + return !message || message.isNeedaction(); + }); + if (ids.length) { + return this._rpc({ + model: 'mail.message', + method: 'set_message_done', + args: [ids], + }); + } else { + return $.when(); + } + }, + /** + * @param {integer|string} threadID + */ + openThread: function (threadID) { + this._openThreadInDiscuss(threadID); + }, + /** + * Special redirection handling for given model and id + * + * If the model is res.partner, and there is a user associated with this + * partner which isn't the current user, open the DM with this user. + * Otherwhise, open the record's form view (if not current user's). + * + * @param {string} resModel model to open + * @param {integer} resID record to open + * @param {function} [dmRedirectionCallback] only used if 'res.partner', + * a function that has a threadID as input + */ + redirect: function (resModel, resID, dmRedirectionCallback) { + if (resModel === 'res.partner') { + this._redirectPartner(resModel, resID, dmRedirectionCallback); + } else { + this._redirectDefault(resModel, resID); + } + }, + /** + * Remove the message from all of its threads + * + * @param {mail.model.Message} message + */ + removeMessageFromThreads: function (message) { + var self = this; + _.each(message.getThreadIDs(), function (threadID) { + var thread = self.getThread(threadID); + if (thread) { + thread.removeMessage(message.getID()); + } + }); + }, + /** + * Search among prefetched partners, using the string 'searchVal' + * + * @param {string} searchVal + * @param {integer} limit max number of found partners in the response + * @returns {$.Promise} list of found partners (matching + * 'searchVal') + */ + searchPartner: function (searchVal, limit) { + var def = $.Deferred(); + var partners = this._searchPartnerPrefetch(searchVal, limit); + + if (!partners.length) { + def = this._searchPartnerFetch(searchVal, limit); + } else { + def = $.when(partners); + } + return def.then(function (partners) { + var suggestions = _.map(partners, function (partner) { + return { + id: partner.id, + value: partner.name, + label: partner.name + }; + }); + return _.sortBy(suggestions, 'label'); + }); + }, + /** + * Unstars all messages from all channels + * + * @returns {$.Promise} + */ + unstarAll: function () { + return this._rpc({ + model: 'mail.message', + method: 'unstar_all', + args: [[]] + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a channel and returns it. + * Simply returns the channel if already exists. + * + * @private + * @param {Object} data + * @param {integer} data.id id of channel + * @param {string|Object} data.name name of channel, e.g. 'general' + * @param {string} data.type type of channel, e.g. 'public' + * @param {string} [data.state] e.g. 'open', 'folded' + * @param {Object|integer} [options=undefined] + * @param {boolean} [options.silent=false] + * @returns {integer} the ID of the newly or already existing channel + */ + _addChannel: function (data, options) { + options = typeof options === 'object' ? options : {}; + var channel = this.getChannel(data.id); + if (!channel) { + channel = this._makeChannel(data, options); + if (channel.getType() === 'dm') { + this._pinnedDmPartners.push(channel.getDirectPartnerID()); + this._busBus.update_option( + 'bus_presence_partner_ids', + this._pinnedDmPartners + ); + } + this._threads.push(channel); + if (data.last_message) { + // channel_info in mobile, necessary for showing channel + // preview in mobile + this.addMessage(data.last_message); + } + this._sortThreads(); + if (!options.silent) { + this._mailBus.trigger('new_channel', channel); + } + } + return channel.getID(); + }, + /** + * Add a new mailbox + * + * @private + * @param {Object} data + * @param {Object} options + * @returns {mail.model.Mailbox} + */ + _addMailbox: function (data, options) { + options = typeof options === 'object' ? options : {}; + var mailbox = new Mailbox(this, data, options, this._commands); + this._threads.push(mailbox); + this._sortThreads(); + return mailbox; + }, + /** + * Stores a message to the cache `domain` of all of its threads + * + * @private + * @param {mail.model.Message} message + * @param {Array} domain + */ + _addMessageToThreads: function (message, domain) { + var self = this; + _.each(message.getThreadIDs(), function (threadID) { + var thread = self.getThread(threadID); + if (thread) { + thread.addMessage(message, domain); + } + }); + }, + /** + * For newly added message, postprocess threads linked to this message + * + * @private + * @param {mail.model.Message} message + * @param {Object} options + * @param {Array} [options.domain] + * @param {boolean} [options.incrementUnread] + * @param {boolean} [options.showNotification] + */ + _addNewMessagePostprocessThread: function (message, options) { + var self = this; + _.each(message.getThreadIDs(), function (threadID) { + var thread = self.getThread(threadID); + if (thread) { + if ( + thread.getType() !== 'mailbox' && + !message.isAuthor() && + !message.isSystemNotification() + ) { + if (options.incrementUnread) { + thread.incrementUnreadCounter(); + } + if (thread.isChat() && options.showNotification) { + if (!self._isDiscussOpen() && !config.device.isMobile) { + // automatically open thread window + // while keeping it unread + thread.detach({ passively: true }); + } + var query = { isBottomVisible: false }; + self._mailBus.trigger('is_thread_bottom_visible', thread, query); + if (!query.isBottomVisible) { + self._notifyIncomingMessage(message, query); + } + } + } + } + }); + }, + /** + * Create a Channel (other than a DM) + * + * @private + * @param {string} name + * @param {string} type + * @returns {$.Promise} ID of the created channel + */ + _createChannel: function (name, type) { + var context = _.extend({ isMobile: config.device.isMobile }, session.user_context); + return this._rpc({ + model: 'mail.channel', + method: 'channel_create', + args: [name, type], + kwargs: { context: context }, + }) + .then(this._addChannel.bind(this)); + }, + /** + * Create a Direct Messages Chat + * + * @private + * @param {string} name + * @returns {$.Promise} ID of the created channel + */ + _createDM: function (name) { + var context = _.extend({ isMobile: config.device.isMobile }, session.user_context); + return this._rpc({ + model: 'mail.channel', + method: 'channel_get', + args: [[name]], + kwargs: { context: context }, + }) + .then(this._addChannel.bind(this)); + }, + /** + * @private + * @param {integer[]} channelIDs + * @returns {$.Promise} resolved with list of channel preview + */ + _fetchChannelPreviews: function (channelIDs) { + return this._rpc({ + model: 'mail.channel', + method: 'channel_fetch_preview', + args: [channelIDs], + }, { shadow: true }); + }, + /** + * Get previews of the channels + * + * The preview of a channel is built from the channel information and its + * lastest message. A channel without a last message is incomplete, so it + * must fetch this message. + * + * Note that a channel could have no message at all, so it fetches for the + * last message only once per channel. This is correct to not fetch more + * than once in this case, because any later received message updates + * automatically the last message of a channel. + * + * @private + * @param {mail.model.Channel[]} channels + * @returns {$.Deferred} resolved with list of channel previews + */ + _getChannelPreviews: function (channels) { + var self = this; + + var unpreviewedChannels = _.reject(channels, function (channel) { + return channel.hasBeenPreviewed(); + }); + var fetchDef; + if (unpreviewedChannels.length) { + var ids = _.map(unpreviewedChannels, function (channel) { + return channel.getID(); + }); + fetchDef = this._fetchChannelPreviews(ids); + } else { + fetchDef = $.when(); + } + return fetchDef.then(function (fetchedPreviews) { + // add fetched messages + _.each(fetchedPreviews, function (fetchedPreview) { + if (fetchedPreview.last_message) { + self.addMessage(fetchedPreview.last_message); + } + // mark the channel as previewed, so that we do not need to + // fetch preview again + var channel = self.getChannel(fetchedPreview.id); + channel.markAsPreviewed(); + }); + return _.map(channels, function (channel) { + return channel.getPreview(); + }); + }); + }, + /** + * Get the previews of the mail failures + * Mail failures of a same model are grouped together, so that there are few + * preview items on the messaging menu in the systray. + * + * To determine whether this is a model preview or a document preview review + * the documentID is omitted for a model preview, whereas it is set for a + * preview of a failure related to a document. + * + * @returns {$.Promise} resolved with previews of mail failures + */ + _getMailFailurePreviews: function () { + // items = list of objects: + // item = { + // unreadCounter: {integer}, + // failure: {mail.model.MailFailure}, + // isSameDocument: {boolean} + // } + var items = []; + _.each(this._mailFailures, function (failure) { + var unreadCounter = 1; + var isSameDocument = true; + var sameModelItem = _.find(items, function (item) { + if ( + item.failure.isLinkedToDocumentThread() && + (item.failure.getDocumentModel() === failure.getDocumentModel()) + ) { + isSameDocument = item.failure.getDocumentID() === failure.getDocumentID(); + return true; + } + return false; + }); + + if (failure.isLinkedToDocumentThread() && sameModelItem) { + unreadCounter = sameModelItem.unreadCounter + 1; + isSameDocument = sameModelItem.isSameDocument && isSameDocument; + var index = _.findIndex(items, sameModelItem); + items[index] = { + unreadCounter: unreadCounter, + failure: failure, + isSameDocument: isSameDocument, + }; + } else { + items.push({ + unreadCounter: unreadCounter, + failure: failure, + isSameDocument: true, + }); + } + }); + return _.map(items, function (item) { + // return preview with correct unread counter + // also unset documentID if the grouped mail failures are from + // same model but different document + var preview = {}; + _.extend(preview, item.failure.getPreview(), { + unreadCounter: item.unreadCounter, + }); + if (!item.failure.isSameDocument) { + preview.documentID = undefined; + } + return preview; + }); + }, + /** + * @private + * @param {string|undefined} [filter] the filter to apply on channel + * previews in systray messaging menu + * @returns {$.Promise} resolved with object valid for template + * mail.Preview on channel previews, based on selected filter + */ + _getSystrayChannelPreviews: function (filter) { + var filteredThreads = _.filter(this._threads, function (thread) { + if (!thread.isChannel()) { + return false; + } + if (filter === 'chat') { + return thread.isChat(); + } else if (filter === 'channels') { + return !thread.isChat(); + } + return true; + }); + return this.getChannelPreviews(filteredThreads); + }, + /** + * Get the previews of needaction messages, i.e. messages in Inbox mailbox, + * based on selected filter. + * + * FIXME: it will use the current fetched messages in inbox + perform + * another message fetch on inbox. In other words, it means that some thread + * previews may be missing in the systray messaging menu. For example: + * + * - Each message fetch on Inbox fetches at most 30 messages. + * - There are 31 messages on Inbox. + * - No message on Inbox has been fetched at the moment + * - the 1st message on Inbox is linked to a document, and no other + * message in Inbox is linked to this document + * + * When clicking on the messaging menu, it will fetch all messages of Inbox, + * except the 1st message, in order to show the previews. The preview of + * the document thread of the 1st message will not be shown in the message + * menu + * + * This is a limitation, which should be solved by making changes on the + * server logic (e.g. provide list of all document threads in inbox on + * the rpc mail/init_messaging). + * + * @private + * @param {string|undefined} [filter] + * @returns {$.Deferred} resolved with valid objects for template + * mail.Preview on inbox message previews + */ + _getSystrayInboxPreviews: function (filter) { + // 'All' filter, show messages preview from inbox and mail failures + // inbox previews + if (filter === 'mailbox_inbox' || !filter) { + var inbox = this.getMailbox('inbox'); + return inbox.getMessagePreviews(); + } else { + return $.when([]); + } + }, + /** + * Get the previews of mail failure, based on selected filter. + * + * @private + * @param {string|undefined} [filter] + */ + _getSystrayMailFailurePreviews: function (filter) { + // mail failure previews + if (filter === 'mailbox_inbox' || !filter) { + return this._getMailFailurePreviews(); + } else { + return $.when([]); + } + }, + /** + * Initialize the canned responses from the server data + * + * @private + * @param {Object} data + * @param {Object[]} [data.shortcodes] + * @param {integer} data.shortcodes[i].id + * @param {string} data.shortcodes[i].source + * @param {string} data.shortcodes[i].substitution + */ + _initializeCannedResponses: function (data) { + var self = this; + _.each(data.shortcodes, function (s) { + var cannedResponse = _.pick(s, ['id', 'source', 'substitution']); + self._cannedResponses.push(cannedResponse); + }); + }, + /** + * Initialize the channels from the server, including public, private, DM, + * livechat, etc. + * + * @private + * @param {Object} data + * @param {Object} [data.channel_slots] contains the data of channels to + * initialize, which are grouped by channel type by key of the object + * (e.g. list of public channel data are stored in 'channel_channel') + * @param {Object[]} [data.channel_slots[i] list of data of channel of type + * `i` + */ + _initializeChannels: function (data) { + var self = this; + _.each(data.channel_slots, function (channels) { + _.each(channels, self._addChannel.bind(self)); + }); + }, + /** + * Initialize commands from the server + * + * @private + * @param {Object} data + * @param {Object[]} data.commands list of command data from the server + */ + _initializeCommands: function (data) { + this._commands = _.map(data.commands, function (command) { + return _.extend({ id: command.name }, command); + }); + }, + /** + * @private + * @returns {$.Promise} + */ + _initializeFromServer: function () { + var self = this; + this._isReady = session.is_bound.then(function () { + var context = _.extend( + { isMobile: config.device.isMobile }, + session.user_context + ); + return self._rpc({ + route: '/mail/init_messaging', + params: { context: context }, + }); + }).then(function (result) { + self._updateFromServer(result); + self._busBus.start_polling(); + }); + }, + /** + * Initialize the mailboxes, namely 'Inbox', 'Starred', + * and 'Moderation Queue' if the user is a moderator of a channel + * + * @private + * @param {Object} data + * @param {boolean} [data.is_moderator=false] states whether the user is + * moderator of a channel + * @param {integer} [data.moderation_counter=0] states the mailbox counter + * to set to 'Moderation Queue' + * @param {integer} [data.needaction_inbox_counter=0] states the mailbox + * counter to set to 'Inbox' + * @param {integer} [data.starred_counter=0] states the mailbox counter to + * set to 'Starred' + */ + _initializeMailboxes: function (data) { + this._addMailbox({ + id: 'inbox', + name: _lt("Inbox"), + mailboxCounter: data.needaction_inbox_counter || 0, + }); + this._addMailbox({ + id: 'starred', + name: _lt("Starred"), + mailboxCounter: data.starred_counter || 0, + }); + + if (data.is_moderator) { + this._addMailbox({ + id: 'moderation', + name: _lt("Moderate Messages"), + mailboxCounter: data.moderation_counter || 0, + }); + } + }, + /** + * Initialize mail failures from the server data + * + * @private + * @param {Object} data + * @param {Object[]} data.mail_failures data to initialize mail failures + * locally + */ + _initializeMailFailures: function (data) { + var self = this; + this._mailFailures = _.map(data.mail_failures, function (mailFailureData) { + return new MailFailure(self, mailFailureData); + }); + }, + /** + * Initialize moderation settings from the server data + * + * @private + * @param {Object} data + * @param {boolean} [data.is_moderator=false] + * @param {integer[]} [data.moderation_channel_ids] + */ + _initializeModerationSettings: function (data) { + this._moderatedChannelIDs = data.moderation_channel_ids; + this._isModerator = data.is_moderator; + }, + /** + * State whether discuss app is open or not + * + * @private + * @returns {boolean} + */ + _isDiscussOpen: function () { + return this._discussOpen; + }, + /** + * Join the channel, and add it locally afterwards + * + * @private + * @param {integer|string} channelID + * @param {Object} options options passed on channel add + * @returns {$.Promise} channelID of joined and added channel + */ + _joinAndAddChannel: function (channelID, options) { + var self = this; + return this._rpc({ + model: 'mail.channel', + method: 'channel_join_and_get_info', + args: [[channelID]], + }) + .then(function (result) { + return self._addChannel(result, options); + }); + }, + /** + * Creates a new instance of Channel with the given data and options. + * + * @private + * @param {Object} data + * @param {Array} [data.direct_partner] if set and is an non-empty array, + * the channel is a DM + * @param {Object} [options] + * @returns {mail.model.Channel} + */ + _makeChannel: function (data, options) { + if (_.size(data.direct_partner) > 0) { + return new DM(this, data, options, this._commands); + } + return new Channel(this, data, options, this._commands); + }, + /** + * Creates a new instance of Message with the given data. + * + * @private + * @param {Object} data + * @returns {mail.model.Message} + */ + _makeMessage: function (data) { + return new Message(this, data); + }, + /** + * shows a popup to notify a new received message. + * This will also rename the odoo tab browser if + * the user has no focus on it. + * + * @private + * @param {mail.model.Message} message message received + * @param {Object} options + * @param {boolean} options.isDisplayed + */ + _notifyIncomingMessage: function (message, options) { + if (this._busBus.is_odoo_focused() && options.isDisplayed) { + // no need to notify + return; + } + var title = _t("New message"); + if (message.hasAuthor()) { + title = _.escape(message.getAuthorName()); + } + var content = mailUtils.parseAndTransform(message.getBody(), mailUtils.stripHTML) + .substr(0, PREVIEW_MSG_MAX_SIZE); + + if (!this._busBus.is_odoo_focused()) { + this._outOfFocusUnreadMessageCounter++; + var tabTitle = _.str.sprintf( + _t("%d Messages"), + this._outOfFocusUnreadMessageCounter + ); + this.trigger_up('set_title_part', { + part: '_chat', + title: tabTitle + }); + } + + this.call('bus_service', 'sendNotification', title, content); + }, + /** + * Open the thread in the Discuss app + * + * @private + * @param {integer|string} threadID a valid threadID + */ + _openThreadInDiscuss: function (threadID) { + var thread = this.getThread(threadID); + this._mailBus.trigger('open_thread_in_discuss', thread); + }, + /** + * @private + * @param {string} resModel + * @param {integer} resID + */ + _redirectDefault: function (resModel, resID) { + var self = this; + this._rpc({ + model: resModel, + method: 'get_formview_id', + args: [[resID], session.user_context], + }) + .then(function (viewID) { + self._redirectToDocument(resModel, resID, viewID); + }); + }, + /** + * @private + * @param {Object} message + * @param {Array} message.channel_ids list of integers and strings, + * where strings for static channels, e.g. 'mailbox_inbox'. + * @param {string} resModel + * @param {integer} resID + * @param {string} viewID + */ + _redirectToDocument: function (resModel, resID, viewID) { + this.do_action({ + type: 'ir.actions.act_window', + view_type: 'form', + view_mode: 'form', + res_model: resModel, + views: [[viewID || false, 'form']], + res_id: resID, + }); + }, + /** + * @private + * @param {string} resModel 'res.partner' + * @param {integer} resID + * @param {function} dmRedirection a function callback that has + * threadID as input + */ + _redirectPartner: function (resModel, resID, dmRedirection) { + var self = this; + var domain = [['partner_id', '=', resID]]; + this._rpc({ + model: 'res.users', + method: 'search', + args: [domain], + }) + .then(function (userIDs) { + if ( + userIDs.length && + userIDs[0] !== session.uid && + dmRedirection + ) { + self.createChannel(resID, 'dm').then(dmRedirection); + } else { + self._redirectToDocument(resModel, resID); + } + }); + }, + /** + * Remove channel + * + * This is only called by the mail_notification_manager + * + * @private + * @param {mail.model.Channel} [channel] + */ + _removeChannel: function (channel) { + if (!channel) { return; } + if (channel.getType() === 'dm') { + var index = this._pinnedDmPartners.indexOf(channel.getDirectPartnerID()); + if (index > -1) { + this._pinnedDmPartners.splice(index, 1); + this._busBus.update_option( + 'bus_presence_partner_ids', + this._pinnedDmPartners + ); + } + } + this._threads = _.without(this._threads, channel); + }, + /** + * Removes a message from a thread. + * + * @private + * @param {string} threadID + * @param {mail.model.Message} message + */ + _removeMessageFromThread: function (threadID, message) { + message.removeThread(threadID); + var thread = _.find(this._threads, function (thread) { + return thread.getID() === threadID; + }); + thread.removeMessage(message.getID()); + }, + /** + * @private + */ + _resetOutOfFocusUnreadMessageCounter: function () { + this._outOfFocusUnreadMessageCounter = 0; + }, + /** + * Extend the research to all users + * + * @private + * @param {string} searchVal + * @param {integer} limit + * @returns {$.Promise} fetched partners matching 'searchVal' + */ + _searchPartnerFetch: function (searchVal, limit) { + return this._rpc({ + model: 'res.partner', + method: 'im_search', + args: [searchVal, limit || 20], + }, { shadow: true }); + }, + /** + * Search among prefetched partners + * + * @private + * @param {string} searchVal + * @param {integer} limit + * @returns {string[]} partner suggestions that match searchVal + * (max limit, exclude session partner) + */ + _searchPartnerPrefetch: function (searchVal, limit) { + var values = []; + var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(searchVal)), 'i'); + _.each(this._mentionPartnerSuggestions, function (partners) { + if (values.length < limit) { + values = values.concat(_.filter(partners, function (partner) { + return (session.partner_id !== partner.id) && + searchRegexp.test(partner.name); + })).splice(0, limit); + } + }); + return values; + }, + /** + * Sort previews + * + * 1. unread, + * 2. chat, + * 3. date, + * + * @private + * @param {Object[]} previews + * @returns {Object[]} sorted list of previews + */ + _sortPreviews: function (previews) { + var res = previews.sort(function (p1, p2) { + var unreadDiff = Math.min(1, p2.unreadCounter) - Math.min(1, p1.unreadCounter); + var isChatDiff = p2.isChat - p1.isChat; + var dateDiff = (!!p2.date - !!p1.date) || + ( + p2.date && + p2.date.diff(p1.date) + ); + return unreadDiff || isChatDiff || dateDiff; + }); + return res; + }, + /** + * Sort threads + * + * In case of mailboxes (Inbox, Starred), the name is translated + * thanks to _lt (lazy translate). In this case, channel.getName() is an + * object, not a string. + * + * @private + */ + _sortThreads: function () { + this._threads = _.sortBy(this._threads, function (thread) { + var name = thread.getName(); + return _.isString(name) ? name.toLowerCase() : ''; + }); + }, + /** + * Update internal state from server data (mail/init_messaging rpc result) + * + * @private + * @param {Object} result data from server on mail/init_messaging rpc + * @param {Object[]} result.mention_partner_suggestions list of suggestions + * with all the employees + * @param {integer} result.menu_id the menu ID of discuss app + */ + _updateFromServer: function (result) { + // commands are needed for channel instantiation + this._initializeCommands(result); + this._initializeChannels(result); + this._initializeModerationSettings(result); + this._initializeMailboxes(result); + this._initializeMailFailures(result); + this._initializeCannedResponses(result); + + this._mentionPartnerSuggestions = result.mention_partner_suggestions; + this._discussMenuID = result.menu_id; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {boolean} open + */ + _onDiscussOpen: function (open) { + this._discussOpen = open; + }, + /** + * Reset out of focus unread message counter + tab title + * + * @private + */ + _onWindowFocus: function () { + this._resetOutOfFocusUnreadMessageCounter(); + this.trigger_up('set_title_part', { part: '_chat' }); + }, +}); + +return MailManager; + +}); diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js new file mode 100644 index 00000000000..cd0ff9223a0 --- /dev/null +++ b/addons/mail/static/src/js/services/mail_notification_manager.js @@ -0,0 +1,484 @@ +odoo.define('mail.Manager.Notification', function (require) { +"use strict"; + +/** + * Mail Notification Manager + * + * This part of the mail manager is responsible for receiving notifications on + * the longpoll bus, which are data received from the server. + */ +var MailManager = require('mail.Manager'); +var MailFailure = require('mail.model.MailFailure'); + +var core = require('web.core'); + +var _t = core._t; + +MailManager.include({ + + start: function () { + this._super.apply(this, arguments); + this._busBus.on('notification', this, this._onNotification); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Remove channel notifications if there is an unsubscribe notification + * on this channel. + * + * @private + * @param {Object[]} notifications + * @returns {Object[]} notifications filtered of channel notifications + * matching unsubscribe notifs + */ + _filterNotificationsOnUnsubscribe: function (notifications) { + var unsubscribedNotif = _.find(notifications, function (notif) { + return notif[1].info === 'unsubscribe'; + }); + if (unsubscribedNotif) { + notifications = _.reject(notifications, function (notif) { + return notif[0][1] === 'mail.channel' && + notif[0][2] === unsubscribedNotif[1].id; + }); + } + return notifications; + }, + /** + * Called when a new or updated message is received on a channel + * + * @private + * @param {Object} messageData + * @param {Array} messageData.channel_ids list of integers and strings, + * where strings for static channels, e.g. 'mailbox_inbox'. + */ + _handleChannelNotification: function (messageData) { + var self = this; + var def; + var channelAlreadyInCache = true; + if (messageData.channel_ids.length === 1) { + channelAlreadyInCache = !!this.getChannel(messageData.channel_ids[0]); + def = this.joinChannel(messageData.channel_ids[0], { autoswitch: false }); + } else { + def = $.when(); + } + def.then(function () { + // don't increment unread if channel wasn't in cache yet as + // its unread counter has just been fetched + self.addMessage(messageData, { + showNotification: true, + incrementUnread: channelAlreadyInCache + }); + }); + }, + /** + * On message becoming a need action (pinned to inbox) + * + * @private + * @param {Object} messageData + * @param {integer[]} messageData.channel_ids + */ + _handleNeedactionNotification: function (messageData) { + var self = this; + var inbox = this.getMailbox('inbox'); + var message = this.addMessage(messageData, { + incrementUnread: true, + showNotification: true, + }); + inbox.incrementMailboxCounter(); + _.each(message.getThreadIDs(), function (threadID) { + var channel = self.getChannel(threadID); + if (channel) { + channel.incrementNeedactionCounter(); + } + }); + this._mailBus.trigger('update_needaction', inbox.getMailboxCounter()); + }, + /** + * Called when an activity record has been updated on the server + * + * @private + * @param {Object} data key, value to decide activity created or deleted + */ + _handlePartnerActivityUpdateNotification: function (data) { + this._mailBus.trigger('activity_updated', data); + }, + /** + * Called when a channel has been seen, and the server responses with the + * last message seen. Useful in order to track last message seen. + * + * @private + * @param {Object} data + * @param {integer} data.id ID of a channel + * @param {integer} [data.last_message_id] mandatory if 'id' refers to an + * existing channel. + */ + _handlePartnerChannelSeenNotification: function (data) { + var channel = this.getChannel(data.id); + if (channel) { + channel.setLastSeenMessageID(data.last_message_id); + if (channel.hasUnreadMessages()) { + channel.resetUnreadCounter(); + } + } + }, + /** + * Called when receiving a channel state as a partner notification: + * + * - if it is a new channel, it means we have been invited to this channel + * - if it is an existing channel, it means the window state of the channel + * may have changed (as this state is stored server-side) + * + * @private + * @param {Object} channelData + * @param {integer} channelData.id + * @param {boolean} channelData.is_minimized + * @param {string} channelData.state + */ + _handlePartnerChannelNotification: function (channelData) { + if ( + (channelData.channel_type === 'channel') && + (channelData.state === 'open') + ) { + // invited to a new channel + this._addChannel(channelData, { autoswitch: false }); + if ( + !channelData.is_minimized && + channelData.info !== 'creation' + ) { + this.do_notify( + _t("Invitation"), + _t("You have been invited to: ") + channelData.name); + } + } + var channel = this.getChannel(channelData.id); + if (channel) { + channel.updateWindowState({ + folded: channelData.state === 'folded' ? true : false, + detached: channelData.is_minimized, + }); + } + }, + /** + * Add or remove failure when receiving a failure update message + * + * @private + * @param {Object} data + * @param {Object[]} data.elements list of mail failure data + * @param {string} data.elements[].message_id ID of related message that + * has a mail failure. + * @param {Array} data.elements[].notifications list of notifications + * that is related to a mail failure. + * @param {string} data.elements[].notifications[0] sending state of a mail + * failure (e.g. 'exception'). + */ + _handlePartnerMailFailureNotification: function (datas) { + var self = this; + _.each(datas.elements, function (data) { + var isNewFailure = _.some(data.notifications, function (notif) { + return notif[0] === 'exception' || notif[0] === 'bounce'; + }); + var matchedFailure = _.find(self._mailFailures, function (failure) { + return failure.getMessageID() === data.message_id; + }); + if (matchedFailure) { + var index = _.findIndex(self._mailFailures, matchedFailure); + if (isNewFailure) { + self._mailFailures[index] = new MailFailure(self, data); + } else { + self._mailFailures.splice(index, 1); + } + } else if (isNewFailure) { + self._mailFailures.push(new MailFailure(self, data)); + } + var message = _.find(self._messages, function (msg) { + return msg.getID() === data.message_id; + }); + if (message) { + if (isNewFailure) { + message.updateCustomerEmailStatus('exception'); + } else { + message.updateCustomerEmailStatus('sent'); + } + self._updateMessageNotificationStatus(data, message); + self._mailBus.trigger('update_message', message); + } + }); + this._mailBus.trigger('update_needaction', this.needactionCounter); + }, + /** + * Updates mailbox_inbox when a message has marked as read. + * + * @private + * @param {Object} data + * @param {integer[]} [data.channel_ids] + * @param {integer[]} [data.message_ids] + * @param {string} [data.type] + */ + _handlePartnerMarkAsReadNotification: function (data) { + var self = this; + _.each(data.message_ids, function (messageID) { + var message = _.find(self._messages, function (msg) { + return msg.getID() === messageID; + }); + if (message) { + self._removeMessageFromThread('mailbox_inbox', message); + self._mailBus.trigger('update_message', message, data.type); + } + }); + if (data.channel_ids) { + _.each(data.channel_ids, function (channelID) { + var channel = self.getChannel(channelID); + if (channel) { + channel.decrementNeedactionCounter(data.message_ids.length, 0); + } + }); + } else { + // if no channel_ids specified, this is a 'mark all read' in inbox + _.each(this._threads, function (thread) { + thread.resetNeedactionCounter(); + }); + } + var inbox = this.getMailbox('inbox'); + inbox.decrementMailboxCounter(data.message_ids.length); + this._mailBus.trigger('update_needaction', inbox.getMailboxCounter()); + }, + /** + * On receiving a message made by the current user, on a moderated channel, + * which is pending moderation. + * + * @private + * @param {Object} data + * @param {Object} data.message server-side data of the message + */ + _handlePartnerMessageAuthorNotification: function (data) { + this.addMessage(data.message); + }, + /** + * Notification to delete several messages locally + * Useful when a pending moderation message has been rejected, so that + * this message should not be displayed anymore. + * + * @private + * @param {Object} data + * @param {Object[]} [data.message_ids] IDs of messages to delete locally. + */ + _handlePartnerMessageDeletionNotification: function (data) { + var self = this; + _.each(data.message_ids, function (messageID) { + var message = self.getMessage(messageID); + if (message) { + message.setModerationStatus('rejected'); + } + }); + }, + /** + * On receiving a message pending moderation, and current user is moderator + * of such message. + * + * @param {Object} data notification data + * @param {Object} data.message data of message + */ + _handlePartnerMessageModeratorNotification: function (data) { + this.addMessage(data.message); + this._mailBus.trigger('update_moderation_counter'); + }, + /** + * On receiving a notification that is specific to a user + * + * @private + * @param {Object} data structure depending on the type + * @param {integer} data.id + */ + _handlePartnerNotification: function (data) { + if (data.info === 'unsubscribe') { + this._handlePartnerUnsubscribeNotification(data); + } else if (data.type === 'toggle_star') { + this._handlePartnerToggleStarNotification(data); + } else if (data.type === 'mark_as_read') { + this._handlePartnerMarkAsReadNotification(data); + } else if (data.type === 'moderator') { + this._handlePartnerMessageModeratorNotification(data); + } else if (data.type === 'author') { + this._handlePartnerMessageAuthorNotification(data); + } else if (data.type === 'deletion') { + this._handlePartnerMessageDeletionNotification(data); + } else if (data.info === 'channel_seen') { + this._handlePartnerChannelSeenNotification(data); + } else if (data.info === 'transient_message') { + this._handlePartnerTransientMessageNotification(data); + } else if (data.type === 'activity_updated') { + this._handlePartnerActivityUpdateNotification(data); + } else if (data.type === 'mail_failure') { + this._handlePartnerMailFailureNotification(data); + } else { + this._handlePartnerChannelNotification(data); + } + }, + /** + * On toggling on or off the star status of one or several messages. + * As the information is stored server-side, the web client must adapt + * itself from server's data on the messages. + * + * @private + * @param {Object} data + * @param {integer[]} data.message_ids IDs of messages that have a change + * of their starred status. + * @param {boolean} data.starred states whether the messages with id in + * `data.message_ids` have become either starred or unstarred + */ + _handlePartnerToggleStarNotification: function (data) { + var self = this; + var starred = this.getMailbox('starred'); + _.each(data.message_ids, function (messageID) { + var message = _.find(self._messages, function (msg) { + return msg.getID() === messageID; + }); + if (message) { + message.setStarred(data.starred); + if (!message.isStarred()) { + self._removeMessageFromThread('mailbox_starred', message); + } else { + self._addMessageToThreads(message, []); + var channelStarred = self.getMailbox('starred'); + channelStarred.invalidateCaches(); + } + self._mailBus.trigger('update_message', message); + } + }); + + if (data.starred) { + // increase starred counter if message is marked as star + starred.incrementMailboxCounter(data.message_ids.length); + } else { + // decrease starred counter if message is remove from starred + // if unstar_all then it will set to 0. + starred.decrementMailboxCounter(data.message_ids.length); + } + + this._mailBus.trigger('update_starred', starred.getMailboxCounter()); + }, + /** + * On receiving a transient message, i.e. a message which does not come from + * a member of the channel. Usually a log message, such as one generated + * from a command with ('/'). + * + * @private + * @param {Object} data + * @param {string} data.author_id + */ + _handlePartnerTransientMessageNotification: function (data) { + var lastMessage = _.last(this._messages); + data.id = (lastMessage ? lastMessage.getID() : 0) + 0.01; + data.author_id = data.author_id || this.getOdoobotID(); + this.addMessage(data); + }, + /** + * On receiving a unsubscribe from channel notification, confirm + * unsubscription from channel and adapt screen accordingly. + * + * @private + * @param {Object} data + * @param {Object} data.id ID of the unsubscribed channel + */ + _handlePartnerUnsubscribeNotification: function (data) { + var channel = this.getChannel(data.id); + if (channel) { + var message; + if (_.contains(['public', 'private'], channel.getType())) { + message = _.str.sprintf( + _t("You unsubscribed from %s."), + channel.getName() + ); + } else { + message = _.str.sprintf( + _t("You unpinned your conversation with %s."), + channel.getName() + ); + } + this._removeChannel(channel); + this._mailBus.trigger('unsubscribe_from_channel', data.id); + this.do_notify(_("Unsubscribed"), message); + } + }, + /** + * On receiving an update on user status (e.g. becoming 'online', 'offline', + * 'idle', etc.). + * + * @private + * @param {Object} data partner infos + * @param {integer} data.id + * @param {string} data.im_status + */ + _handlePresenceNotification: function (data) { + var dm = this.getDmFromPartnerID(data.id); + if (dm) { + dm.setStatus(data.im_status); + this._mailBus.trigger('update_dm_presence', dm); + } + }, + /** + * Update the message notification status of message based on update_message + * + * @private + * @param {Object} data + * @param {Object[]} data.notifications + * @param {mail.model.Message} message + */ + _updateMessageNotificationStatus: function (data, message) { + _.each(data.notifications, function (notif, id) { + var partnerName = notif[1]; + var notifStatus = notif[0]; + var res = _.find(message.getCustomerEmailData(), function (entry) { + return entry[0] === parseInt(id); + }); + if (res) { + res[2] = notifStatus; + } else { + message.addCustomerEmailData([parseInt(id), partnerName, notifStatus]); + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Notification handlers + * Sometimes, the web client receives unsubscribe notification and an extra + * notification on that channel. This is then followed by an attempt to + * rejoin the channel that we just left. The next few lines remove the + * extra notification to prevent that situation to occur. + * + * @private + * @param {Array} notifs + */ + _onNotification: function (notifs) { + var self = this; + notifs = this._filterNotificationsOnUnsubscribe(notifs); + _.each(notifs, function (notif) { + var model = notif[0][1]; + if (model === 'ir.needaction') { + // new message in the inbox + self._handleNeedactionNotification(notif[1]); + } else if (model === 'mail.channel') { + // new message in a channel + self._handleChannelNotification(notif[1]); + } else if (model === 'res.partner') { + // channel joined/left, message marked as read/(un)starred, + // chat open/closed, moderation specific, etc. + self._handlePartnerNotification(notif[1]); + } else if (model === 'bus.presence') { + // update presence of users + self._handlePresenceNotification(notif[1]); + } + }); + }, +}); + +return MailManager; + +}); diff --git a/addons/mail/static/src/js/services/mail_service.js b/addons/mail/static/src/js/services/mail_service.js new file mode 100644 index 00000000000..9815b23b6de --- /dev/null +++ b/addons/mail/static/src/js/services/mail_service.js @@ -0,0 +1,15 @@ +odoo.define('mail.Service', function (require) { +"use strict"; + +var core = require('web.core'); + +var MailManager = require('mail.Manager'); + +require('mail.Manager.Notification'); +require('mail.Manager.Window'); + +core.serviceRegistry.add('mail_service', MailManager); + +return MailManager; + +}); diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js new file mode 100644 index 00000000000..c72d6418f08 --- /dev/null +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -0,0 +1,684 @@ +odoo.define('mail.Manager.Window', function (require) { +"use strict"; + +var MailManager = require('mail.Manager'); +var ThreadWindow = require('mail.ThreadWindow'); + +var config = require('web.config'); +var core = require('web.core'); +var utils = require('web.utils'); + +var QWeb = core.qweb; + +var THREAD_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows + +/** + * Mail Window Manager + * + * This part of the mail manager is responsible for the management of thread + * windows. + */ +MailManager.include({ + + // tell where to append thread window + THREAD_WINDOW_APPENDTO: 'body', + + start: function () { + this._super.apply(this, arguments); + + this._availableSlotsForThreadWindows = 0; + this._hiddenThreadWindows = []; + // used to keep dropdown open when closing thread windows + this._keepHiddenThreadWindowsDropdownOpen = false; + this._spaceLeftForThreadWindows = 0; + this._threadWindows = []; + // jQuery element for the dropdown of hidden thread windows + // see _renderHiddenThreadWindowsDropdown + this._$hiddenThreadWindowsDropdown = null; + + this._mailBus + .on('update_message', this, this._onUpdateMessage) + .on('new_message', this, this._onNewMessage) + .on('new_channel', this, this._onNewChannel) + .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) + .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel) + .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter) + .on('update_dm_presence', this, this._onUpdateDmPresence); + + core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100)); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Open the blank thread window (i.e. the thread window without any thread + * linked to it). Make it if there is no blank thread window yet. + */ + openBlankThreadWindow: function () { + var blankThreadWindow = this._getBlankThreadWindow(); + if (!blankThreadWindow) { + blankThreadWindow = new ThreadWindow(this, null); + this._addThreadWindow(blankThreadWindow); + blankThreadWindow.appendTo(this.THREAD_WINDOW_APPENDTO) + .then(this._repositionThreadWindows.bind(this)); + } else { + if (blankThreadWindow.isHidden()) { + this._makeThreadWindowVisible(blankThreadWindow); + } else if (blankThreadWindow.isFolded()) { + blankThreadWindow.toggleFold(false); + } + } + }, + /** + * Open a DM in a thread window. This is useful when selecting a DM in the + * blank thread window, so that it replaces it with the DM window. + * + * @param {integer} partnerID + */ + openDmWindow: function (partnerID) { + var dm = this.getDmFromPartnerID(partnerID); + if (!dm) { + this._openAndDetachDm(partnerID); + } else { + this.openThreadWindow(dm.getID()); + } + }, + /** + * Open the thread window if discuss is not opened + * + * @override + * @param {integer|string} threadID + */ + openThread: function (threadID) { + if (!this._isDiscussOpen()) { + var thread = this.getThread(threadID); + if (thread) { + thread.detach(); + } + } else { + this._super.apply(this, arguments); + } + }, + /** + * Open a thread in a thread window + * + * @param {integer} threadID a valid thread ID + * @param {Object} [options] + * @param {boolean} [options.passively] if set to true, open the thread + * window without focusing the input and marking messages as read if it + * is not open yet, and do nothing otherwise. + * @param {boolean} [options.keepFoldState=false] if set to true, keep the + * fold state of the thread + */ + openThreadWindow: function (threadID, options) { + var self = this; + options = options || {}; + // valid threadID, therefore no check + var thread = this.getThread(threadID); + var threadWindow = this._getThreadWindow(threadID); + if (!threadWindow) { + threadWindow = this._makeNewThreadWindow(thread, options); + this._placeNewThreadWindow(threadWindow, options.passively); + + threadWindow.appendTo($(this.THREAD_WINDOW_APPENDTO)) + .then(function () { + self._repositionThreadWindows(); + return thread.getMessages(); + }).then(function (messages) { + threadWindow.render(messages); + threadWindow.threadWidget.scrollToBottom(); + // setTimeout to prevent to execute handler on first + // scrollTo, which is asynchronous + setTimeout(function () { + threadWindow.threadWidget.$el.on('scroll', null, _.debounce(function () { + if ( + !threadWindow.isPassive() && + threadWindow.threadWidget.isAtBottom() + ) { + thread.markAsRead(); + } + }, 100)); + }, 0); + if (options.passively) { + // mark first unread messages as seen when focusing the + // window, then on scroll to bottom as usual + threadWindow.$('.o_mail_thread, .o_thread_composer') + .one('click', function () { + thread.markAsRead(); + }); + } else if ( + !self._areThreadWindowsHidden() && + !thread.isFolded() + ) { + thread.markAsRead(); + } + }); + } else if (!options.passively) { + if (threadWindow.isHidden()) { + this._makeThreadWindowVisible(threadWindow); + } + } + threadWindow.updateVisualFoldState(); + }, + /** + * Called when a thread has its window state that has been changed, so its + * thread window view should be changed to match the model. + * + * @param {integer|string} threadID + * @param {Object} options option to be applied on opening thread window, if + * the thread is detached + */ + updateThreadWindow: function (threadID, options) { + var thread = this.getThread(threadID); + if (thread) { + if (thread.isDetached()) { + _.extend(options, { keepFoldState: true }); + this.openThreadWindow(threadID, options); + } else { + this._closeThreadWindow(threadID); + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add the thread window such that it will be the left-most visible window + * + * @private + * @param {mail.ThreadWindow} threadWindow + */ + _addThreadWindow: function (threadWindow) { + this._computeAvailableSlotsForThreadWindows(this._threadWindows.length+1); + this._threadWindows.splice(this._availableSlotsForThreadWindows-1, 0, threadWindow); + }, + /** + * States whether the thread windows are hidden or not. + * When discuss is open, the thread windows are hidden. + * + * @private + * @returns {boolean} + */ + _areThreadWindowsHidden: function () { + return this._isDiscussOpen(); + }, + /** + * Close the thread window linked to the thread with ID `threadID`. + * If there is no window linked to this thread, do nothing. + * + * @private + * @param {integer|string} threadID + */ + _closeThreadWindow: function (threadID) { + var threadWindow = _.find(this._threadWindows, function (threadWindow) { + return threadWindow.getID() === threadID; + }); + if (threadWindow) { + this._threadWindows = _.without(this._threadWindows, threadWindow); + this._repositionThreadWindows(); + threadWindow.destroy(); + } + }, + /** + * Compute the number of available slots to display thread windows on the + * screen. This is based on the width of the screen, and the width of a + * single thread window. + * + * The available slots attributes are updated as a consequence of this + * method call. + * + * @private + * @param {integer} nbWindows + */ + _computeAvailableSlotsForThreadWindows: function (nbWindows) { + if (config.device.isMobile) { + // one thread window full screen in mobile + this._availableSlotsForThreadWindows = 1; + return; + } + var width = window.innerWidth; + var availableSlots = Math.floor(width/THREAD_WINDOW_WIDTH); + var spaceLeft = width - (Math.min(availableSlots, nbWindows)*THREAD_WINDOW_WIDTH); + if (availableSlots < nbWindows && spaceLeft < 50) { + // leave at least 50px for the hidden windows dropdown button + availableSlots--; + spaceLeft += THREAD_WINDOW_WIDTH; + } + this._availableSlotsForThreadWindows = availableSlots; + this._spaceLeftForThreadWindows = spaceLeft; + }, + /** + * Get the blank thread window, which is the special thread window that has + * no thread linked to it. + * + * This is useful in case a DM window may replace the blank thread window, + * when we want to open a DM from the blank thread window. + * + * @private + * @returns {mail.ThreadWindow|undefined} the "blank thread" window, + * if it exists, otherwise undefined + */ + _getBlankThreadWindow: function () { + return _.find(this._threadWindows, function (threadWindow) { + return threadWindow.getID() === '_blank'; + }); + }, + /** + * Get thread window in the hidden windows matching ID `threadID`. + * + * Note: hidden windows are open windows that cannot be displayed + * due to having more thread windows open than available slots for thread + * windows on the screen. These thread windows are displayed in the hidden + * thread window dropdown menu. + * + * @private + * @param {integer|string} threadID + * @returns {mail.ThreadWindow|undefined} the hidden thread window, + * if exists + */ + _getHiddenThreadWindow: function (threadID) { + return _.find(this._hiddenThreadWindows, function (threadWindow) { + return threadWindow.getID() === threadID; + }); + }, + /** + * Get thread window matching ID `threadID` + * + * @private + * @param {integer} threadID + * @returns {mail.ThreadWindow|undefined} the thread window, if exists + */ + _getThreadWindow: function (threadID) { + return _.find(this._threadWindows, function (threadWindow) { + return threadWindow.getID() === threadID; + }); + }, + /** + * Make the hidden thread window dropdown menu, that is render it and set + * event listener on this dropdown menu DOM element. + * + * @private + */ + _makeHiddenThreadWindowsDropdown: function () { + var self = this; + if (this._$hiddenThreadWindowsDropdown) { + this._$hiddenThreadWindowsDropdown.remove(); + } + if (this._hiddenThreadWindows.length) { + this._$hiddenThreadWindowsDropdown = this._renderHiddenThreadWindowsDropdown(); + var $hiddenWindowsDropdown = this._$hiddenThreadWindowsDropdown; + $hiddenWindowsDropdown.css({right: THREAD_WINDOW_WIDTH * this._availableSlotsForThreadWindows, bottom: 0 }) + .appendTo(this.THREAD_WINDOW_APPENDTO); + this._repositionHiddenWindowsDropdown(); + this._keepHiddenThreadWindowsDropdownOpen = false; + + $hiddenWindowsDropdown + .on('click', '.o_thread_window_header', function (ev) { + var threadID = $(ev.currentTarget).data('thread-id'); + var threadWindow = self._getHiddenThreadWindow(threadID); + if (threadWindow) { + self._makeThreadWindowVisible(threadWindow); + } + }) + .on('click', '.o_thread_window_close', function (ev) { + var threadID = $(ev.currentTarget).closest('.o_thread_window_header') + .data('thread-id'); + var threadWindow = self._getHiddenThreadWindow(threadID); + if (threadWindow) { + threadWindow.close(); + // keep the dropdown open + self._keepHiddenThreadWindowsDropdownOpen = true; + } + }); + } + }, + /** + * Make a new thread window linked to a thread. + * + * @private + * @param {mail.model.Thread} thread + * @param {Object} options + * @param {boolean} [options.passively=false] + */ + _makeNewThreadWindow: function (thread, options) { + return new ThreadWindow(this, thread, _.extend(options, { + autofocus: !options.passively, + })); + }, + /** + * Make an open thread window fully visible on screen. + * + * This method assumes that the thread window is hidden (i.e. in the hidden + * dropdown menu). To make it visible, it swap the position of this thread + * window with the last thread window that is visible (i.e. the left-most + * visible thread window). + * + * @private + * @param {mail.ThreadWindow} threadWindow + */ + _makeThreadWindowVisible: function (threadWindow) { + utils.swap( + this._threadWindows, + threadWindow, + this._threadWindows[this._availableSlotsForThreadWindows-1] + ); + this._repositionThreadWindows(); + threadWindow.toggleFold(false); + }, + /** + * Open and detach the DM in a thread window. + * + * This method assumes that no such DM exists locally, so it is kind of a + * "create DM and open DM window" operation + * + * @private + * @param {integer} partnerID + * @returns {$.Promise} resolved with ID of the dm channel + */ + _openAndDetachDm: function (partnerID) { + return this._rpc({ + model: 'mail.channel', + method: 'channel_get_and_minimize', + args: [[partnerID]], + }) + .then(this._addChannel.bind(this)); + }, + /** + * On opening a new thread window, place it with other thread windows: + * + * - if it has been open with the blank thread window, replace the blank + * thread window with this one + * - if it has been open passively, simply but it after all windows + * - otherwise, make it the left-most visible thread window + * + * @param {mail.ThreadWindow} threadWindow a thread window that is linked + * to a thread (this must not be the blank thread window) + * @param {boolean} [passively=false] if set, if the thread window does not + * replace the blank thread window, it is add at the tail of the list of + * thread windows, which might be put in the thread window hidden dropdown + * menu if there are not enough space on the screen. + */ + _placeNewThreadWindow: function (threadWindow, passively) { + var thread = this.getThread(threadWindow.getID()); + // replace the blank thread window? + // the thread window should be a DM + var blankThreadWindow = this._getBlankThreadWindow(); + if ( + blankThreadWindow && + thread.getType() === 'dm' && + thread.getDirectPartnerID() === blankThreadWindow.directPartnerID + ) { + // the window takes the place of the 'blank' thread window + var index = _.indexOf(this._threadWindows, blankThreadWindow); + this._threadWindows[index] = threadWindow; + blankThreadWindow.destroy(); + } else if (passively) { + // simply insert the window to the left + this._threadWindows.push(threadWindow); + } else { + // add window such that it is visible + this._addThreadWindow(threadWindow); + } + }, + /** + * Unfold dropdown to the left if there is enough space on the screen. + * + * @private + */ + _repositionHiddenWindowsDropdown: function () { + var $dropdownUL = this._$hiddenThreadWindowsDropdown.children('ul'); + if (this._spaceLeftForThreadWindows > $dropdownUL.width() + 10) { + $dropdownUL.addClass('dropdown-menu-right'); + } + }, + /** + * Load the template of the hidden thread window dropdown + * + * @private + * @returns {jQuery.Element} + */ + _renderHiddenThreadWindowsDropdown: function () { + var $dropdown = $(QWeb.render('mail.HiddenThreadWindowsDropdown', { + threadWindows: this._hiddenThreadWindows, + open: this._keepHiddenThreadWindowsDropdownOpen, + unreadCounter: this._hiddenThreadWindowsUnreadCounter, + widget: { + isMobile: function () { + return config.device.isMobile; + }, + }, + })); + return $dropdown; + }, + /** + * Reposition the thread windows that should be hidden on the screen. + * Thread windows that have an index equal or greater than `index` in the + * attribute `threadWindows` should be hidden. Those thread windows are put + * in the hidden thread window dropdown menu. + * + * @private + * @param {integer} startIndex the index of the first thread window to hide, + * in increasing order of the thread windows in the `threadWindows` + * attribute + */ + _repositionHiddenThreadWindows: function (startIndex) { + var hiddenWindows = []; + var hiddenUnreadCounter = 0; + var index = startIndex; + while (index < this._threadWindows.length) { + var threadWindow = this._threadWindows[index]; + hiddenWindows.push(threadWindow); + hiddenUnreadCounter += threadWindow.getUnreadCounter(); + threadWindow.do_hide(); + index++; + } + this._hiddenThreadWindows = hiddenWindows; + this._hiddenThreadWindowsUnreadCounter = hiddenUnreadCounter; + + this._makeHiddenThreadWindowsDropdown(); + }, + /** + * Reposition the thread windows, based on the size of the screen: + * + * - display thread windows by increasing order of index in + * `_threadWindows` attribute, from right to left on the screen + * - if there is no enough space to show all windows at once, display + * a dropdown menu for hidden windows. + * + * This method should be called whenever there is a change of state of a + * thread in a window. Also, when this method is called, all the windows + * are visible and stacked in the top-left corner of the screen. + * + * @private + */ + _repositionThreadWindows: function () { + if (this._areThreadWindowsHidden()) { + return; + } + this._computeAvailableSlotsForThreadWindows(this._threadWindows.length); + var availableSlots = this._availableSlotsForThreadWindows; + + this._repositionVisibleThreadWindows(availableSlots-1); + this._repositionHiddenThreadWindows(availableSlots); + }, + /** + * Reposition the thread windows that should be visible on the screen. + * + * @private + * @param {integer} count how many thread windows can should be visible, + * which are picked in the attribute `_threadWindows` in increasing index + * order of appearance in the array. constraint: + * 0 <= count < this._threadWindows.length + */ + _repositionVisibleThreadWindows: function (count) { + var index = 0; + while (index < count && index < this._threadWindows.length) { + var threadWindow = this._threadWindows[index]; + threadWindow.$el.css({ right: THREAD_WINDOW_WIDTH*index, bottom: 0 }); + threadWindow.do_show(); + index++; + } + }, + /** + * Update thread windows state of threads that have `message`. + * This is either a new message or an updated message. + * + * @private + * @param {mail.model.Message} message + * @param {boolean} [scrollBottom=false] if set, thread windows with this + * message should scroll to the bottom if the message is visible + */ + _updateThreadWindows: function (message, scrollBottom) { + var self = this; + _.each(this._threadWindows, function (threadWindow) { + if (_.contains(message.getThreadIDs(), threadWindow.getID())) { + var thread = self.getThread(threadWindow.getID()); + var messageVisible = !self._areThreadWindowsHidden() && + !threadWindow.isFolded() && + !threadWindow.isHidden() && + threadWindow.threadWidget.isAtBottom(); + if (messageVisible && !threadWindow.isPassive()) { + thread.markAsRead(); + } + thread.getMessages() + .then(function (messages) { + threadWindow.render(messages); + if (scrollBottom && messageVisible) { + threadWindow.threadWidget.scrollToBottom(); + } + }); + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {boolean} open + */ + _onDiscussOpen: function (open) { + this._super.apply(this, arguments); + + if (open) { + $(this.THREAD_WINDOW_APPENDTO).addClass('o_no_thread_window'); + } else { + $(this.THREAD_WINDOW_APPENDTO).removeClass('o_no_thread_window'); + this._repositionThreadWindows(); + } + }, + /** + * Called when someone asks window manager whether the bottom of `thread` is + * visible or not. An object `query` is provided in order to reponse on the + * key `isDisplayed`. + * + * @private + * @param {mail.model.Thread} thread + * @param {Object} query + * @param {boolean} query.isBottomVisible write on it + */ + _onIsThreadBottomVisible: function (thread, query) { + _.each(this._threadWindows, function (threadWindow) { + if ( + thread.getID() === threadWindow.getID() && + threadWindow.threadWidget.isAtBottom() && + !threadWindow.isHidden() + ) { + query.isBottomVisible = true; + } + }); + }, + /** + * Show or hide window of this channel when a new channel is added. + * + * @private + * @param {mail.model.Channel} channel + */ + _onNewChannel: function (channel) { + if (channel.isDetached()) { + this.openThreadWindow(channel.getID(), { keepFoldState: true }); + } else { + this._closeThreadWindow(channel.getID()); + } + }, + /** + * Update thread window containing this message when a new message is added. + * + * @private + * @param {Object} message + */ + _onNewMessage: function (message) { + this._updateThreadWindows(message, true); + }, + /** + * Close the thread window when unsusbscribe from a channel. + * + * @private + * @param {integer} channelID + */ + _onUnsubscribeFromChannel: function (channelID) { + this._closeThreadWindow(channelID); + }, + /** + * Called when a thread has its unread counter that has changed. + * The unread counter on the thread windows should be updated. + * + * @private + * @param {mail.model.Thread} thread + */ + _onUpdateThreadUnreadCounter: function (thread) { + var self = this; + this._hiddenThreadWindowsUnreadCounter = 0; + _.each(this._threadWindows, function (threadWindow) { + if (thread.getID() === threadWindow.getID()) { + threadWindow.updateHeader(); + if (thread.getUnreadCounter() === 0) { + threadWindow.removePassive(); + } + } + if (threadWindow.isHidden()) { + self._hiddenThreadWindowsUnreadCounter += threadWindow.getUnreadCounter(); + } + }); + if (this._$hiddenThreadWindowsDropdown) { + this._$hiddenThreadWindowsDropdown.html( + this._renderHiddenThreadWindowsDropdown().html()); + this._repositionHiddenWindowsDropdown(); + } + }, + /** + * Called when there is a change of the im status of the user linked to + * DMs. The header of the thread window should be updated accordingly, + * in order to display the correct new im status of this users. + * + * @private + * @param {mail.model.Thread} thread + */ + _onUpdateDmPresence: function (thread) { + _.each(this._threadWindows, function (threadWindow) { + if (thread.getID() === threadWindow.getID()) { + threadWindow.updateHeader(); + } + }); + }, + /** + * Called when a message has been updated. + * + * @private + * @param {Object} message + */ + _onUpdateMessage: function (message) { + this._updateThreadWindows(message, false); + }, + +}); + +return MailManager; + +}); diff --git a/addons/mail/static/src/js/systray.js b/addons/mail/static/src/js/systray.js deleted file mode 100644 index 18e9511cf25..00000000000 --- a/addons/mail/static/src/js/systray.js +++ /dev/null @@ -1,353 +0,0 @@ -odoo.define('mail.systray', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var session = require('web.session'); -var SystrayMenu = require('web.SystrayMenu'); -var time = require('web.time'); -var Widget = require('web.Widget'); -var QWeb = core.qweb; - -/** - * Menu item appended in the systray part of the navbar - * - * The menu item indicates the counter of needactions + unread messages in chat channels. When - * clicking on it, it toggles a dropdown containing a preview of each pinned channels (except - * static and mass mailing channels) with a quick link to open them in chat windows. It also - * contains a direct link to the Inbox in Discuss. - **/ -var MessagingMenu = Widget.extend({ - template:'mail.chat.MessagingMenu', - events: { - "click": "on_click", - "click .o_filter_button": "on_click_filter_button", - "click .o_new_message": "on_click_new_message", - "click .o_mail_channel_preview": "_onClickChannel", - "click .o_mail_channel_mark_read":"_onClickMarkRead", - }, - init: function () { - this._super.apply(this, arguments); - this.isMobile = config.device.isMobile; // used by the template - }, - start: function () { - this.$filter_buttons = this.$('.o_filter_button'); - this.$channels_preview = this.$('.o_mail_navbar_dropdown_channels'); - this.filter = false; - var self = this; - this.call('chat_manager', 'isReady').then(function () { - self.update_counter(); - var chatBus = self.call('chat_manager', 'getChatBus'); - chatBus.on('update_needaction', self, self.update_counter); - chatBus.on('update_channel_unread_counter', self, self.update_counter); - }); - return this._super(); - }, - is_open: function () { - return this.$el.hasClass('open'); - }, - update_counter: function () { - var messageFailures = this.call('chat_manager', 'getMessageFailures'); - var needactionCounter = this.call('chat_manager', 'getNeedactionCounter'); - var unreadConversationCounter = this.call('chat_manager', 'getUnreadConversationCounter'); - var counter = needactionCounter + unreadConversationCounter + messageFailures.length; - this.$('.o_notification_counter').text(counter); - this.$el.toggleClass('o_no_notification', !counter); - if (this.is_open()) { - this.update_channels_preview(); - } - }, - update_channels_preview: function () { - var self = this; - // Display spinner while waiting for channels preview - this.$channels_preview.html(QWeb.render('Spinner')); - this.call('chat_manager', 'isReady').then(function () { - var allChannels = self.call('chat_manager', 'getChannels'); - var channels = _.filter(allChannels, function (channel) { - if (self.filter === 'chat') { - return channel.is_chat; - } else if (self.filter === 'channels') { - return !channel.is_chat && channel.type !== 'static'; - } else { - return channel.type !== 'static'; - } - }); - var formatedFailures = []; - var messageFailures = self.call('chat_manager', 'getMessageFailures'); - _.each(messageFailures, function (messageFailure) { - var model = messageFailure.model; - var existingFailure = _.findWhere(formatedFailures, { model: model }); - if (existingFailure) { - if (existingFailure.res_id !== messageFailure.res_id) { - existingFailure.res_id = null; - existingFailure.record_name = messageFailure.model_name;//for display, will be used as subject - } - existingFailure.unread_counter ++; - } else { - messageFailure = _.clone(messageFailure); - messageFailure.unread_counter = 1; - messageFailure.id = "mail_failure"; - messageFailure.body = "An error occured sending an email"; - messageFailure.date = moment(time.str_to_datetime(messageFailure.last_message_date)); - messageFailure.displayed_author = ""; - formatedFailures.push(messageFailure); - } - }); - channels = _.union(channels, formatedFailures); - self.call('chat_manager', 'getMessages', {channelID: 'channel_inbox'}) - .then(function (messages) { - var res = []; - _.each(messages, function (message) { - message.unread_counter = 1; - var duplicatedMessage = _.findWhere(res, { - model: message.model, - 'res_id': message.res_id - }); - if (message.model && message.res_id && duplicatedMessage) { - message.unread_counter = duplicatedMessage.unread_counter + 1; - res[_.findIndex(res, duplicatedMessage)] = message; - } else { - res.push(message); - } - }); - if (self.filter === 'channel_inbox' || !self.filter) { - channels = _.union(channels, res); - } - self.call('chat_manager', 'getChannelsPreview', channels) - .then(self._render_channels_preview.bind(self)); - }); - }); - }, - _render_channels_preview: function (channels_preview) { - this.$channels_preview.html(QWeb.render('mail.chat.ChannelsPreview', { - channels: channels_preview, - })); - }, - on_click: function () { - if (!this.is_open()) { - this.update_channels_preview(); // we are opening the dropdown so update its content - } - }, - on_click_filter_button: function (event) { - event.stopPropagation(); - this.$filter_buttons.removeClass('active'); - var $target = $(event.currentTarget); - $target.addClass('active'); - this.filter = $target.data('filter'); - this.update_channels_preview(); - }, - on_click_new_message: function () { - this.call('chat_window_manager', 'openChat'); - }, - - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - /** - * When a channel is clicked on, we want to open chat/channel window - * - * @private - * @param {MouseEvent} event - */ - _onClickChannel: function (event) { - var self = this; - var channelID = $(event.currentTarget).data('channel_id'); - if (channelID === 'channel_inbox' || channelID === 'mail_failure') { - var resID = $(event.currentTarget).data('res_id'); - var resModel = $(event.currentTarget).data('res_model'); - if (resModel && resModel !== 'mail.channel' && resID) { - this.do_action({ - type: 'ir.actions.act_window', - res_model: resModel, - views: [[false, 'form']], - res_id: resID - }); - } else if (resModel && resModel !== 'mail.channel' && !resID) { - this.do_action({ - name: "Mail failures", - type: 'ir.actions.act_window', - view_mode: 'kanban,list,form', - views: [[false, 'kanban'], [false, 'list'], [false, 'form']], - target: 'current', - res_model: resModel, - domain: [['message_has_error', '=', true]], - }); - } else { - var clientChatOptions = {clear_breadcrumbs: true}; - if (resModel && resModel === 'mail.channel' && resID) { - clientChatOptions.active_id = resID; - } - this.do_action('mail.mail_channel_action_client_chat', clientChatOptions) - .then(function () { - self.trigger_up('hide_home_menu'); // we cannot 'go back to previous page' otherwise - core.bus.trigger('change_menu_section', self.call('chat_manager', 'getDiscussMenuID')); - }); - } - } else { - var channel = this.call('chat_manager', 'getChannel', channelID); - if (channel) { - this.call('chat_manager', 'openChannel', channel); - } - } - }, - /** - * When a channel Mark As Read button is clicked on, we want mark message as read - * - * @private - * @param {MouseEvent} event - */ - _onClickMarkRead: function (ev) { - ev.stopPropagation(); - var channelID = $(ev.currentTarget).data('channel_id'), - channel = this.call('chat_manager', 'getChannel', channelID); - //Mark only static channel's messages as read and clear notification - if (channelID === 'channel_inbox') { - var resID = $(ev.currentTarget).data('res_id'); - var model = $(ev.currentTarget).data('model'); - var domain = [['model', '=', model], ['res_id', '=', resID]]; - this.call('chat_manager', 'markAllAsRead', channel, domain); - } else { - this.call('chat_manager', 'markChannelAsSeen', channel); - } - }, -}); - -/** - * Menu item appended in the systray part of the navbar, redirects to the next activities of all app - */ -var ActivityMenu = Widget.extend({ - template:'mail.chat.ActivityMenu', - events: { - "click": "_onActivityMenuClick", - "click .o_mail_channel_preview": "_onActivityFilterClick", - }, - start: function () { - this.$activities_preview = this.$('.o_mail_navbar_dropdown_channels'); - this.call('chat_manager', 'getChatBus').on("activity_updated", this, this._updateCounter); - this.call('chat_manager', 'isReady').then(this._updateCounter.bind(this)); - this._updateActivityPreview(); - return this._super(); - }, - //-------------------------------------------------- - // Private - //-------------------------------------------------- - /** - * Make RPC and get current user's activity details - * @private - */ - _getActivityData: function () { - var self = this; - - return self._rpc({ - model: 'res.users', - method: 'systray_get_activities', - args: [], - kwargs: {context: session.user_context}, - }).then(function (data) { - self.activities = data; - self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0); - self.$('.o_notification_counter').text(self.activityCounter); - self.$el.toggleClass('o_no_notification', !self.activityCounter); - }); - }, - /** - * Get particular model view to redirect on click of activity scheduled on that model. - * @private - * @param {string} model - */ - _getActivityModelViewID: function (model) { - return this._rpc({ - model: model, - method: 'get_activity_view_id' - }); - }, - /** - * Check wether activity systray dropdown is open or not - * @private - * @returns {boolean} - */ - _isOpen: function () { - return this.$el.hasClass('open'); - }, - /** - * Update(render) activity system tray view on activity updation. - * @private - */ - _updateActivityPreview: function () { - var self = this; - self._getActivityData().then(function (){ - self.$activities_preview.html(QWeb.render('mail.chat.ActivityMenuPreview', { - activities : self.activities - })); - }); - }, - /** - * update counter based on activity status(created or Done) - * @private - * @param {Object} [data] key, value to decide activity created or deleted - * @param {String} [data.type] notification type - * @param {Boolean} [data.activity_deleted] when activity deleted - * @param {Boolean} [data.activity_created] when activity created - */ - _updateCounter: function (data) { - if (data) { - if (data.activity_created) { - this.activityCounter ++; - } - if (data.activity_deleted && this.activityCounter > 0) { - this.activityCounter --; - } - this.$('.o_notification_counter').text(this.activityCounter); - this.$el.toggleClass('o_no_notification', !this.activityCounter); - } - }, - //------------------------------------------------------------ - // Handlers - //----------------------------------------------------------- - /** - * Redirect to particular model view - * @private - * @param {MouseEvent} event - */ - _onActivityFilterClick: function (event) { - // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_channel_preview). - var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data()); - var context = {}; - if (data.filter === 'my') { - context['search_default_activities_overdue'] = 1; - context['search_default_activities_today'] = 1; - } else { - context['search_default_activities_' + data.filter] = 1; - } - this.do_action({ - type: 'ir.actions.act_window', - name: data.model_name, - res_model: data.res_model, - views: [[false, 'kanban'], [false, 'form']], - search_view_id: [false], - domain: [['activity_user_id', '=', session.uid]], - context:context, - }); - }, - /** - * When menu clicked update activity preview if counter updated - * @private - * @param {MouseEvent} event - */ - _onActivityMenuClick: function () { - if (!this._isOpen()) { - this._updateActivityPreview(); - } - }, -}); - -SystrayMenu.Items.push(MessagingMenu); -SystrayMenu.Items.push(ActivityMenu); - -// to test activity and messaging menus in qunit test cases we need it -return { - ActivityMenu: ActivityMenu, - MessagingMenu: MessagingMenu, -}; -}); diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js new file mode 100644 index 00000000000..d65bb7ff5db --- /dev/null +++ b/addons/mail/static/src/js/systray/systray_activity_menu.js @@ -0,0 +1,150 @@ +odoo.define('mail.systray.ActivityMenu', function (require) { +"use strict"; + +var core = require('web.core'); +var session = require('web.session'); +var SystrayMenu = require('web.SystrayMenu'); +var Widget = require('web.Widget'); +var QWeb = core.qweb; + +/** + * Menu item appended in the systray part of the navbar, redirects to the next + * activities of all app + */ +var ActivityMenu = Widget.extend({ + name: 'activity_menu', + template:'mail.systray.ActivityMenu', + events: { + 'click': '_onActivityMenuClick', + 'click .o_mail_preview': '_onActivityFilterClick', + }, + willStart: function () { + return $.when(this.call('mail_service', 'isReady')); + }, + start: function () { + this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items'); + this.call('mail_service', 'getMailBus').on('activity_updated', this, this._updateCounter); + this._updateCounter(); + this._updateActivityPreview(); + return this._super(); + }, + //-------------------------------------------------- + // Private + //-------------------------------------------------- + /** + * Make RPC and get current user's activity details + * @private + */ + _getActivityData: function () { + var self = this; + + return self._rpc({ + model: 'res.users', + method: 'systray_get_activities', + args: [], + kwargs: {context: session.user_context}, + }).then(function (data) { + self._activities = data; + self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0); + self.$('.o_notification_counter').text(self.activityCounter); + self.$el.toggleClass('o_no_notification', !self.activityCounter); + }); + }, + /** + * Get particular model view to redirect on click of activity scheduled on that model. + * @private + * @param {string} model + */ + _getActivityModelViewID: function (model) { + return this._rpc({ + model: model, + method: 'get_activity_view_id' + }); + }, + /** + * Check wether activity systray dropdown is open or not + * @private + * @returns {boolean} + */ + _isOpen: function () { + return this.$el.hasClass('open'); + }, + /** + * Update(render) activity system tray view on activity updation. + * @private + */ + _updateActivityPreview: function () { + var self = this; + self._getActivityData().then(function (){ + self._$activitiesPreview.html(QWeb.render('mail.systray.ActivityMenu.Previews', { + activities : self._activities + })); + }); + }, + /** + * update counter based on activity status(created or Done) + * @private + * @param {Object} [data] key, value to decide activity created or deleted + * @param {String} [data.type] notification type + * @param {Boolean} [data.activity_deleted] when activity deleted + * @param {Boolean} [data.activity_created] when activity created + */ + _updateCounter: function (data) { + if (data) { + if (data.activity_created) { + this.activityCounter ++; + } + if (data.activity_deleted && this.activityCounter > 0) { + this.activityCounter --; + } + this.$('.o_notification_counter').text(this.activityCounter); + this.$el.toggleClass('o_no_notification', !this.activityCounter); + } + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * Redirect to particular model view + * @private + * @param {MouseEvent} event + */ + _onActivityFilterClick: function (event) { + // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_preview). + var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data()); + var context = {}; + if (data.filter === 'my') { + context['search_default_activities_overdue'] = 1; + context['search_default_activities_today'] = 1; + } else { + context['search_default_activities_' + data.filter] = 1; + } + this.do_action({ + type: 'ir.actions.act_window', + name: data.model_name, + res_model: data.res_model, + views: [[false, 'kanban'], [false, 'form']], + search_view_id: [false], + domain: [['activity_user_id', '=', session.uid]], + context:context, + }); + }, + /** + * When menu clicked update activity preview if counter updated + * @private + * @param {MouseEvent} event + */ + _onActivityMenuClick: function () { + if (!this._isOpen()) { + this._updateActivityPreview(); + } + }, +}); + +SystrayMenu.Items.push(ActivityMenu); + +return ActivityMenu; + +}); diff --git a/addons/mail/static/src/js/systray/systray_messaging_menu.js b/addons/mail/static/src/js/systray/systray_messaging_menu.js new file mode 100644 index 00000000000..7f8a565b6c7 --- /dev/null +++ b/addons/mail/static/src/js/systray/systray_messaging_menu.js @@ -0,0 +1,329 @@ +odoo.define('mail.systray.MessagingMenu', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var SystrayMenu = require('web.SystrayMenu'); +var Widget = require('web.Widget'); +var QWeb = core.qweb; + +/** + * Menu item appended in the systray part of the navbar + * + * The menu item indicates the counter of needactions + unread messages in chat + * channels. When clicking on it, it toggles a dropdown containing a preview of + * each pinned channels (except mailbox and mass mailing channels) with a quick + * link to open them in chat windows. It also contains a direct link to the + * Inbox in Discuss. + **/ +var MessagingMenu = Widget.extend({ + name: 'messaging_menu', + template:'mail.systray.MessagingMenu', + events: { + 'click': '_onClick', + 'click .o_mail_preview': '_onClickPreview', + 'click .o_filter_button': '_onClickFilterButton', + 'click .o_new_message': '_onClickNewMessage', + 'click .o_mail_preview_mark_as_read': '_onClickPreviewMarkAsRead', + }, + /** + * @override + */ + willStart: function () { + return $.when(this._super.apply(this, arguments), this.call('mail_service', 'isReady')); + }, + /** + * @override + */ + start: function () { + this._$filterButtons = this.$('.o_filter_button'); + this._$previews = this.$('.o_mail_systray_dropdown_items'); + this._filter = false; + this._updateCounter(); + var mailBus = this.call('mail_service', 'getMailBus'); + mailBus.on('update_needaction', this, this._updateCounter); + mailBus.on('new_channel', this, this._updateCounter); + mailBus.on('update_thread_unread_counter', this, this._updateCounter); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * States whether the widget is in mobile mode or not. + * This is used by the template. + * + * @returns {boolean} + */ + isMobile: function () { + return config.device.isMobile; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when clicking on a preview related to a mail failure + * + * @private + * @param {$.Element} $target DOM of preview element clicked + */ + _clickMailFailurePreview: function ($target) { + var documentID = $target.data('document-id'); + var documentModel = $target.data('document-model'); + if (documentModel && documentID) { + this._openDocument(documentModel, documentID); + } else if (documentModel !== 'mail.channel') { + // preview of mail failures grouped to different document of same model + this.do_action({ + name: "Mail failures", + type: 'ir.actions.act_window', + view_mode: 'kanban,list,form', + views: [[false, 'kanban'], [false, 'list'], [false, 'form']], + target: 'current', + res_model: documentModel, + domain: [['message_has_error', '=', true]], + }); + } + }, + /** + * @private + * @return {boolean} whether the messaging menu is open or not. + */ + _isOpen: function () { + return this.$el.hasClass('open'); + }, + /** + * Open discuss + * + * @private + * @param {integer} [channelID] if set, auto-select this channel when + * opening the discuss app. + */ + _openDiscuss: function (channelID) { + var self = this; + var discussOptions = { clear_breadcrumbs: true }; + + if (channelID) { + discussOptions.active_id = channelID; + } + + this.do_action('mail.mail_channel_action_client_chat', discussOptions) + .then(function () { + // we cannot 'go back to previous page' otherwise + self.trigger_up('hide_home_menu'); + core.bus.trigger('change_menu_section', + self.call('mail_service', 'getDiscussMenuID')); + }); + }, + /** + * Open the document + * + * @private + * @param {string} documentModel the model of the document + * @param {integer} documentID + */ + _openDocument: function (documentModel, documentID) { + if (documentModel === 'mail.channel') { + this._openDiscuss(documentID); + } else { + this.do_action({ + type: 'ir.actions.act_window', + res_model: documentModel, + views: [[false, 'form']], + res_id: documentID + }); + } + }, + /** + * Render the list of conversation previews + * + * @private + * @param {Object} previews list of valid objects for preview rendering + * (see mail.Preview template) + */ + _renderPreviews: function (previews) { + this._$previews.html(QWeb.render('mail.systray.MessagingMenu.Previews', { + previews: previews, + })); + }, + /** + * Get and render list of previews, based on the selected filter + * + * preview shows the last message of a channel with inline format. + * There is a hack where filter "All" also shows preview of chatter + * messages (which are not channels). + * + * List of filters: + * + * 1. All + * - filter: undefined + * - previews: last messages of all non-mailbox channels, in addition + * to last messages of chatter (get from inbox) + * + * 2. Channel + * - filter: "Channels" + * - previews: last messages of all non-mailbox and non-DM channels + * + * 3. Chat + * - filter: "Chat" + * - previews: last messages of all DM channels + * + * @private + */ + _updatePreviews: function () { + // Display spinner while waiting for conversations preview + this._$previews.html(QWeb.render('Spinner')); + this.call('mail_service', 'getSystrayPreviews', this._filter) + .then(this._renderPreviews.bind(this)); + }, + /** + * Update the counter on the systray messaging menu icon. + * The counter display the number of unread messages in channels (DM included), the number of + * messages in Inbox mailbox, and the number of mail failures. + * Also updates the previews if the messaging menu is open. + * + * Note that the number of unread messages in document thread are ignored, because they are + * already considered in the number of messages in Inbox with the current design. + * Also, some unread messages in channel can also be in inbox, so they are considered twice in + * the counter. This is intended, as the number of needaction messages in a channel are + * separately considered in the messaging menu from the unread messages, even though a message + * can be both unread and needaction (such a message increments the counter twice). + * + * The global counter of the messaging menu should match the counter next to each of the preview + * item when the messaging menu is open. + * + * @private + */ + _updateCounter: function () { + var counter; + + var channels = this.call('mail_service', 'getChannels'); + var channelUnreadCounters = _.map(channels, function (channel) { + return channel.getUnreadCounter(); + }); + var unreadChannelCounter = _.reduce(channelUnreadCounters, function (c1, c2) { + return c1 + c2; + }, 0); + var inboxCounter = this.call('mail_service', 'getMailbox', 'inbox').getMailboxCounter(); + var mailFailureCounter = this.call('mail_service', 'getMailFailures').length; + + counter = unreadChannelCounter + inboxCounter + mailFailureCounter; + this.$('.o_notification_counter').text(counter); + this.$el.toggleClass('o_no_notification', !counter); + if (this._isOpen()) { + this._updatePreviews(); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClick: function () { + if (!this._isOpen()) { + // we are opening the dropdown so update its content + this._updatePreviews(); + } + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFilterButton: function (ev) { + ev.stopPropagation(); + this._$filterButtons.removeClass('active'); + var $target = $(ev.currentTarget); + $target.addClass('active'); + this._filter = $target.data('filter'); + this._updatePreviews(); + }, + /** + * @private + */ + _onClickNewMessage: function () { + this.call('mail_service', 'openBlankThreadWindow'); + }, + /** + * When a preview is clicked on, we want to open the related object + * (thread, mail failure, etc.) + * + * @private + * @param {MouseEvent} ev + */ + _onClickPreview: function (ev) { + var $target = $(ev.currentTarget); + var previewID = $target.data('preview-id'); + + if (previewID === 'mail_failure') { + this._clickMailFailurePreview($target); + } else if (previewID === 'mailbox_inbox') { + // inbox preview for non-document thread, + // e.g. needaction message of channel + var documentID = $target.data('document-id'); + var documentModel = $target.data('document-model'); + this._openDocument(documentModel, documentID); + } else { + // preview of thread + this.call('mail_service', 'openThread', previewID); + } + }, + /** + * When a preview "Mark as read" button is clicked on, we want mark message + * as read + * + * @private + * @param {MouseEvent} event + */ + _onClickPreviewMarkAsRead: function (ev) { + ev.stopPropagation(); + var thread; + var $preview = $(ev.currentTarget).closest('.o_mail_preview'); + var previewID = $preview.data('preview-id'); + if (previewID === 'mailbox_inbox') { + var documentModel = $preview.data('document-model'); + var documentID = $preview.data('document-id'); + var inbox = this.call('mail_service', 'getMailbox', 'inbox'); + var messages = inbox.getLocalMessages({ + documentModel: documentModel, + documentID: documentID, + }); + var messageIDs = _.map(messages, function (message) { + return message.getID(); + }); + this.call('mail_service', 'markMessagesAsRead', messageIDs); + } else if (previewID === 'mail_failure') { + // AKU: ignored for the moment, this should be fixed when task + // 1860054 is merged + } else { + // this is mark as read on a thread + thread = this.call('mail_service', 'getThread', previewID); + if (thread) { + thread.markAsRead(); + } + } + }, +}); + +// Systray menu items display order matches order in the list +// lower index comes first, and display is from right to left. +// For messagin menu, it should come before activity menu, if any +// otherwise, it is the next systray item. +var activityMenuIndex = _.findIndex(SystrayMenu.Items, function (SystrayMenuItem) { + return SystrayMenuItem.prototype.name === 'activity_menu'; +}); +if (activityMenuIndex > 0) { + SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu); +} else { + SystrayMenu.Items.push(MessagingMenu); +} + +return MessagingMenu; + +}); diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js index 54a36e169e4..7d4833e81cd 100644 --- a/addons/mail/static/src/js/thread.js +++ b/addons/mail/static/src/js/thread.js @@ -1,9 +1,11 @@ -odoo.define('mail.ChatThread', function (require) { +odoo.define('mail.widget.Thread', function (require) { "use strict"; +var DocumentViewer = require('mail.DocumentViewer'); +var mailUtils = require('mail.utils'); + var core = require('web.core'); var time = require('web.time'); -var DocumentViewer = require('mail.DocumentViewer'); var Widget = require('web.Widget'); var QWeb = core.qweb; @@ -14,60 +16,31 @@ var ORDER = { DESC: -1, }; -var read_more = _t('read more'); -var read_less = _t('read less'); +var READ_MORE = _t("read more"); +var READ_LESS = _t("read less"); -function time_from_now(date) { - if (moment().diff(date, 'seconds') < 45) { - return _t("now"); - } - return date.fromNow(); -} - -var Thread = Widget.extend({ +/** + * This is a generic widget to render a chat thread + */ +var ThreadWidget = Widget.extend({ className: 'o_mail_thread', events: { - "click a": "on_click_redirect", - "click img": "on_click_redirect", - "click strong": "on_click_redirect", - "click .o_thread_show_more": "on_click_show_more", - "click .o_attachment_download": "_onAttachmentDownload", - "click .o_attachment_view": "_onAttachmentView", - "click .o_thread_message_needaction": function (event) { - var messageId = $(event.currentTarget).data('message-id'); - this.trigger("mark_as_read", messageId); - }, - "click .o_thread_message_moderation": "_onClickMessageModeration", - "change .moderation_checkbox": "_onChangeModerationCheckbox", - "click .o_thread_message_star": function (event) { - var messageId = $(event.currentTarget).data('message-id'); - this.trigger("toggle_star_status", messageId); - }, - "click .o_thread_message_email_exception": "_onClickMailException", - "click .o_thread_message_reply": function (event) { - this.selected_id = $(event.currentTarget).data('message-id'); - this.$('.o_thread_message').removeClass('o_thread_selected_message'); - this.$('.o_thread_message[data-message-id="' + this.selected_id + '"]') - .addClass('o_thread_selected_message'); - this.trigger('select_message', this.selected_id); - event.stopPropagation(); - }, - "click .oe_mail_expand": function (event) { - event.preventDefault(); - var $message = $(event.currentTarget).parents('.o_thread_message'); - $message.addClass('o_message_expanded'); - this.expanded_msg_ids.push($message.data('message-id')); - }, - "click .o_thread_message": function (event) { - $(event.currentTarget).toggleClass('o_thread_selected_message'); - }, - "click": function () { - if (this.selected_id) { - this.unselect(); - this.trigger('unselect_message'); - } - }, + 'click a': '_onClickRedirect', + 'click img': '_onClickRedirect', + 'click strong': '_onClickRedirect', + 'click .o_thread_show_more': '_onClickShowMore', + 'click .o_attachment_download': '_onAttachmentDownload', + 'click .o_attachment_view': '_onAttachmentView', + 'click .o_thread_message_needaction': '_onClickMessageNeedaction', + 'click .o_thread_message_star': '_onClickMessageStar', + 'click .o_thread_message_reply': '_onClickMessageReply', + 'click .oe_mail_expand': '_onClickMailExpand', + 'click .o_thread_message': '_onClickMessage', + 'click': '_onClick', + 'click .o_thread_message_email_exception': '_onClickEmailException', + 'click .o_thread_message_moderation': '_onClickMessageModeration', + 'change .moderation_checkbox': '_onChangeModerationCheckbox', }, /** @@ -77,243 +50,211 @@ var Thread = Widget.extend({ */ init: function (parent, options) { this._super.apply(this, arguments); - this.enabledOptions = _.defaults(options || {}, { - display_order: ORDER.ASC, - display_needactions: true, - display_stars: true, - display_document_link: true, - display_avatar: true, - squash_close_messages: true, - display_email_icon: true, - display_reply_icon: false, + // options when the thread is enabled (e.g. can send message, + // interact on messages, etc.) + this._enabledOptions = _.defaults(options || {}, { + displayOrder: ORDER.ASC, + displayMarkAsRead: true, + displayStars: true, + displayDocumentLinks: true, + displayAvatars: true, + squashCloseMessages: true, + displayEmailIcons: true, + displayReplyIcons: false, loadMoreOnScroll: false, }); - this.disabledOptions = { - display_order: this.enabledOptions.display_order, - display_needactions: false, - display_stars: false, - display_document_link: false, - display_avatar: this.enabledOptions.display_avatar, - squash_close_messages: false, - display_email_icon: false, - display_reply_icon: false, - loadMoreOnScroll: this.enabledOptions.loadMoreOnScroll, + // options when the thread is disabled + this._disabledOptions = { + displayOrder: this._enabledOptions.displayOrder, + displayMarkAsRead: false, + displayStars: false, + displayDocumentLinks: false, + displayAvatars: this._enabledOptions.displayAvatars, + squashCloseMessages: false, + displayEmailIcons: false, + displayReplyIcons: false, + loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll, }; - this.expanded_msg_ids = []; - this.selected_id = null; + this._selectedMessageID = null; }, /** - * @param {Object[]} messages + * @override + */ + destroy: function () { + clearInterval(this._updateTimestampsInterval); + }, + + /** + * @param {mail.model.AbstractMessage[]} messages list of messages of the + * thread, ordered by increasing IDs (a higher ID means a more recent + * message). * @param {Object} [options] - * @param {integer} [options.display_order=ORDER.ASC] - * @param {boolean} [options.display_load_more] + * @param {integer} [options.displayOrder=ORDER.ASC] order of displaying + * messages in the thread: + * - ORDER.ASC: last message is at the bottom of the thread + * - ORDER.DESC: last message is at the top of the thread + * @param {boolean} [options.displayLoadMore] * @param {boolean} [options.isCreateMode] - * @param {boolean} [options.squash_close_messages] + * @param {boolean} [options.squashCloseMessages] */ render: function (messages, options) { var self = this; - var msgs = _.map(messages, this._preprocessMessage.bind(this)); - var modeOptions = options.isCreateMode ? this.disabledOptions : this.enabledOptions; - if (modeOptions.display_order === ORDER.DESC) { - msgs.reverse(); + // copy so that reverse do not alter order in the thread object + messages = _.clone(messages); + + var modeOptions = options.isCreateMode ? this._disabledOptions : + this._enabledOptions; + + // attachments ordered by messages order (increasing ID) + this.attachments = _.uniq(_.flatten(_.map(messages, function (message) { + return message.getAttachments(); + }))); + + if (modeOptions.displayOrder === ORDER.DESC) { + messages.reverse(); } - options = _.extend({}, modeOptions, options); + options = _.extend({}, modeOptions, options, { + selectedMessageID: this._selectedMessageID, + }); // Hide avatar and info of a message if that message and the previous // one are both comments wrote by the same author at the same minute // and in the same document (users can now post message in documents // directly from a channel that follows it) - var prev_msg; - _.each(msgs, function (msg) { - if (!prev_msg || (Math.abs(msg.date.diff(prev_msg.date)) > 60000) || - prev_msg.message_type !== 'comment' || msg.message_type !== 'comment' || - (prev_msg.author_id[0] !== msg.author_id[0]) || prev_msg.model !== msg.model || - prev_msg.res_id !== msg.res_id) { - msg.display_author = true; + var prevMessage; + _.each(messages, function (message) { + if ( + // is first message of thread + !prevMessage || + // more than 1 min. elasped + (Math.abs(message.getDate().diff(prevMessage.getDate())) > 60000) || + prevMessage.getType() !== 'comment' || + message.getType() !== 'comment' || + // from a different author + (prevMessage.getAuthorID() !== message.getAuthorID()) || + ( + // messages are linked to a document thread + ( + prevMessage.isLinkedToDocumentThread() && + message.isLinkedToDocumentThread() + ) && + ( + // are from different documents + prevMessage.getDocumentModel() !== message.getDocumentModel() || + prevMessage.getDocumentID() !== message.getDocumentID() + ) + ) + ) { + message.displayAuthor(true); } else { - msg.display_author = !options.squash_close_messages; + message.displayAuthor(!options.squashCloseMessages); } - if (msg.attachment_ids) { - var sortedAttachments = _.partition(msg.attachment_ids, function (att) { - return att.mimetype && att.mimetype.split('/')[0] === 'image'; - }); - msg.images = sortedAttachments[0]; - msg.others = sortedAttachments[1]; - } - prev_msg = msg; + prevMessage = message; }); - this.$el.html(QWeb.render('mail.ChatThread', { - messages: msgs, + this.$el.html(QWeb.render('mail.widget.Thread', { + messages: messages, options: options, ORDER: ORDER, - date_format: time.getLangDatetimeFormat(), + dateFormat: time.getLangDatetimeFormat(), })); - this.attachments = _.uniq(_.flatten(_.map(messages, 'attachment_ids'))); + _.each(messages, function (message) { + var $message = self.$('.o_thread_message[data-message-id="'+ message.getID() +'"]'); + $message.find('.o_mail_timestamp').data('date', message.getDate()); - _.each(msgs, function (msg) { - var $msg = self.$('.o_thread_message[data-message-id="'+ msg.id +'"]'); - $msg.find('.o_mail_timestamp').data('date', msg.date); - - self.insert_read_more($msg); + self._insertReadMore($message); }); - if (!this.update_timestamps_interval) { - this.update_timestamps_interval = setInterval(function () { - self.update_timestamps(); + if (!this._updateTimestampsInterval) { + this.updateTimestampsInterval = setInterval(function () { + self._updateTimestamps(); }, 1000*60); } }, - /** - * Modifies $element to add the 'read more/read less' functionality - * All element nodes with "data-o-mail-quote" attribute are concerned. - * All text nodes after a ``#stopSpelling`` element are concerned. - * Those text nodes need to be wrapped in a span (toggle functionality). - * All consecutive elements are joined in one 'read more/read less'. - */ - insert_read_more: function ($element) { - var self = this; + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- - var groups = []; - var read_more_nodes; - - // nodeType 1: element_node - // nodeType 3: text_node - var $children = $element.contents() - .filter(function () { - return this.nodeType === 1 || this.nodeType === 3 && this.nodeValue.trim(); - }); - - _.each($children, function (child) { - var $child = $(child); - - // Hide Text nodes if "stopSpelling" - if (child.nodeType === 3 && $child.prevAll("[id*='stopSpelling']").length > 0) { - // Convert Text nodes to Element nodes - $child = $('', { - text: child.textContent, - "data-o-mail-quote": "1", - }); - child.parentNode.replaceChild($child[0], child); - } - - // Create array for each "read more" with nodes to toggle - if ($child.attr('data-o-mail-quote') || ($child.get(0).nodeName === 'BR' && $child.prev("[data-o-mail-quote='1']").length > 0)) { - if (!read_more_nodes) { - read_more_nodes = []; - groups.push(read_more_nodes); - } - $child.hide(); - read_more_nodes.push($child); - } else { - read_more_nodes = undefined; - self.insert_read_more($child); - } - }); - - _.each(groups, function (group) { - // Insert link just before the first node - var $read_more = $('', { - class: "o_mail_read_more", - href: "#", - text: read_more, - }).insertBefore(group[0]); - - // Toggle All next nodes - var is_read_more = true; - $read_more.click(function (e) { - e.preventDefault(); - is_read_more = !is_read_more; - _.each(group, function ($child) { - $child.hide(); - $child.toggle(!is_read_more); - }); - $read_more.text(is_read_more ? read_more : read_less); - }); - }); - }, - update_timestamps: function () { - var isAtBottom = this.is_at_bottom(); - this.$('.o_mail_timestamp').each(function () { - var date = $(this).data('date'); - $(this).html(time_from_now(date)); - }); - if (isAtBottom && !this.is_at_bottom()) { - this.scroll_to(); - } - }, - on_click_redirect: function (event) { - // ignore inherited branding - if ($(event.target).data('oe-field') !== undefined) { - return; - } - var id = $(event.target).data('oe-id'); - if (id) { - event.preventDefault(); - var model = $(event.target).data('oe-model'); - var options = model && (model !== 'mail.channel') ? {model: model, id: id} : {channel_id: id}; - this._redirect(options); - } - }, - - on_click_show_more: function () { - this.trigger('load_more_messages'); - }, - - /** - * Removes a message and re-renders the thread - * @param {int} [message_id] the id of the removed message - * @param {array} [messages] the list of messages to display, without the removed one - * @param {object} [options] options for the thread rendering - */ - remove_message_and_render: function (message_id, messages, options) { - var self = this; - var done = $.Deferred(); - this.$('.o_thread_message[data-message-id="' + message_id + '"]').fadeOut({ - done: function () { self.render(messages, options); done.resolve();}, - duration: 200, - }); - return done; - }, - - /** - * Scrolls the thread to a given message or offset if any, to bottom otherwise - * @param {int} [options.id] optional: the id of the message to scroll to - * @param {int} [options.offset] optional: the number of pixels to scroll - */ - scroll_to: function (options) { - options = options || {}; - if (options.id !== undefined) { - var $target = this.$('.o_thread_message[data-message-id="' + options.id + '"]'); - if (options.only_if_necessary) { - var delta = $target.parent().height() - $target.height(); - var offset = delta < 0 ? 0 : delta - ($target.offset().top - $target.offsetParent().offset().top); - offset = - Math.min(offset, 0); - this.$el.scrollTo("+=" + offset + "px", options); - } else if ($target.length) { - this.$el.scrollTo($target); - } - } else if (options.offset !== undefined) { - this.$el.scrollTop(options.offset); - } else { - this.$el.scrollTop(this.el.scrollHeight); - } - }, - get_scrolltop: function () { + getScrolltop: function () { return this.$el.scrollTop(); }, - is_at_bottom: function () { - return this.el.scrollHeight - this.$el.scrollTop() - this.$el.outerHeight() < 5; + /** + * State whether the bottom of the thread is visible or not, + * with a tolerance of 5 pixels + * + * @return {boolean} + */ + isAtBottom: function () { + var fullHeight = this.el.scrollHeight; + var topHiddenHeight = this.$el.scrollTop(); + var visibleHeight = this.$el.outerHeight(); + var bottomHiddenHeight = fullHeight - topHiddenHeight - visibleHeight; + return bottomHiddenHeight < 5; }, - unselect: function () { - this.$('.o_thread_message').removeClass('o_thread_selected_message'); - this.selected_id = null; + /** + * Removes a message and re-renders the thread + * + * @param {integer} [messageID] the id of the removed message + * @param {mail.model.AbstractMessage[]} [messages] the list of messages to + * display, without the removed one + * @param {Object} [options] options for the thread rendering + */ + removeMessageAndRender: function (messageID, messages, options) { + var self = this; + var done = $.Deferred(); + this.$('.o_thread_message[data-message-id="' + messageID + '"]') + .fadeOut({ + done: function () { + self.render(messages, options); + done.resolve(); + }, + duration: 200, + }); + return done; }, - destroy: function () { - clearInterval(this.update_timestamps_interval); + /** + * Scroll to the bottom of the thread + */ + scrollToBottom: function () { + this.$el.scrollTop(this.el.scrollHeight); + }, + /** + * Scrolls the thread to a given message + * + * @param {integer} options.messageID the ID of the message to scroll to + * @param {integer} [options.duration] + * @param {boolean} [options.onlyIfNecessary] + */ + scrollToMessage: function (options) { + var $target = this.$('.o_thread_message[data-message-id="' + options.messageID + '"]'); + if (options.onlyIfNecessary) { + var delta = $target.parent().height() - $target.height(); + var offset = delta < 0 ? + 0 : + delta - ($target.offset().top - $target.offsetParent().offset().top); + offset = - Math.min(offset, 0); + this.$el.scrollTo("+=" + offset + "px", options.duration); + } else if ($target.length) { + this.$el.scrollTo($target); + } + }, + /** + * Scroll to the specific position in pixel + * + * If no position is provided, scroll to the bottom of the thread + * + * @param {integer} [position] distance from top to position in pixels. + * If not provided, scroll to the bottom. + */ + scrollToPosition: function (position) { + if (position) { + this.$el.scrollTop(position); + } else { + this.scrollToBottom(); + } }, /** * Toggle all the moderation checkboxes in the thread @@ -324,56 +265,132 @@ var Thread = Widget.extend({ toggleModerationCheckboxes: function (checked) { this.$('.moderation_checkbox').prop('checked', checked); }, + /** + * Unselect the selected message + */ + unselectMessage: function () { + this.$('.o_thread_message').removeClass('o_thread_selected_message'); + this._selectedMessageID = null; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Modifies $element to add the 'read more/read less' functionality + * All element nodes with 'data-o-mail-quote' attribute are concerned. + * All text nodes after a ``#stopSpelling`` element are concerned. + * Those text nodes need to be wrapped in a span (toggle functionality). + * All consecutive elements are joined in one 'read more/read less'. + * + * @private + * @param {jQuery} $element + */ + _insertReadMore: function ($element) { + var self = this; + + var groups = []; + var readMoreNodes; + + // nodeType 1: element_node + // nodeType 3: text_node + var $children = $element.contents() + .filter(function () { + return this.nodeType === 1 || + this.nodeType === 3 && + this.nodeValue.trim(); + }); + + _.each($children, function (child) { + var $child = $(child); + + // Hide Text nodes if "stopSpelling" + if ( + child.nodeType === 3 && + $child.prevAll('[id*="stopSpelling"]').length > 0 + ) { + // Convert Text nodes to Element nodes + $child = $('', { + text: child.textContent, + 'data-o-mail-quote': '1', + }); + child.parentNode.replaceChild($child[0], child); + } + + // Create array for each 'read more' with nodes to toggle + if ( + $child.attr('data-o-mail-quote') || + ( + $child.get(0).nodeName === 'BR' && + $child.prev('[data-o-mail-quote="1"]').length > 0 + ) + ) { + if (!readMoreNodes) { + readMoreNodes = []; + groups.push(readMoreNodes); + } + $child.hide(); + readMoreNodes.push($child); + } else { + readMoreNodes = undefined; + self._insertReadMore($child); + } + }); + + _.each(groups, function (group) { + // Insert link just before the first node + var $readMore = $('', { + class: 'o_mail_read_more', + href: '#', + text: READ_MORE, + }).insertBefore(group[0]); + + // Toggle All next nodes + var isReadMore = true; + $readMore.click(function (e) { + e.preventDefault(); + isReadMore = !isReadMore; + _.each(group, function ($child) { + $child.hide(); + $child.toggle(!isReadMore); + }); + $readMore.text(isReadMore ? READ_MORE : READ_LESS); + }); + }); + }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- - /** - * @private - * @param {Object} message - * @param {integer} [message.id] - * @param {string} [message.model] - * @param {string} [message.subject] - * @param {string} [message.message_type] - */ - _preprocessMessage: function (message) { - var msg = _.extend({}, message); - - msg.date = moment.min(msg.date, moment()); - msg.hour = time_from_now(msg.date); - - var date = msg.date.format('YYYY-MM-DD'); - if (date === moment().format('YYYY-MM-DD')) { - msg.day = _t("Today"); - } else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) { - msg.day = _t("Yesterday"); - } else { - msg.day = msg.date.format('LL'); - } - - if (_.contains(this.expanded_msg_ids, message.id)) { - msg.expanded = true; - } - - msg.display_subject = message.subject && message.message_type !== 'notification' && !(message.model && (message.model !== 'mail.channel')); - msg.is_selected = msg.id === this.selected_id; - return msg; - }, /** * @private * @param {Object} options - * @param {integer} [options.channel_id] + * @param {integer} [options.channelID] * @param {string} options.model * @param {integer} options.id */ _redirect: _.debounce(function (options) { - if ('channel_id' in options) { - this.trigger('redirect_to_channel', options.channel_id); + if ('channelID' in options) { + this.trigger('redirect_to_channel', options.channelID); } else { this.trigger('redirect', options.model, options.id); } }, 500, true), + /** + * @private + */ + _updateTimestamps: function () { + var isAtBottom = this.isAtBottom(); + this.$('.o_mail_timestamp').each(function () { + var date = $(this).data('date'); + $(this).html(mailUtils.timeFromNow(date)); + }); + if (isAtBottom && !this.isAtBottom()) { + this.scrollToBottom(); + } + }, //-------------------------------------------------------------------------- // Handlers @@ -407,30 +424,115 @@ var Thread = Widget.extend({ }, /** * @private - * @param {MouseEvent} ev */ - _onClickMessageModeration: function (ev) { - var $button = $(ev.currentTarget); - var messageID = $button.data('message-id'); - var decision = $button.data('decision'); - this.trigger_up('message_moderation', { messageID: messageID, decision: decision }); + _onClick: function () { + if (this._selectedMessageID) { + this.unselectMessage(); + this.trigger('unselect_message'); + } }, /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onClickMailException: function (event) { - var messageId = $(event.currentTarget).data('message-id'); + _onClickEmailException: function (ev) { + var messageID = $(ev.currentTarget).data('message-id'); this.do_action('mail.mail_resend_message_action', { - additional_context: { - mail_message_to_resend: messageId + additional_context: { + mail_message_to_resend: messageID } }); }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMailExpand: function (ev) { + ev.preventDefault(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessage: function (ev) { + $(ev.currentTarget).toggleClass('o_thread_selected_message'); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessageNeedaction: function (ev) { + var messageID = $(ev.currentTarget).data('message-id'); + this.trigger('mark_as_read', messageID); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessageReply: function (ev) { + this._selectedMessageID = $(ev.currentTarget).data('message-id'); + this.$('.o_thread_message').removeClass('o_thread_selected_message'); + this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]') + .addClass('o_thread_selected_message'); + this.trigger('select_message', this._selectedMessageID); + ev.stopPropagation(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessageStar: function (ev) { + var messageID = $(ev.currentTarget).data('message-id'); + this.trigger('toggle_star_status', messageID); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMessageModeration: function (ev) { + var $button = $(ev.currentTarget); + var messageID = $button.data('message-id'); + var decision = $button.data('decision'); + this.trigger_up('message_moderation', { + messageID: messageID, + decision: decision, + }); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRedirect: function (ev) { + // ignore inherited branding + if ($(ev.target).data('oe-field') !== undefined) { + return; + } + var id = $(ev.target).data('oe-id'); + if (id) { + ev.preventDefault(); + var model = $(ev.target).data('oe-model'); + var options; + if (model && (model !== 'mail.channel')) { + options = { + model: model, + id: id + }; + } else { + options = { channelID: id }; + } + this._redirect(options); + } + }, + /** + * @private + */ + _onClickShowMore: function () { + this.trigger('load_more_messages'); + }, }); -Thread.ORDER = ORDER; +ThreadWidget.ORDER = ORDER; -return Thread; +return ThreadWidget; }); diff --git a/addons/mail/static/src/js/thread_field.js b/addons/mail/static/src/js/thread_field.js index 6d036208eb2..d3021ca1ce5 100644 --- a/addons/mail/static/src/js/thread_field.js +++ b/addons/mail/static/src/js/thread_field.js @@ -1,7 +1,8 @@ odoo.define('mail.ThreadField', function (require) { "use strict"; -var ChatThread = require('mail.ChatThread'); +var Message = require('mail.model.Message'); +var ThreadWidget = require('mail.widget.Thread'); var AbstractField = require('web.AbstractField'); var core = require('web.core'); @@ -11,69 +12,69 @@ var session = require('web.session'); var _t = core._t; -// ----------------------------------------------------------------------------- -// 'mail_thread' widget: displays the thread of messages -// ----------------------------------------------------------------------------- +/** + * 'mail_thread' widget: displays the thread of messages + */ var ThreadField = AbstractField.extend({ - // inherited + /** + * @override + */ init: function () { this._super.apply(this, arguments); - this.msgIDs = this.value.res_ids; + this._setDocumentThread(); }, + /** + * @override + */ willStart: function () { - return this.alive(this.call('chat_manager', 'isReady')); + return this.alive(this.call('mail_service', 'isReady')); }, + /** + * @override + */ start: function () { var self = this; this.dp = new concurrency.DropPrevious(); - this.thread = new ChatThread(this, { - display_order: ChatThread.ORDER.DESC, - display_document_link: false, - display_needactions: false, - squash_close_messages: false, + this._threadWidget = new ThreadWidget(this, { + displayOrder: ThreadWidget.ORDER.DESC, + displayDocumentLinks: false, + displayMarkAsRead: false, + squashCloseMessages: false, }); - this.thread.on('load_more_messages', this, this._onLoadMoreMessages); - this.thread.on('redirect', this, this._onRedirect); - this.thread.on('redirect_to_channel', this, this._onRedirectToChannel); - this.thread.on('toggle_star_status', this, function (messageID) { - self.call('chat_manager', 'toggleStarStatus', messageID); + this._threadWidget.on('load_more_messages', this, this._onLoadMoreMessages); + this._threadWidget.on('redirect', this, this._onRedirect); + this._threadWidget.on('redirect_to_channel', this, this._onRedirectToChannel); + this._threadWidget.on('toggle_star_status', this, function (messageID) { + var message = self.call('mail_service', 'getMessage', messageID); + message.toggleStarStatus(); }); - var def1 = this.thread.appendTo(this.$el); + var def1 = this._threadWidget.appendTo(this.$el); var def2 = this._super.apply(this, arguments); return this.alive($.when(def1, def2)).then(function () { // unwrap the thread to remove an unnecessary level on div - self.setElement(self.thread.$el); - var chatBus = self.call('chat_manager', 'getChatBus'); - chatBus.on('new_message', self, self._onNewMessage); - chatBus.on('update_message', self, self._onUpdateMessage); + self.setElement(self._threadWidget.$el); + var mailBus = self.call('mail_service', 'getMailBus'); + mailBus.on('new_message', self, self._onNewMessage); + mailBus.on('update_message', self, self._onUpdateMessage); }); }, - _render: function () { - return this._fetchAndRenderThread(this.msgIDs); - }, - isSet: function () { - return true; - }, - destroy: function () { - this.call('chat_manager', 'removeChatterMessages', this.model); - this._super.apply(this, arguments); - }, - _reset: function (record) { - this._super.apply(this, arguments); - this.msgIDs = this.value.res_ids; - // the mail widgets being persistent, one need to update the res_id on reset - this.res_id = record.res_id; - }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- + /** + * @override + * @return {boolean} + */ + isSet: function () { + return true; + }, /** * @param {Object} message * @param {integer[]} message.partner_ids @@ -81,15 +82,14 @@ var ThreadField = AbstractField.extend({ */ postMessage: function (message) { var self = this; - var options = {model: this.model, res_id: this.res_id}; - return this.call('chat_manager', 'postMessage', message, options) + return this._documentThread.postMessage(message) .then(function () { if (message.partner_ids.length) { - self.trigger_up('reload_mail_fields', {followers: true}); + self.trigger_up('reload_mail_fields', { followers: true }); } }) .fail(function () { - self.do_notify(_t('Sending Error'), _t('Your message has not been sent.')); + self.do_notify(_t("Sending Error"), _t("Your message has not been sent.")); }); }, @@ -99,43 +99,80 @@ var ThreadField = AbstractField.extend({ /** * @private - * @returns {Object[]} an array containing a single message 'Creating a new record...' - */ - _forgeCreationModeMessages: function () { - return [{ - id: 0, - body: "

Creating a new record...

", - date: moment(), - author_id: [session.partner_id, session.partner_display_name], - displayed_author: session.partner_display_name, - avatar_src: "/web/image/res.partner/" + session.partner_id + "/image_small", - attachment_ids: [], - customer_email_data: [], - tracking_value_ids: [], - }]; - }, - /** - * @private - * @param {integer[]} ids * @param {Object} [options] + * @param {boolean} [options.forceFetch] + * @return {$.Deferred} */ - _fetchAndRenderThread: function (ids, options) { + _fetchAndRenderThread: function (options) { var self = this; options = options || {}; - options.ids = ids; - var fetch_def = this.dp.add(this.call('chat_manager', 'getMessages', options)); - return fetch_def.then(function (raw_messages) { + options.ids = this._messageIDs; + var fetchDef = this.dp.add(this._documentThread.getMessages(options)); + return fetchDef.then(function (rawMessages) { var isCreateMode = false; if (!self.res_id) { - raw_messages = self._forgeCreationModeMessages(); + rawMessages = self._forgeCreateMessages(); isCreateMode = true; } - self.thread.render(raw_messages, { - display_load_more: raw_messages.length < ids.length, + self._threadWidget.render(rawMessages, { + displayLoadMore: rawMessages.length < self._documentThread.getMessageIDs().length, isCreateMode: isCreateMode, }); + return self._documentThread.markAsRead(); }); }, + /** + * Make a fake list of messages to render in create mode. + * Instead of rendering no messages at all, it displays a single message + * with body content "Creating a new record". + * + * @private + * @returns {mail.model.Message[]} an array containing a single message 'Creating a new record...' + */ + _forgeCreateMessages: function () { + var createMessage = new Message(this, { + id: 0, + body: _t("

Creating a new record...

"), + author_id: [session.partner_id, session.partner_display_name], + }); + return [createMessage]; + }, + /** + * @override + * @private + * @returns {$.Deferred} + */ + _render: function () { + return this._fetchAndRenderThread(); + }, + /** + * The mail widget being persistent, one needs to update the res_id and + * to set the correct DocumentThread on reset. + * + * @override + * @private + * @param {any} record + */ + _reset: function (record) { + this._super.apply(this, arguments); + this.res_id = record.res_id; + this._setDocumentThread(); + }, + /** + * Sets this._documentThread, the DocumentThread associated with the current + * model and resID. + * + * @returns {mail.model.DocumentThread} + */ + _setDocumentThread: function () { + var params = { + messageIDs: this.value.res_ids, + name: this.recordData.display_name, + resID: this.res_id, + resModel: this.model, + }; + this._documentThread = this.call('mail_service', 'getOrAddDocumentThread', params); + }, //-------------------------------------------------------------------------- // Handlers @@ -143,38 +180,62 @@ var ThreadField = AbstractField.extend({ /** * When a new message arrives, fetch its data to render it - * @param {Number} message_id : the identifier of the new message - * @returns {Deferred} + * + * @private */ _onLoadMoreMessages: function () { - this._fetchAndRenderThread(this.msgIDs, {forceFetch: true}); + this._fetchAndRenderThread({ forceFetch: true }); }, + /** + * @private + * @param {mail.model.Message} + */ _onNewMessage: function (message) { - if (message.model === this.model && message.res_id === this.res_id) { - this.msgIDs.unshift(message.id); + if ( + message.isLinkedToDocumentThread() && + message.getDocumentModel() === this.model && + message.getDocumentID() === this.res_id + ) { this.trigger_up('new_message', { id: this.value.id, - msgIDs: this.msgIDs, + messageIDs: this._documentThread.getMessageIDs(), }); - this._fetchAndRenderThread(this.msgIDs); + this._fetchAndRenderThread(); } }, + /** + * @private + * @param {integer} channelID + */ _onRedirectToChannel: function (channelID) { var self = this; - this.call('chat_manager', 'joinChannel', channelID).then(function () { - // Execute Discuss client action with 'channel' as default channel - self.do_action('mail.mail_channel_action_client_chat', {active_id: channelID}); + this.call('mail_service', 'joinChannel', channelID).then(function () { + // Execute Discuss with 'channel' as default channel + self.do_action('mail.mail_channel_action_client_chat', { active_id: channelID }); }); }, - _onRedirect: function (res_model, res_id) { + /** + * @private + * @param {string} resModel + * @param {integer} resID + */ + _onRedirect: function (resModel, resID) { this.trigger_up('redirect', { - res_id: res_id, - res_model: res_model, + res_id: resID, + res_model: resModel, }); }, + /** + * @private + * @param {mail.model.Message} + */ _onUpdateMessage: function (message) { - if (message.model === this.model && message.res_id === this.res_id) { - this._fetchAndRenderThread(this.msgIDs); + if ( + message.isLinkedToDocumentThread() && + message.getDocumentModel() === this.model && + message.getDocumentID() === this.res_id + ) { + this._fetchAndRenderThread(); } }, }); diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js new file mode 100644 index 00000000000..1d49d89eeb7 --- /dev/null +++ b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js @@ -0,0 +1,390 @@ +odoo.define('mail.AbstractThreadWindow', function (require) { +"use strict"; + +var ThreadWidget = require('mail.widget.Thread'); + +var config = require('web.config'); +var core = require('web.core'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +var HEIGHT_OPEN = '400px'; +var HEIGHT_FOLDED = '34px'; + +/** + * This is an abstract widget for rendering thread windows. + * + * It contains logic that are shared between mail.ThreadWindow and + * mail.LivechatWindow. + * + * The reason for having two different implementation of thread windows is + * that mail.ThreadWindow makes use of mail.Manager, which is used in the + * backend, while mail.LivechatWindow must work without this mail service. + */ +var AbstractThreadWindow = Widget.extend({ + template: 'mail.AbstractThreadWindow', + custom_events: { + escape_pressed: '_onEscapePressed', + document_viewer_closed: '_onDocumentViewerClose', + }, + events: { + 'click .o_thread_window_close': '_onClickClose', + 'click .o_thread_window_title': '_onClickFold', + 'click .o_thread_composer': '_onComposerClick', + 'click .o_mail_thread': '_onThreadWindowClicked', + 'keydown .o_thread_composer': '_onKeydown', + 'keypress .o_thread_composer': '_onKeypress', + }, + /** + * Children of this class must make use of `thread`, which is an object that + * represent the thread that is linked to this thread window. + * + * If no thread is provided, this will represent the "blank" thread window. + * + * @abstract + * @param {Widget} parent + * @param {Object} thread + * @param {Object} [options={}] + */ + init: function (parent, thread, options) { + this._super(parent); + + this.options = _.defaults(options || {}, { + autofocus: true, + displayStars: true, + displayReplyIcons: false, + displayEmailIcons: false, + placeholder: _t("Say something"), + }); + + this._hidden = false; + }, + start: function () { + this.$input = this.$('.o_composer_text_field'); + this.$header = this.$('.o_thread_window_header'); + + this.threadWidget = new ThreadWidget(this, { + threadID: this._getThreadID(), + displayDocumentLinks: false, + displayMarkAsRead: false, + displayStars: this.options.displayStars, + }); + + if (this.isFolded()) { + this.$el.css('height', HEIGHT_FOLDED); + } else if (this.options.autofocus) { + this._focusInput(); + } + if (!config.device.isMobile) { + this.$el.css('margin-right', $.position.scrollbarWidth()); + } + var def = this.threadWidget.replace(this.$('.o_chat_content')); + return $.when(this._super(), def); + }, + /** + * @override + */ + do_hide: function () { + this._hidden = true; + this._super.apply(this, arguments); + }, + /** + * @override + */ + do_show: function () { + this._hidden = false; + this._super.apply(this, arguments); + }, + /** + * @override + */ + do_toggle: function (display) { + this._hidden = _.isBoolean(display) ? !display : !this._hidden; + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Close this window + * + * @abstract + */ + close: function () {}, + /** + * Get the status of the thread, such as the im status of a DM. + * + * @abstract + * @returns {string} + */ + getStatus: function () {}, + /** + * Get the title of the thread window, which usually contains the name of + * the thread. + * + * @abstract + * @returns {string} + */ + getTitle: function () {}, + /** + * Get the unread counter of the thread linked to this thread window. + * + * @abstract + * @returns {integer} + */ + getUnreadCounter: function () {}, + /** + * States whether this thread window is related to a thread or not. + * + * This is useful in order to provide specific behaviour for thread windows + * without any thread, e.g. let them open a thread from this "blank" thread + * window. + * + * @abstract + * @returns {boolean} + */ + hasThread: function () {}, + /** + * States whether this thread is folded or not. This information is stored + * on the thread model. + * + * @abstract + * @returns {boolean} + */ + isFolded: function () {}, + /** + * States whether the current environment is in mobile or not. This is + * useful in order to customize the template rendering for mobile view. + * + * @returns {boolean} + */ + isMobile: function () { + return config.device.isMobile; + }, + /** + * States whether the thread window is hidden or not. + * + * @returns {boolean} + */ + isHidden: function () { + return this._hidden; + }, + /** + * Render the thread window, using the provided list of messages + * + * TODO: use messages in thread, instead of providing list of messages + * + * @param {mail.model.AbstractMessage[]} messages + */ + render: function (messages) { + this._renderHeader(); + this.threadWidget.render(messages, { displayLoadMore: false }); + }, + /** + * Toggle the fold state of this thread window. Also update the fold state + * of the thread model. + * + * @param {boolean} folded + */ + toggleFold: function (folded) { + if (!_.isBoolean(folded)) { + folded = !this.isFolded(); + } + this._updateThreadFoldState(folded); + }, + /** + * Update the visual state of the window so that it matched the internal + * fold state. This is useful in case the related thread has its fold state + * that has been changed. + */ + updateVisualFoldState: function () { + if (!this.isFolded()) { + this.threadWidget.scrollToBottom(); + this._focusInput(); + } + this._animateFold(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when there is a change of the fold state of the thread window. + * This method animates the change of fold state of this thread window. + * + * @private + */ + _animateFold: function () { + this.$el.animate({ + height: this.isFolded() ? HEIGHT_FOLDED : HEIGHT_OPEN + }, 200); + }, + /** + * Set the focus on the composer of the thread window. This operation is + * ignored in mobile context. + * + * @private + * Set the focus on the input of the window + */ + _focusInput: function () { + if ( + config.device.touch && + config.device.size_class <= config.device.SIZES.SM + ) { + return; + } + this.$input.focus(); + }, + /** + * Returns the options used by the rendering of the window's header + * + * @private + * @returns {Object} + */ + _getHeaderRenderingOptions: function () { + return { + status: this.getStatus(), + title: this.getTitle(), + unreadCounter: this.getUnreadCounter(), + widget: this, + }; + }, + /** + * Get the ID of the related thread. + * + * @abstract + * @private + * @return {integer} + */ + _getThreadID: function () {}, + /** + * Post a message on this thread window + * + * @abstract + * @private + * @param {Object} messageData + */ + _postMessage: function (messageData) {}, + /** + * Update the fold state of the related thread. + * This function is called when toggling the fold state of this window + * + * @abstract + * @private + * @param {boolean} folded + */ + _updateThreadFoldState: function (folded) {}, + /** + * Render the header of this thread window + * + * @private + */ + _renderHeader: function () { + var options = this._getHeaderRenderingOptions(); + this.$header.html( + QWeb.render('mail.AbstractThreadWindowHeaderContent', options)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Close the thread + * + * @private + * @param {MouseEvent} ev + */ + _onClickClose: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.close(); + }, + /** + * Fold/unfold the thread window + * + * @private + */ + _onClickFold: function () { + if (!config.device.isMobile) { + this.toggleFold(); + } + }, + /** + * Called when the composer is clicked -> forces focus on input even if + * jquery's blockUI is enabled. + * + * @private + * @param {Event} ev + */ + _onComposerClick: function (ev) { + if ($(ev.target).closest('a, button').length) { + return; + } + this._focusInput(); + }, + /** + * @private + */ + _onDocumentViewerClose: function () { + this._focusInput(); + }, + /** + * @private + */ + _onEscapePressed: function () { + if (!this.isFolded()) { + this.close(); + } + }, + /** + * Called when typing something on the composer of this thread window. + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown: function (ev) { + ev.stopPropagation(); // to prevent jquery's blockUI to cancel event + // ENTER key (avoid requiring jquery ui for external livechat) + if (ev.which === 13) { + var content = _.str.trim(this.$input.val()); + var messageData = { + content: content, + attachment_ids: [], + partner_ids: [], + }; + this.$input.val(''); + if (content) { + this._postMessage(messageData); + } + } + }, + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeypress: function (ev) { + ev.stopPropagation(); // to prevent jquery's blockUI to cancel event + }, + /** + * When a thread window is clicked on, we want to give the focus to the main + * input. An exception is made when the user is selecting something. + * + * @private + */ + _onThreadWindowClicked: function () { + var selectObj = window.getSelection(); + if (selectObj.anchorOffset === selectObj.focusOffset) { + this.$input.focus(); + } + }, +}); + +return AbstractThreadWindow; + +}); diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js new file mode 100644 index 00000000000..c0d7a22ea33 --- /dev/null +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -0,0 +1,428 @@ +odoo.define('mail.ThreadWindow', function (require) { +"use strict"; + +var AbstractThreadWindow = require('mail.AbstractThreadWindow'); +var BasicComposer = require('mail.composer.Basic'); + +var core = require('web.core'); + +var _t = core._t; + +/** + * This is the main widget for rendering small windows for mail.model.Thread. + * Almost all instances of this class are linked to a thread. The sole + * exception is the "blank" thread window. This window let us open another + * thread window, using this "blank" thread window. + */ +var ThreadWindow = AbstractThreadWindow.extend({ + template: 'mail.ThreadWindow', + events: _.extend({}, AbstractThreadWindow.prototype.events, { + 'click .o_thread_window_expand': '_onClickExpand', + }), + /** + * Version of thread window that supports {mail.model.Thread} + * + * @override + * @param {mail.Manager} parent + * @param {mail.model.Thread} [thread = null] if not set, this is a "blank" + * thread window. It lets us open a DM by providing the name of a chat. + * @param {Object} [options={}] + * @param {boolean} [options.passively=false] + */ + init: function (parent, thread, options) { + this._super.apply(this, arguments); + + // don't automatically mark unread messages as seen when at the bottom + // of the thread + this._passive = this.options.passively; + this._thread = thread || null; + + if (!this.hasThread()) { + // internal fold state of thread window without any thread + this._folded = false; + // remembered partner ID of "blank" thread window in order to be + // replaced with newly opened DM window + this.directPartnerID = null; + } + }, + + /** + * @override + */ + start: function () { + var self = this; + + var superDef = this._super().then(this._listenThreadWidget.bind(this)); + + var composerDef; + if (!this.hasThread()) { + this._startWithoutThread(); + } else if (this._needsComposer()) { + var basicComposer = new BasicComposer(this, { + mentionPartnersRestricted: this._thread.getType() !== 'document_thread', + isMini: true + }); + basicComposer.on('post_message', this, this._postMessage); + basicComposer.once('input_focused', this, function () { + var commands = this._thread.getCommands(); + var partners = this._thread.getMentionPartnerSuggestions(); + basicComposer.mentionSetCommands(commands); + basicComposer.mentionSetPrefetchedPartners(partners); + }); + composerDef = basicComposer.replace(this.$('.o_thread_composer')); + composerDef.then(function () { + self.$input = self.$('.o_composer_text_field'); + }); + } + + return $.when(superDef, composerDef); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + close: function () { + if (this._hasThread()) { + this._thread.close(); + } + }, + /** + * Set the focus on the input of this thread window + */ + focusInput: function () { + this._focusInput(); + }, + /** + * Get the ID of the thread window, which is equivalent to the ID of the + * thread related to this window + * + * @returns {integer|string} + */ + getID: function () { + return this._getThreadID(); + }, + /** + * Get the status of the thread. If this window has no thread, returns + * 'undefined' + * + * @override + * @returns {string|undefined} + */ + getStatus: function () { + if (!this.hasThread()) { + return undefined; + } + return this._thread.getStatus(); + }, + /** + * Get the title of the thread window, which is equivalent to the name of + * related thread, prefixed with "#" if this is a chat (i.e. DM, backend + * livechat). + * + * If there is no thread linked to this thread window, display title of + * "blank" thread window. + * + * @override + * @returns {string} + */ + getTitle: function () { + if (!this.hasThread()) { + return _t("New message"); + } + return this._thread.getTitle(); + }, + /** + * Get the unread counter of the related thread. If there are no thread + * linked to this window, returns 0. + * + * @override + * @returns {integer} + */ + getUnreadCounter: function () { + if (!this.hasThread()) { + return 0; + } + return this._thread.getUnreadCounter(); + }, + /** + * @override + * @returns {boolean} + */ + hasThread: function () { + return !!this._thread; + }, + /** + * State whether the related thread is folded or not. If there are no + * thread related to this window, it means this is the "blank" thread + * window, therefore we use the internal folded state. + * + * @override + * @returns {boolean} + */ + isFolded: function () { + if (!this.hasThread()) { + return this._folded; + } + return this._thread.isFolded(); + }, + /** + * Tell whether the thread window is passive or not. A passive thread window + * does not auto-mark the thread as read when scrolling at the bottom. + * + * @returns {boolean} + */ + isPassive: function () { + return this._passive; + }, + /** + * Turn the thread window in active mode, so that when the bottom of the + * thread is visible, it is automatically marked as read. + */ + removePassive: function () { + this._passive = false; + }, + /** + * Update the header of this thread window + * This is useful when some information on the header have be updated + * such as the status or the title of the thread that have changed. + */ + updateHeader: function () { + this._renderHeader(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Get the ID of the thread window, which is equivalent to the ID of the + * related thread. + * + * If this window is not related to a thread, it means this is the "blank" + * thread window, therefore it returns "_blank" as its ID. + * + * @override + * @private + * @returns {integer|string} the threadID, or '_blank' for the window that + * is not related to any thread. + */ + _getThreadID: function () { + if (!this.hasThread()) { + return '_blank'; + } + return this._thread.getID(); + }, + /** + * @override + * @private + */ + _getHeaderRenderingOptions: function () { + var options = this._super.apply(this, arguments); + if (this._thread.getType() === 'document_thread') { + options.expandTitle = _t("Open document"); + } else { + options.expandTitle = _t("Open in Discuss"); + } + return options; + }, + /** + * States whether the window is linked to a thread or not + * + * Useful to detect whether this is the "blank" thread window or not. + * + * @private + * @returns {boolean} + */ + _hasThread: function () { + return !!this._thread; + }, + /** + * Listen on thread widget events + * + * @private + */ + _listenThreadWidget: function () { + this.threadWidget + .on('redirect', this, this._onRedirect) + .on('redirect_to_channel', this, this._onRedirectToChannel) + .on('toggle_star_status', this, this._onToggleStarStatus); + }, + /** + * States whether the input of the thread window should be displayed or not. + * This is based on the type of the thread: + * + * Do not display the input in the following cases: + * + * - no thread related to this window + * - window of a mailbox (temp: let us have mailboxes in window mode) + * - window of a thread with mass mailing + * + * Any other threads show the input in the window. + * + * @private + * @returns {boolean} + */ + _needsComposer: function () { + return this.hasThread() && + (this._thread.getType() !== 'mailbox') && + !this._thread.isMassMailing(); + }, + /** + * Open this thread window. + * This private method exists only for the purpose of providing a callback + * function on redirect to a partner. + * + * @private + */ + _open: function () { + this.call('mail_service', 'openThreadWindow', this.getID()); + }, + /** + * Post a message in the thread related to this window. + * If this window has no thread, do nothing + * + * @override + * @private + * @param {Object} messageData + */ + _postMessage: function (messageData) { + var self = this; + if (!this._hasThread()) { + return; + } + this._thread.postMessage(messageData) + .then(function () { + self.threadWidget.scrollToBottom(); + }); + }, + /** + * @private + */ + _startWithoutThread: function () { + var self = this; + this.$el.addClass('o_thread_less'); + this.$('.o_thread_search_input input') + .autocomplete({ + source: function (request, response) { + self.call('mail_service', 'searchPartner', request.term, 10) + .done(response); + }, + select: function (event, ui) { + // remember partner ID so that we can replace this window + // with new DM window + var partnerID = ui.item.id; + self.directPartnerID = partnerID; + self.call('mail_service', 'openDmWindow', partnerID); + }, + }) + .focus(); + }, + /** + * Update the fold state of the thread + * If there is no thread linked to this window, it means this is the + * "blank" thread window, therefore we use the internal state 'folded' + * + * @override + * @private + * @param {boolean} folded + */ + _updateThreadFoldState: function (folded) { + if (this.hasThread()) { + this._thread.fold(folded); + } else { + this._folded = folded; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Expand the thread, which will open the thread in discuss. + * + * If there is no thread linked to this window, it means this is the "blank" + * thread window, therefore it opens discuss with the default thread (which + * should be the mailbox 'inbox') + * + * @private + * @param {MouseEvent} ev + */ + _onClickExpand: _.debounce(function (ev) { + var self = this; + ev.preventDefault(); + if (this._thread.getType() === 'document_thread') { + this.do_action({ + type: 'ir.actions.act_window', + res_model: this._thread.getDocumentModel(), + views: [[false, 'form']], + res_id: this._thread.getDocumentID(), + }); + } else { + this.do_action('mail.mail_channel_action_client_chat', { + clear_breadcrumbs: false, + active_id: this.hasThread() ? this._getThreadID() : undefined, + on_reverse_breadcrumb: function () { + self.call('mail_service', 'getMailBus') + .trigger('discuss_open', false); + }, + }); + } + }, 1000, true), + /** + * @override + * @private + * @param {KeyboardEvent} ev + * + * Override _onKeydown to only prevent jquery's blockUI to cancel event, + * but without sending the message on ENTER keydown as this is handled by + * the BasicComposer + */ + _onKeydown: function (ev) { + ev.stopPropagation(); + }, + /** + * callback function receives the threadID linked to resModel and resID + * Note: this callback function is only called for 'res.partner' + * + * @private + * @param {string} resModel + * @param {integer} resID + */ + _onRedirect: function (resModel, resID) { + var callback = this._open.bind(this); + this.call('mail_service', 'redirect', resModel, resID, callback); + }, + /** + * @private + * @param {integer} channelID + */ + _onRedirectToChannel: function (channelID) { + var thread = this.call('mail_service', 'getThread', channelID); + if (!thread) { + this.call('mail_service', 'joinChannel', channelID) + .then(function (channel) { + channel.detach(); + }); + } else { + this.toggleFold(false); + } + }, + /** + * @private + * @param {integer} messageID + */ + _onToggleStarStatus: function (messageID) { + var message = this.call('mail_service', 'getMessage', messageID); + message.toggleStarStatus(); + }, + +}); + +return ThreadWindow; + +}); diff --git a/addons/mail/static/src/js/tour.js b/addons/mail/static/src/js/tour.js index f8fd387eb9f..ffbe69de525 100644 --- a/addons/mail/static/src/js/tour.js +++ b/addons/mail/static/src/js/tour.js @@ -1,4 +1,4 @@ -odoo.define('mail.tour', function(require) { +odoo.define('mail.tour', function (require) { "use strict"; var core = require('web.core'); @@ -7,24 +7,24 @@ var tour = require('web_tour.tour'); var _t = core._t; tour.register('mail_tour', [{ - trigger: '.o_mail_chat .o_mail_chat_sidebar .o_add[data-type="public"]', + trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="public"]', content: _t("

Channels make it easy to organize information across different topics and groups.

Try to create your first channel (e.g. sales, marketing, product XYZ, after work party, etc).

"), position: 'bottom', }, { - trigger: '.o_mail_chat .o_composer_text_field', - content: _t("

Write a message to the members of the channel here.

You can notify someone with \'@\' or link another channel with \'#\'. Start your message with \'/\' to get the list of possible commands.

"), + trigger: '.o_mail_discuss .o_composer_text_field', + content: _t("

Write a message to the members of the channel here.

You can notify someone with '@' or link another channel with '#'. Start your message with '/' to get the list of possible commands.

"), position: "top", width: 350, }, { - trigger: ".o_mail_chat .o_mail_thread .o_thread_message_star", + trigger: ".o_mail_discuss .o_mail_thread .o_thread_message_star", content: _t("Messages can be starred to remind you to check back later."), position: "right", }, { - trigger: '.o_mail_chat .o_mail_chat_channel_item[data-channel-id="channel_starred"]', + trigger: '.o_mail_discuss .o_mail_item_item[data-thread-id="mailbox_starred"]', content: _t("Once a message has been starred, you can come back and review it at any time here."), position: "bottom", }, { - trigger: '.o_mail_chat .o_mail_chat_sidebar .o_add[data-type="dm"]', + trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="dm"]', content: _t("

Chat with coworkers in real-time using direct messages.

You might need to invite users from the Settings app first.

"), position: 'bottom', }]); diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index 16507035982..4749ba11b20 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -1,24 +1,28 @@ odoo.define('mail.utils', function (require) { "use strict"; -function parse_and_transform(html_string, transform_function) { - var open_token = "OPEN" + Date.now(); - var string = html_string.replace(/</g, open_token); +var core = require('web.core'); + +var _t = core._t; + +function parseAndTransform(htmlString, transformFunction) { + var openToken = "OPEN" + Date.now(); + var string = htmlString.replace(/</g, openToken); var children = $('
').html(string).contents(); - return _parse_and_transform(children, transform_function) - .replace(new RegExp(open_token, "g"), "<"); + return _parseAndTransform(children, transformFunction) + .replace(new RegExp(openToken, "g"), "<"); } -function _parse_and_transform(nodes, transform_function) { +function _parseAndTransform(nodes, transformFunction) { return _.map(nodes, function (node) { - return transform_function(node, function () { - return _parse_and_transform(node.childNodes, transform_function); + return transformFunction(node, function () { + return _parseAndTransform(node.childNodes, transformFunction); }); }).join(""); } // Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url // Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match. -var url_regexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_\+.~#?&'$//=]*)/gi; +var urlRegexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_+.~#?&'$//=]*)/gi; function linkify(text, attrs) { attrs = attrs || {}; if (attrs.target === undefined) { @@ -27,28 +31,28 @@ function linkify(text, attrs) { attrs = _.map(attrs, function (value, key) { return key + '="' + _.escape(value) + '"'; }).join(' '); - return text.replace(url_regexp, function (url) { + return text.replace(urlRegexp, function (url) { var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url; return '' + url + ''; }); } -function add_link (node, transform_children) { +function addLink(node, transformChildren) { if (node.nodeType === 3) { // text node return linkify(node.data); } if (node.tagName === "A") return node.outerHTML; - node.innerHTML = transform_children(); + node.innerHTML = transformChildren(); return node.outerHTML; } -function strip_html (node, transform_children) { +function stripHTML(node, transformChildren) { if (node.nodeType === 3) return node.data; // text node if (node.tagName === "BR") return "\n"; - return transform_children(); + return transformChildren(); } -function inline (node, transform_children) { +function inline(node, transform_children) { if (node.nodeType === 3) return node.data; if (node.nodeType === 8) return ""; if (node.tagName === "BR") return " "; @@ -58,7 +62,7 @@ function inline (node, transform_children) { } // Parses text to find email: Tagada -> [Tagada, address@mail.fr] or False -function parse_email (text) { +function parseEmail(text) { if (text){ var result = text.match(/(.*)<(.*@.*)>/); if (result) { @@ -74,13 +78,13 @@ function parse_email (text) { // Replaces textarea text into html text (add

, ) // TDE note : should be done server-side, in Python -> use mail.compose.message ? -function get_text2html (text) { +function getTextToHTML(text) { return text .replace(/((?:https?|ftp):\/\/[\S]+)/g,'$1 ') .replace(/[\n\r]/g,'
'); } -var accented_letters_mapping = { +var accentedLettersMapping = { 'a': '[àáâãäå]', 'ae': 'æ', 'c': 'ç', @@ -92,21 +96,29 @@ var accented_letters_mapping = { 'u': '[ùúûűü]', 'y': '[ýÿ]', }; -function unaccent (str) { - _.each(accented_letters_mapping, function (value, key) { +function unaccent(str) { + _.each(accentedLettersMapping, function (value, key) { str = str.replace(new RegExp(value, 'g'), key); }); return str; } +function timeFromNow(date) { + if (moment().diff(date, 'seconds') < 45) { + return _t("now"); + } + return date.fromNow(); +} + return { - parse_and_transform: parse_and_transform, - add_link: add_link, - linkify: linkify, - strip_html: strip_html, + addLink: addLink, + getTextToHTML: getTextToHTML, inline: inline, - parse_email: parse_email, - get_text2html: get_text2html, + linkify: linkify, + parseAndTransform: parseAndTransform, + parseEmail: parseEmail, + stripHTML: stripHTML, + timeFromNow: timeFromNow, unaccent: unaccent, }; diff --git a/addons/mail/static/src/scss/chat_window.scss b/addons/mail/static/src/scss/abstract_thread_window.scss similarity index 72% rename from addons/mail/static/src/scss/chat_window.scss rename to addons/mail/static/src/scss/abstract_thread_window.scss index 93ab8248b6b..c55662f4bb3 100644 --- a/addons/mail/static/src/scss/chat_window.scss +++ b/addons/mail/static/src/scss/abstract_thread_window.scss @@ -1,17 +1,17 @@ -$o-mail-chat-window-zindex: $zindex-modal + 1 !default; +$o-mail-thread-window-zindex: $zindex-modal + 1 !default; -.o_chat_window { +.o_thread_window { @include o-flex-display; @include o-flex-flow(column, nowrap); position: fixed; - width: $o-mail-chat-window-width; + width: $o-mail-thread-window-width; max-width: 100%; height: 400px; max-height: 100%; font-size: 12px; - background-color: $o-mail-chat-window-bg; + background-color: $o-mail-thread-window-bg; border-radius: 6px 6px 0 0; - z-index: $o-mail-chat-window-zindex; + z-index: $o-mail-thread-window-zindex; box-shadow: -5px -5px 10px rgba(black, 0.18); @media (max-width: $screen-xs-max) { @@ -27,7 +27,7 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; display: none; } - .o_chat_header { + .o_thread_window_header { @include o-flex-display; @include o-flex(0, 0, auto); color: white; @@ -41,26 +41,26 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; height: $o-mail-chat-header-height; padding-top: 12px; border-radius: 0px; - .o_chat_title { + .o_thread_window_title { font-size: 16px; margin-left: 10px; } - .o_chat_window_close { + .o_thread_window_close { font-size: 17px; color: white; } } - .o_chat_title { + .o_thread_window_title { cursor: pointer; @include o-flex(1, 1, auto); @include o-text-overflow; } - .o_chat_window_buttons { + .o_thread_window_buttons { @include o-flex(0, 0, auto); - .o_chat_window_close, .o_chat_window_expand { + .o_thread_window_close, .o_thread_window_expand { color: white; padding: 3px; margin-left: 5px; @@ -77,7 +77,7 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; .o_thread_date_separator { margin: 0px 0px 15px 0px; .o_thread_date { - background-color: $o-mail-chat-window-bg; + background-color: $o-mail-thread-window-bg; } } .o_thread_message { @@ -88,12 +88,12 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; } } - .o_chat_composer input { + .o_thread_composer input { width: 100%; } } -.o_chat_window_dropdown { +.o_thread_window_dropdown { width: auto; height: 28px; color: white; @@ -105,11 +105,11 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; display: none; } - .o_chat_header { + .o_thread_window_header { border-radius: 0; } - .o_chat_window_dropdown_toggler { + .o_thread_window_dropdown_toggler { padding: 5px; .o_total_unread_counter { @@ -119,18 +119,18 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; font-size: smaller; } } - &.open .o_chat_window_dropdown_toggler .o_total_unread_counter, .o_chat_window_expand { + &.open .o_thread_window_dropdown_toggler .o_total_unread_counter, .o_thread_window_expand { display: none; } > ul { - max-width: $o-mail-chat-window-width; + max-width: $o-mail-thread-window-width; padding: 0; - > li.o_chat_header { + > li.o_thread_window_header { font-size: 12px; padding: 3px 5px; - &~li.o_chat_header { + &~li.o_thread_window_header { border-top: 1px solid white; } &:hover { @@ -140,17 +140,17 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; } } -.o_ui_blocked .o_chat_window { - // We cannot put the z-index of chat windows directly to be greater than +.o_ui_blocked .o_thread_window { + // We cannot put the z-index of thread windows directly to be greater than // blockUI's as ui-autocomplete dropdowns (which are below blockUI) would - // appear under the chat windows (and ui-autocomplete is used to choose the + // appear under the thread windows (and ui-autocomplete is used to choose the // person you want to chat with). So we only raise the z-index value when // the ui is really blocked (in that case, the ui-autocomplete dropdowns - // will disappear under the chat windows but this is not really an issue as + // will disappear under the thread windows but this is not really an issue as // there should not be any at that time). z-index: 1101; // blockUI's z-index is 1100 } -.o_no_chat_window .o_chat_window { +.o_no_thread_window .o_thread_window { display: none; } diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index fed625ace35..58bf5596cd3 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -12,7 +12,7 @@ local(Symbola); } -.o_chat_composer { +.o_thread_composer { position: relative; @include o-flex(0, 0, auto); @include o-flex-display; @@ -244,8 +244,8 @@ //font-size:150%; font-family: emojifont; } -.o_mail_channel_preview .o_mail_emoji{ - font-size:100%; +.o_mail_preview .o_mail_emoji { + font-size: 100%; } // Attachment Icons (common for chat thread and chat composer) @@ -428,7 +428,7 @@ $o-mail-attachment-margin: 5px; } } .o_modal_fullscreen { - z-index: $o-mail-chat-window-zindex + 1; + z-index: $o-mail-thread-window-zindex + 1; .o_viewer_content { position: relative; diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss index d6cd8dbaaa9..2a65cc6e183 100644 --- a/addons/mail/static/src/scss/discuss.scss +++ b/addons/mail/static/src/scss/discuss.scss @@ -2,7 +2,7 @@ // Layout // ------------------------------------------------------------------ -.o_mail_chat { +.o_mail_discuss { @include o-position-absolute(0, 0, 0, 0); height: 100%; overflow: hidden; @@ -36,7 +36,7 @@ } } - .o_mail_chat_sidebar { + .o_mail_discuss_sidebar { width: $o-mail-chat-sidebar-width; height: 100%; float: left; @@ -63,12 +63,12 @@ cursor: pointer; } } - .o_mail_chat_channel_item { + .o_mail_discuss_item { @include o-flex-display; width: 100%; padding: 2px $o-horizontal-padding 2px ($o-horizontal-padding + 12px); // compensate icon - .o_channel_name { + .o_thread_name { @include o-flex(1, 1, auto); @include o-text-overflow; } @@ -85,17 +85,17 @@ margin-left: 3px; } - &.o_mail_chat_title_main { + &.o_mail_discuss_title_main { padding: $padding-base-vertical $o-horizontal-padding; margin-bottom: 2px; } } - .o_mail_chat_channel_item + .o_mail_sidebar_title { + .o_mail_discuss_item + .o_mail_sidebar_title { margin-top: $padding-base-vertical; } - .o_mail_add_channel { + .o_mail_add_thread { width: 100%; padding: 5px ($o-horizontal-padding + 23px); // compensate icon position: relative; @@ -110,7 +110,7 @@ } } - .o_mail_chat_content { + .o_mail_discuss_content { @include o-flex-display; @include o-flex-flow(column, nowrap); height: 100%; @@ -151,12 +151,12 @@ // Style // ------------------------------------------------------------------ -.o_mail_chat { - .o_mail_chat_sidebar { +.o_mail_discuss { + .o_mail_discuss_sidebar { background-color: $gray-dark; color: $gray-lighter-dark; - .o_mail_chat_channel_item { + .o_mail_discuss_item { cursor: pointer; &:hover { @@ -192,10 +192,10 @@ height: 18px; } - &.o_mail_chat_title_main { + &.o_mail_discuss_title_main { font-size: $font-size-base; - &.o_mail_chat_title_starred > .badge { + &.o_mail_mailbox_title_starred > .badge { background-color: $gray-light; border-color: $gray-light; } @@ -206,7 +206,7 @@ border-color: $gray; } - .o_mail_add_channel { + .o_mail_add_thread { display: none; > input { color: black; @@ -234,7 +234,7 @@ } } - .o_mail_chat_content { + .o_mail_discuss_content { background-color: white; } @@ -266,18 +266,18 @@ // Mobile // ------------------------------------------------------------------ @media (max-width: $screen-xs-max) { - .o_mail_chat { + .o_mail_discuss { @include o-flex-display; @include o-flex-flow(column, nowrap); background: white; - .o_mail_chat_mobile_inbox_buttons { + .o_mail_discuss_mobile_mailboxes_buttons { @include o-flex-display; @include o-flex(0, 0, auto); z-index: 2; box-shadow: 0 2px 4px $gray-lighter-darker; - .o_channel_inbox_item { + .o_mailbox_inbox_item { @include o-flex(1, 1, auto); width: 50%; padding: 2%; @@ -288,16 +288,16 @@ } } - .o_mail_chat_content { + .o_mail_discuss_content { @include o-flex(1, 0, 0); margin-left: 10px; margin-right: 10px; background-color: transparent; } - .o_mail_chat_tab_pane { + .o_mail_discuss_tab_pane { overflow-x: scroll; - .o_mail_add_channel { + .o_mail_add_thread { display: none; position: relative; padding: 5px 0px; @@ -321,10 +321,10 @@ } // ------------------------------------------------------------------ -// Channel preview: shared between discuss (mobile) and systray +// Thread preview: shared between discuss (mobile) and systray // ------------------------------------------------------------------ -.o_mail_channel_preview { +.o_mail_preview { @include o-flex-display; background-color: $gray-lighter; color: $o-main-text-color; @@ -333,7 +333,7 @@ position: relative; &:hover { background-color: $gray-lighter-dark; - .o_channel_name { + .o_preview_name { color: $headings-color; } .o_discuss_icon { @@ -343,30 +343,30 @@ &:not(:last-child) { border-bottom: 1px solid $gray-lighter-darker; } - .o_mail_channel_image { + .o_mail_preview_image { @include o-flex(0, 0, auto); position: relative; > img { @include square(100%); border-radius: 50%; } - &.o_mail_channel_app > img { + &.o_mail_preview_app > img { border-radius: 2px; } .o_mail_user_status { @include o-position-absolute($bottom: 0px, $right: 0px); } } - .o_channel_info { + .o_preview_info { @include o-flex(1, 1, 100%); overflow: hidden; - .o_channel_title { + .o_preview_title { @include o-flex-display; - .o_channel_name { + .o_preview_name { @include o-flex(0, 1, auto); @include o-text-overflow; } - .o_channel_counter { + .o_preview_counter { @include o-flex(1, 1, auto); } .o_last_message_date { @@ -385,14 +385,14 @@ .o_discuss_icon { opacity: 0; } - &.o_channel_unread { + &.o_preview_unread { background-color: transparent; &:hover { background-color: $gray-lighter; } - .o_channel_info { - .o_channel_title { - .o_channel_name, .o_channel_counter { + .o_preview_info { + .o_preview_title { + .o_preview_name, .o_preview_counter { font-weight: 700; } .o_last_message_date { @@ -409,18 +409,18 @@ border: none; } - .o_mail_channel_preview { + .o_mail_preview { padding: $o-mail-chatter-mobile-gap; - .o_mail_channel_image:not(img) { + .o_mail_preview_image:not(img) { width: $o-mail-chatter-mobile-gap*6; } - .o_channel_info, .o_last_message_date { + .o_preview_info, .o_last_message_date { margin-left: $o-mail-chatter-mobile-gap; } - .o_channel_name { + .o_preview_name { font-size: 1.1em; } diff --git a/addons/mail/static/src/scss/systray.scss b/addons/mail/static/src/scss/systray.scss index b0ed61e140f..9937608e040 100644 --- a/addons/mail/static/src/scss/systray.scss +++ b/addons/mail/static/src/scss/systray.scss @@ -1,5 +1,5 @@ -// Navbar icon and dropdown -.o_mail_navbar_item { +// Systray icon and dropdown +.o_mail_systray_item { > a { opacity: 1; > i { @@ -15,7 +15,7 @@ display: none; } } - &.open .o_mail_navbar_dropdown { + &.open .o_mail_systray_dropdown { @include o-flex-display; @include o-flex-flow(column, nowrap); } @@ -26,7 +26,7 @@ padding: 0em 0.3em; font-size: 0.7em; } - .o_mail_navbar_dropdown { + .o_mail_systray_dropdown { width: 350px; padding: 0; @@ -38,7 +38,7 @@ height: 50px; } - .o_mail_navbar_dropdown_top { + .o_mail_systray_dropdown_top { @include o-flex-display; @include o-flex(0, 0, auto); justify-content: space-between; @@ -59,23 +59,23 @@ } } - .o_mail_navbar_dropdown_channels { + .o_mail_systray_dropdown_items { @include o-flex(0, 1, auto); max-height: 400px; min-height: 50px; overflow-y: auto; @media (min-width: $screen-sm-min) { - .o_mail_channel_preview { + .o_mail_preview { min-height: 50px; padding: 5px; - .o_mail_channel_image { + .o_mail_preview_image { width: 40px; height: 40px; } - .o_channel_info { + .o_preview_info { margin-left: 10px; - .o_channel_title { + .o_preview_title { .o_last_message_date { padding-top: 2px; font-size: x-small; @@ -99,29 +99,29 @@ } } -.o_no_chat_window .o_mail_navbar_dropdown .o_new_message { +.o_no_thread_window .o_mail_systray_dropdown .o_new_message { display: none; // hide 'new message' button if chat windows are disabled } // Mobile rules // Goal: mock the design of Discuss in mobile @media (max-width: $screen-xs-max) { - .o_mail_navbar_item { + .o_mail_systray_item { .o_notification_counter { top: 10%; } - .o_mail_navbar_dropdown { + .o_mail_systray_dropdown { position: relative; - .o_mail_navbar_dropdown_top { + .o_mail_systray_dropdown_top { padding: 5px; } - .o_mail_navbar_mobile_header { + .o_mail_systray_mobile_header { padding: 5px; height: 44px; border-bottom: 1px solid #ebebeb; box-shadow: 0 0 2px $gray-lighter-darker; } - .o_mail_navbar_dropdown_channels { + .o_mail_systray_dropdown_items { max-height: none; padding-bottom: 52px; // leave space for tabs } diff --git a/addons/mail/static/src/scss/extended_chat_window.scss b/addons/mail/static/src/scss/thread_window.scss similarity index 93% rename from addons/mail/static/src/scss/extended_chat_window.scss rename to addons/mail/static/src/scss/thread_window.scss index 6cb930b4488..bca79bc9c7a 100644 --- a/addons/mail/static/src/scss/extended_chat_window.scss +++ b/addons/mail/static/src/scss/thread_window.scss @@ -1,6 +1,6 @@ -.o_chat_window { +.o_thread_window { &.o_thread_less { - .o_chat_search_input { + .o_thread_search_input { @include o-flex-display; @include o-align-items(baseline); padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap; @@ -20,12 +20,12 @@ background-color: $o-webclient-background-color; } - .o_chat_composer { + .o_thread_composer { display: none; } } - .o_chat_composer { + .o_thread_composer { .o_composer { .o_composer_input { width: 80%; diff --git a/addons/mail/static/src/scss/variables.scss b/addons/mail/static/src/scss/variables.scss index c3086ce579d..4630a98c816 100644 --- a/addons/mail/static/src/scss/variables.scss +++ b/addons/mail/static/src/scss/variables.scss @@ -1,8 +1,8 @@ $o-mail-thread-avatar-size: 36px !default; $o-mail-thread-icon-opacity: 0.6 !default; $o-mail-thread-side-date-opacity: 0.6 !default; -$o-mail-chat-window-bg: #FAFAFA !default; -$o-mail-chat-window-width: 325px !default; +$o-mail-thread-window-bg: #FAFAFA !default; +$o-mail-thread-window-width: 325px !default; $o-mail-chatter-gap: 10px !default; $o-mail-chatter-mobile-gap: 2% !default; $o-mail-chat-header-height: 46px !default; diff --git a/addons/mail/static/src/xml/abstract_thread_window.xml b/addons/mail/static/src/xml/abstract_thread_window.xml new file mode 100644 index 00000000000..6857c638c45 --- /dev/null +++ b/addons/mail/static/src/xml/abstract_thread_window.xml @@ -0,0 +1,79 @@ + + + + + +

+
+ + + + + +
+
+
+
+ +
+
+ + + + +
+ + + + + + + +
+
+ + + + + + + + + + () + + + + + + + diff --git a/addons/mail/static/src/xml/activity.xml b/addons/mail/static/src/xml/activity.xml index 9d2f0d72ed8..d96db53ac41 100644 --- a/addons/mail/static/src/xml/activity.xml +++ b/addons/mail/static/src/xml/activity.xml @@ -40,13 +40,13 @@
Created on
- +
Assigned to
- , due on + , due on
diff --git a/addons/mail/static/src/xml/chat_window.xml b/addons/mail/static/src/xml/chat_window.xml deleted file mode 100644 index 7d0fce19ee6..00000000000 --- a/addons/mail/static/src/xml/chat_window.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - -
-
- - - - - -
-
-
-
- -
-
-
- - -
- - - - - - - -
-
- - - -
-
- - - - () - - - - - - - diff --git a/addons/mail/static/src/xml/chatter.xml b/addons/mail/static/src/xml/chatter.xml index e6cd3995bc7..7a5959789cf 100644 --- a/addons/mail/static/src/xml/chatter.xml +++ b/addons/mail/static/src/xml/chatter.xml @@ -3,28 +3,30 @@ - + - + To: Followers of - - "" + + "" - + this document -
- +
+
- - - - diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml index 752b9febdf9..f7baafeb777 100644 --- a/addons/mail/static/src/xml/composer.xml +++ b/addons/mail/static/src/xml/composer.xml @@ -1,7 +1,7 @@ -
+
Avatar
@@ -20,11 +20,11 @@
- +
- + /web/binary/upload_attachment @@ -34,15 +34,19 @@
-
+
-
- + + - + @@ -60,18 +64,18 @@ - + - + - + / diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml index 7901a870ea3..493d3c918aa 100644 --- a/addons/mail/static/src/xml/discuss.xml +++ b/addons/mail/static/src/xml/discuss.xml @@ -1,12 +1,16 @@ - + -
-
-
- +
+
+
+
Odoo needs your permission to enable desktop notifications. @@ -15,145 +19,184 @@
- -
-
- Inbox - - + + + +
+
+ Inbox + +
-
- Starred - - +
+ Starred + +
-
- Moderation Queue +
+ Moderation Queue - +

- - - Channels - + + + Channels + - - - Add a channel + + + Add a channel - - - Direct Messages - + + + Direct Messages + - - - User name + + + User name - - - Private Channels - + + + Private Channels + - - Add a private channel - + + Add a private channel +
- + + + - - - + + + + - - + + - +
-

- - +

+ +

- -
-
-
- -
- # - -
- - -
- - - - - - - # - - - - - - +
- + + +
+ # + +
+ + +
+ + + + + + + # + + + + + + +
+
+
+ + + - + + - - + +
- - - - - - - - - - + + + + + + + + + +
- +
@@ -163,17 +206,18 @@ -
-
- +
-
+
-
+
Inbox
@@ -189,51 +233,71 @@
- -
-
+ + +
+
# - Add a channel - Open chat - + Add a channel + Open chat +
- - + +
- - -
-
- Channel - - + + +
+
+ Channel + +
-
-
- - +
+
+ + - - &nbsp;() + + &nbsp;() - +
-
- +
+ You: - - - : - + + : - +
- +
diff --git a/addons/mail/static/src/xml/extended_chat_window.xml b/addons/mail/static/src/xml/extended_chat_window.xml deleted file mode 100644 index c5188feadfa..00000000000 --- a/addons/mail/static/src/xml/extended_chat_window.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - -
- To: - -
-
-
- - - - - - - -
diff --git a/addons/mail/static/src/xml/systray.xml b/addons/mail/static/src/xml/systray.xml index 6350e07d2da..ae1d7f2879f 100644 --- a/addons/mail/static/src/xml/systray.xml +++ b/addons/mail/static/src/xml/systray.xml @@ -1,18 +1,21 @@ - -
  • + + +
  • -