diff options
| author | Sébastien Theys <seb@odoo.com> | 2020-06-15 18:12:21 +0000 |
|---|---|---|
| committer | Sébastien Theys <seb@odoo.com> | 2020-06-15 18:12:21 +0000 |
| commit | 3fea5b2136627c1d7e83b2a5e111952fe3eb10a1 (patch) | |
| tree | 5eefad07972a87ce4689b83260f981bc87aadad4 /addons/mail/static/src/models/messaging_notification_handler | |
| parent | 0a82848606151ef60eb6523e9c4c161171f214e7 (diff) | |
[REF] mail, *: refactor messaging with OWL
This commit is a significant rewriting of client-side discuss, chatter,
chat window, and messaging menu using OWL. The behavior should be broadly
the same, with some slight functional changes here and there.
From a technical standpoint, the code of messaging is mainly organized in 2
main groups of modules:
- models, which are logical entities that depict the client-side state of
messaging as a whole.
- components, which are in charge of displaying information from models.
This refactoring also introduces new JS guidelines regarding folder structure
(/static) and naming rules for JS modules.
Community PR: https://github.com/odoo/odoo/pull/39023
Enterprise PR: https://github.com/odoo/enterprise/pull/6249
Task-1914207
This PR is a collaborative work by Alexandre, Julien, Sébastien and Xavier,
with the precious help of Lucas to speed it up towards the end.
closes odoo/odoo#39023
Related: odoo/enterprise#6249
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Co-authored-by: Alexandre Kühn <aku@odoo.com>
Co-authored-by: Julien Giannone <jgi@odoo.com>
Co-authored-by: Lucas Perais <lpe@odoo.com>
Co-authored-by: Sébastien Theys <seb@odoo.com>
Co-authored-by: Xavier Dubuc <xdu@odoo.com>
Diffstat (limited to 'addons/mail/static/src/models/messaging_notification_handler')
| -rw-r--r-- | addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js | 744 |
1 files changed, 744 insertions, 0 deletions
diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js new file mode 100644 index 00000000000..e779b5a20ff --- /dev/null +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -0,0 +1,744 @@ +odoo.define('mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { one2one } = require('mail/static/src/model/model_field.js'); + +const PREVIEW_MSG_MAX_SIZE = 350; // optimal for native English speakers + +function factory(dependencies) { + + class MessagingNotificationHandler extends dependencies['mail.model'] { + + /** + * @override + */ + delete() { + this.env.services['bus_service'].off('notification'); + this.env.services['bus_service'].stopPolling(); + super.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Fetch messaging data initially to populate the store specifically for + * the current users. This includes pinned channels for instance. + */ + start() { + this.env.services.bus_service.onNotification(null, notifs => this._handleNotifications(notifs)); + this.env.services.bus_service.startPolling(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @param {Object[]} notifications + * @returns {Object[]} + */ + _filterNotificationsOnUnsubscribe(notifications) { + const unsubscribedNotif = notifications.find(notif => + notif[1].info === 'unsubscribe'); + if (unsubscribedNotif) { + notifications = notifications.filter(notif => + notif[0][1] !== 'mail.channel' || + notif[0][2] !== unsubscribedNotif[1].id + ); + } + return notifications; + } + + /** + * @private + * @param {Object[]} notifications + * @param {Array} notifications[i][0] meta-data of the notification. + * @param {string} notifications[i][0][0] name of database this + * notification comes from. + * @param {string} notifications[i][0][1] type of notification. + * @param {integer} notifications[i][0][2] usually id of related type + * of notification. For instance, with `mail.channel`, this is the id + * of the channel. + * @param {Object} notifications[i][1] payload of the notification + */ + async _handleNotifications(notifications) { + const filteredNotifications = this._filterNotificationsOnUnsubscribe(notifications); + const proms = filteredNotifications.map(notification => { + const [[, model, id], data] = notification; + switch (model) { + case 'ir.needaction': + return this._handleNotificationNeedaction(data); + case 'mail.channel': + return this._handleNotificationChannel(Object.assign({ + channelId: id, + }, data)); + case 'res.partner': + return this._handleNotificationPartner(Object.assign({}, data)); + default: + console.warn(`mail.messaging_notification_handler: Unhandled notification "${model}"`); + return; + } + }); + await this.async(() => Promise.all(proms)); + } + + /** + * @private + * @param {Object} data + * @param {integer} data.channelId + * @param {string} [data.info] + * @param {boolean} [data.is_typing] + * @param {integer} [data.last_message_id] + * @param {integer} [data.partner_id] + */ + async _handleNotificationChannel(data) { + const { + channelId, + id, + info, + is_typing, + last_message_id, + partner_id, + } = data; + switch (info) { + case 'channel_fetched': + return this._handleNotificationChannelFetched({ + id, + channelId, + last_message_id, + partner_id, + }); + case 'channel_seen': + return this._handleNotificationChannelSeen({ + id, + channelId, + last_message_id, + partner_id, + }); + case 'typing_status': + return this._handleNotificationChannelTypingStatus({ + channelId, + is_typing, + partner_id, + }); + default: + return this._handleNotificationChannelMessage(data); + } + } + + /** + * @private + * @param {Object} param0 + * @param {integer} param0.channelId + * @param {integer} param0.id + * @param {integer} param0.last_message_id + * @param {integer} param0.partner_id + */ + async _handleNotificationChannelFetched({ + id, + channelId, + last_message_id, + partner_id, + }) { + const channel = this.env.models['mail.thread'].find(thread => + thread.id === channelId && thread.model === 'mail.channel' + ); + if (!channel) { + // for example seen from another browser, the current one has no + // knowledge of the channel + return; + } + channel.update({ + partnerSeenInfos: [['insert', + { + lastFetchedMessage: [['insert', {id: last_message_id}]], + id, + partner: [['insert', {id: partner_id}]], + } + ]], + messageSeenIndicators: [['insert', + { + id: this.env.models['mail.message_seen_indicator'].computeId(last_message_id, channel.id), + message: [['insert', {id: last_message_id}]], + } + ]], + }); + // FIXME force the computing of message values (cf task-2261221) + this.env.models['mail.message_seen_indicator'].recomputeFetchedValues(channel); + } + + /** + * @private + * @param {Object} param0 + * @param {integer} param0.channelId + * @param {...Object} param0.data + * @param {Array} [param0.data.author_id] + * @param {integer} param0.data.author_id[0] + * @param {integer[]} param0.data.channel_ids + */ + async _handleNotificationChannelMessage(param0) { + const channelId = param0.channelId; + const convertedData = this.env.models['mail.message'].convertData(param0); + + const oldMessage = this.env.models['mail.message'].find(message => + message.id === convertedData.id + ); + // locally save old value, as insert would overwrite them + const oldMessageModerationStatus = ( + oldMessage && oldMessage.moderation_status + ); + const oldMessageWasModeratedByCurrentPartner = ( + oldMessage && oldMessage.isModeratedByCurrentPartner + ); + + const message = this.env.models['mail.message'].insert(convertedData); + + // join the corresponding channel if necessary + const channel = this.env.models['mail.thread'].find(thread => + thread.id === channelId && + thread.model === 'mail.channel' + ); + if (!channel || !channel.isPinned) { + this.env.models['mail.thread'].joinChannel(channelId); + } + + // If the message was already known: nothing else should be done, + // except if it was pending moderation by the current partner, then + // decrement the moderation counter. + if (oldMessage) { + if ( + oldMessageModerationStatus === 'pending_moderation' && + message.moderation_status !== 'pending_moderation' && + oldMessageWasModeratedByCurrentPartner + ) { + const moderation = this.env.messaging.moderation; + moderation.update({ counter: moderation.counter - 1 }); + } + return; + } + + // If the current partner is author, do nothing else. + if (message.author === this.env.messaging.currentPartner) { + return; + } + + // If the channel wasn't known, joining the channel already updated + // the counter. + if (!channel) { + return; + } + + // Message from mailing channel should not make a notification in + // Odoo for users with notification "Handled by Email". + // Channel has been marked as read server-side in this case, so + // it should not display a notification by incrementing the + // unread counter. + if ( + channel && + channel.mass_mailing && + this.env.session.notification_type === 'email' + ) { + return; + } + + // In all other cases: update counter and notify if out of focus. + const isOdooFocused = this.env.services['bus_service'].isOdooFocused(); + if (!isOdooFocused) { + this._notifyNewChannelMessageWhileOutOfFocus({ channel, message }); + } + if (channel.model === 'mail.channel') { + channel.markAsFetched(); + } + channel.update({ message_unread_counter: channel.message_unread_counter + 1 }); + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * 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} param0 + * @param {integer} param0.channelId + * @param {integer} param0.id + * @param {integer} param0.last_message_id + * @param {integer} param0.partner_id + */ + async _handleNotificationChannelSeen({ + channelId, + id, + last_message_id, + partner_id, + }) { + const channel = this.env.models['mail.thread'].find(thread => + thread.id === channelId && thread.model === 'mail.channel' + ); + if (!channel) { + // for example seen from another browser, the current one has no + // knowledge of the channel + return; + } + const lastMessage = this.env.models['mail.message'].insert({id: last_message_id}); + const updateData = { + partnerSeenInfos: [['insert', + { + id, + lastFetchedMessage: [['link', lastMessage]], + lastSeenMessage: [['link', lastMessage]], + partner: [['insert', {id: partner_id}]], + }], + ], + messageSeenIndicators: [['insert', + { + id: this.env.models['mail.message_seen_indicator'].computeId(last_message_id, channel.id), + message: [['link', lastMessage]], + } + ]], + }; + if (this.env.messaging.currentPartner.id === partner_id) { + Object.assign(updateData, { + message_unread_counter: 0, + seen_message_id: last_message_id, + }); + } + channel.update(updateData); + // FIXME force the computing of thread values (cf task-2261221) + this.env.models['mail.thread'].computeLastCurrentPartnerMessageSeenByEveryone(channel); + // FIXME force the computing of message values (cf task-2261221) + this.env.models['mail.message_seen_indicator'].recomputeSeenValues(channel); + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer} param0.channelId + * @param {boolean} param0.is_typing + * @param {integer} param0.partner_id + */ + _handleNotificationChannelTypingStatus({ + channelId, + is_typing, + partner_id, + }) { + const channel = this.env.models['mail.thread'].insert({ + id: channelId, + model: 'mail.channel', + }); + const partner = this.env.models['mail.partner'].insert({ id: partner_id }); + if (partner === this.env.messaging.currentPartner) { + // Ignore management of current partner is typing notification. + return; + } + if (is_typing) { + if (channel.typingMembers.includes(partner)) { + channel.refreshOtherMemberTypingMember(partner); + } else { + channel.registerOtherMemberTypingMember(partner); + } + } else { + if (!channel.typingMembers.includes(partner)) { + // Ignore no longer typing notifications of members that + // are not registered as typing something. + return; + } + channel.unregisterOtherMemberTypingMember(partner); + } + } + + /** + * @private + * @param {Object} data + */ + _handleNotificationNeedaction(data) { + const message = this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(data) + ); + const inboxMailbox = this.env.messaging.inbox; + inboxMailbox.update({ counter: inboxMailbox.counter + 1 }); + for (const thread of message.allThreads) { + if ( + thread.channel_type === 'channel' && + message.allThreads.includes(inboxMailbox) + ) { + thread.update({ message_needaction_counter: thread.message_needaction_counter + 1 }); + } + const mainThreadCache = thread.mainCache; + mainThreadCache.update({ messages: [['link', message]] }); + } + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} data + * @param {string} [data.info] + * @param {string} [data.type] + */ + async _handleNotificationPartner(data) { + const { + info, + type, + } = data; + if (type === 'activity_updated') { + this.env.bus.trigger('activity_updated', data); + } else if (type === 'author') { + return this._handleNotificationPartnerAuthor(data); + } else if (type === 'deletion') { + return this._handleNotificationPartnerDeletion(data); + } else if (type === 'message_notification_update') { + return this._handleNotificationPartnerMessageNotificationUpdate(data.elements); + } else if (type === 'mark_as_read') { + return this._handleNotificationPartnerMarkAsRead(data); + } else if (type === 'moderator') { + return this._handleNotificationPartnerModerator(data); + } else if (type === 'simple_notification') { + const escapedTitle = owl.utils.escape(data.title); + const escapedMessage = owl.utils.escape(data.message); + this.env.services['notification'].notify({ + message: escapedMessage, + sticky: data.sticky, + title: escapedTitle, + type: data.warning ? 'warning' : 'danger', + }); + } else if (type === 'toggle_star') { + return this._handleNotificationPartnerToggleStar(data); + } else if (info === 'transient_message') { + return this._handleNotificationPartnerTransientMessage(data); + } else if (info === 'unsubscribe') { + return this._handleNotificationPartnerUnsubscribe(data.id); + } else if (type === 'user_connection') { + return this._handleNotificationPartnerUserConnection(data); + } else { + return this._handleNotificationPartnerChannel(data); + } + } + + /** + * @private + * @param {Object} data + * @param {Object} data.message + */ + _handleNotificationPartnerAuthor(data) { + this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(data.message) + ); + } + + /** + * @private + * @param {Object} data + * @param {string} data.channel_type + * @param {integer} data.id + * @param {string} [data.info] + * @param {boolean} data.is_minimized + * @param {string} data.name + * @param {string} data.state + * @param {string} data.uuid + */ + _handleNotificationPartnerChannel(data) { + const convertedData = this.env.models['mail.thread'].convertData(data); + + let channel = this.env.models['mail.thread'].find(thread => + thread.id === convertedData.id && + thread.model === 'mail.channel' + ); + const wasCurrentPartnerMember = ( + channel && + channel.members.includes(this.env.messaging.currentPartner) + ); + + channel = this.env.models['mail.thread'].insert(Object.assign( + { isPinned: true }, + convertedData, + )); + + if ( + channel.channel_type === 'channel' && + data.info !== 'creation' && + !wasCurrentPartnerMember + ) { + this.env.services['notification'].notify({ + message: _.str.sprintf( + this.env._t("You have been invited to: %s"), + owl.utils.escape(channel.name) + ), + title: this.env._t("Invitation"), + type: 'warning', + }); + } + // a new thread with unread messages could have been added + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer[]} param0.messag_ids + */ + _handleNotificationPartnerDeletion({ message_ids }) { + const moderationMailbox = this.env.messaging.moderation; + for (const id of message_ids) { + const message = this.env.models['mail.message'].find(message => message.id === id); + if (message) { + if ( + message.moderation_status === 'pending_moderation' && + message.originThread.isModeratedByCurrentPartner + ) { + moderationMailbox.update({ counter: moderationMailbox.counter - 1 }); + } + message.delete(); + } + } + // deleting message might have deleted notifications, force recompute + this.messaging.notificationGroupManager.computeGroups(); + // manually force recompute of counter (after computing the groups) + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} data + */ + _handleNotificationPartnerMessageNotificationUpdate(data) { + for (const messageData of data) { + const message = this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(messageData) + ); + // implicit: failures are sent by the server as notification + // only if the current partner is author of the message + if (!message.author && this.messaging.currentPartner) { + message.update({ author: [['link', this.messaging.currentPartner]] }); + } + } + this.messaging.notificationGroupManager.computeGroups(); + // manually force recompute of counter (after computing the groups) + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer[]} [param0.channel_ids + * @param {integer[]} [param0.message_ids=[]] + */ + _handleNotificationPartnerMarkAsRead({ channel_ids, message_ids = [] }) { + const inboxMailbox = this.env.messaging.inbox; + const historyMailbox = this.env.messaging.history; + const mainHistoryThreadCache = historyMailbox.mainCache; + + // 1. move messages from inbox to history + for (const cache of inboxMailbox.caches) { + const messages = message_ids + .map(id => this.env.models['mail.message'].find(message => message.id === id)) + .filter(message => !!message); + cache.update({ messages: [['unlink', messages]] }); + // TODO FIXME cannot update other caches of history since the + // messages in them depend on the search domain + mainHistoryThreadCache.update({ messages: [['link', messages]] }); + } + + // 2. remove "needaction" from channels + let channels; + if (channel_ids) { + channels = channel_ids + .map(id => this.env.models['mail.thread'].find(thread => + thread.id === id && + thread.model === 'mail.channel' + )) + .filter(thread => !!thread); + } else { + // flux specific: channel_ids unset means "mark all as read" + channels = this.env.models['mail.thread'].all(thread => + thread.model === 'mail.channel' + ); + } + for (const channel of channels) { + channel.update({ message_needaction_counter: 0 }); + } + inboxMailbox.update({ counter: inboxMailbox.counter - message_ids.length }); + + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {Object} param0.message + */ + _handleNotificationPartnerModerator({ message: data }) { + this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(data) + ); + const moderationMailbox = this.env.messaging.moderation; + if (moderationMailbox) { + moderationMailbox.update({ counter: moderationMailbox.counter + 1 }); + } + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer[]} param0.message_ids + * @param {boolean} param0.starred + */ + _handleNotificationPartnerToggleStar({ message_ids = [], starred }) { + const starredMailbox = this.env.messaging.starred; + for (const messageId of message_ids) { + const message = this.env.models['mail.message'].find(message => + message.id === messageId + ); + if (!message) { + continue; + } + if (starred) { + message.update({ threadCaches: [['link', starredMailbox.mainCache]] }); + starredMailbox.update({ counter: starredMailbox.counter + 1 }); + } else { + for (const cache of starredMailbox.caches) { + cache.update({ messages: [['unlink', message]] }); + } + starredMailbox.update({ counter: starredMailbox.counter - 1 }); + } + } + } + + /** + * 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 + */ + _handleNotificationPartnerTransientMessage(data) { + const convertedData = this.env.models['mail.message'].convertData(data); + const messageIds = this.env.models['mail.message'].all().map(message => message.id); + const partnerRoot = this.env.messaging.partnerRoot; + this.env.models['mail.message'].create(Object.assign(convertedData, { + author: [['link', partnerRoot]], + id: (messageIds ? Math.max(...messageIds) : 0) + 0.01, + isTransient: true, + })); + } + + /** + * @private + * @param {integer} channelId + */ + _handleNotificationPartnerUnsubscribe(channelId) { + const channel = this.env.models['mail.thread'].find(thread => + thread.id === channelId && + thread.model === 'mail.channel' + ); + if (!channel) { + return; + } + let message; + if (channel.correspondent) { + const correspondent = channel.correspondent; + message = _.str.sprintf( + this.env._t("You unpinned your conversation with <b>%s</b>."), + owl.utils.escape(correspondent.name) + ); + } else { + message = _.str.sprintf( + this.env._t("You unsubscribed from <b>%s</b>."), + owl.utils.escape(channel.name) + ); + } + this.env.services['notification'].notify({ + message, + title: this.env._t("Unsubscribed"), + type: 'warning', + }); + channel.update({ isPinned: false }); + } + + /** + * @private + * @param {Object} param0 + * @param {string} param0.message + * @param {integer} param0.partner_id + * @param {string} param0.title + */ + _handleNotificationPartnerUserConnection({ message, partner_id, title }) { + this.env.services['bus_service'].sendNotification(title, message); + const partner = this.env.models['mail.partner'].insert({ + id: partner_id, + }); + partner.openChat(); + } + + /** + * @private + * @param {Object} param0 + * @param {mail.thread} param0.channel + * @param {mail.message} param0.message + */ + _notifyNewChannelMessageWhileOutOfFocus({ channel, message }) { + const author = message.author; + const messaging = this.env.messaging; + let notificationTitle; + if (!author) { + notificationTitle = this.env._t("New message"); + } else { + const authorName = author.nameOrDisplayName; + if (channel.channel_type === 'channel') { + // hack: notification template does not support OWL components, + // so we simply use their template to make HTML as if it comes + // from component + const channelIcon = this.env.qweb.renderToString('mail.ThreadIcon', { + env: this.env, + thread: channel, + }); + const channelName = owl.utils.escape(channel.displayName); + const channelNameWithIcon = channelIcon + channelName; + notificationTitle = _.str.sprintf( + this.env._t("%s from %s"), + owl.utils.escape(authorName), + channelNameWithIcon + ); + } else { + notificationTitle = owl.utils.escape(authorName); + } + } + const notificationContent = message.prettyBody.substr(0, PREVIEW_MSG_MAX_SIZE); + this.env.services['bus_service'].sendNotification(notificationTitle, notificationContent); + messaging.update({ outOfFocusUnreadMessageCounter: messaging.outOfFocusUnreadMessageCounter + 1 }); + const titlePattern = messaging.outOfFocusUnreadMessageCounter === 1 + ? this.env._t("%d Message") + : this.env._t("%d Messages"); + this.env.bus.trigger('set_title_part', { + part: '_chat', + title: _.str.sprintf(titlePattern, messaging.outOfFocusUnreadMessageCounter), + }); + } + + } + + MessagingNotificationHandler.fields = { + messaging: one2one('mail.messaging', { + inverse: 'notificationHandler', + }), + }; + + MessagingNotificationHandler.modelName = 'mail.messaging_notification_handler'; + + return MessagingNotificationHandler; +} + +registerNewModel('mail.messaging_notification_handler', factory); + +}); |
