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 { decrement, increment } = require('mail/static/src/model/model_field_command.js');
const { htmlToTextContentInline } = require('mail.utils');
const PREVIEW_MSG_MAX_SIZE = 350; // optimal for native English speakers
function factory(dependencies) {
class MessagingNotificationHandler extends dependencies['mail.model'] {
/**
* @override
*/
_willDelete() {
if (this.env.services['bus_service']) {
this.env.services['bus_service'].off('notification');
this.env.services['bus_service'].stopPolling();
}
return super._willDelete(...arguments);
}
//----------------------------------------------------------------------
// 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|string} 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 [channel, message] = notification;
if (typeof channel === 'string') {
// uuid notification, only for (livechat) public handler
return;
}
const [, model, id] = channel;
switch (model) {
case 'ir.needaction':
return this._handleNotificationNeedaction(message);
case 'mail.channel':
return this._handleNotificationChannel(id, message);
case 'res.partner':
if (id !== this.env.messaging.currentPartner.id) {
// ignore broadcast to other partners
return;
}
return this._handleNotificationPartner(Object.assign({}, message));
}
});
await this.async(() => Promise.all(proms));
}
/**
* @private
* @param {integer} channelId
* @param {Object} data
* @param {string} [data.info]
* @param {boolean} [data.is_typing]
* @param {integer} [data.last_message_id]
* @param {integer} [data.partner_id]
*/
async _handleNotificationChannel(channelId, data) {
const {
info,
is_typing,
last_message_id,
partner_id,
partner_name,
} = data;
switch (info) {
case 'channel_fetched':
return this._handleNotificationChannelFetched(channelId, {
last_message_id,
partner_id,
});
case 'channel_seen':
return this._handleNotificationChannelSeen(channelId, {
last_message_id,
partner_id,
});
case 'typing_status':
return this._handleNotificationChannelTypingStatus(channelId, {
is_typing,
partner_id,
partner_name,
});
default:
return this._handleNotificationChannelMessage(channelId, data);
}
}
/**
* @private
* @param {integer} channelId
* @param {Object} param1
* @param {integer} param1.last_message_id
* @param {integer} param1.partner_id
*/
async _handleNotificationChannelFetched(channelId, {
last_message_id,
partner_id,
}) {
const channel = this.env.models['mail.thread'].findFromIdentifyingData({
id: channelId,
model: 'mail.channel',
});
if (!channel) {
// for example seen from another browser, the current one has no
// knowledge of the channel
return;
}
if (channel.channel_type === 'channel') {
// disabled on `channel` channels for performance reasons
return;
}
this.env.models['mail.thread_partner_seen_info'].insert({
channelId: channel.id,
lastFetchedMessage: [['insert', { id: last_message_id }]],
partnerId: partner_id,
});
channel.update({
messageSeenIndicators: [['insert',
{
channelId: channel.id,
messageId: last_message_id,
}
]],
});
// FIXME force the computing of message values (cf task-2261221)
this.env.models['mail.message_seen_indicator'].recomputeFetchedValues(channel);
}
/**
* @private
* @param {integer} channelId
* @param {Object} messageData
*/
async _handleNotificationChannelMessage(channelId, messageData) {
let channel = this.env.models['mail.thread'].findFromIdentifyingData({
id: channelId,
model: 'mail.channel',
});
const wasChannelExisting = !!channel;
const convertedData = this.env.models['mail.message'].convertData(messageData);
const oldMessage = this.env.models['mail.message'].findFromIdentifyingData(convertedData);
// locally save old values, as insert would overwrite them
const oldMessageModerationStatus = (
oldMessage && oldMessage.moderation_status
);
const oldMessageWasModeratedByCurrentPartner = (
oldMessage && oldMessage.isModeratedByCurrentPartner
);
// Fetch missing info from channel before going further. Inserting
// a channel with incomplete info can lead to issues. This is in
// particular the case with the `uuid` field that is assumed
// "required" by the rest of the code and is necessary for some
// features such as chat windows.
if (!channel) {
channel = (await this.async(() =>
this.env.models['mail.thread'].performRpcChannelInfo({ ids: [channelId] })
))[0];
}
if (!channel.isPinned) {
channel.pin();
}
const message = this.env.models['mail.message'].insert(convertedData);
this._notifyThreadViewsMessageReceived(message);
// 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: decrement() });
}
return;
}
// If the current partner is author, do nothing else.
if (message.author === this.env.messaging.currentPartner) {
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.mass_mailing &&
this.env.session.notification_type === 'email'
) {
this._handleNotificationChannelSeen(channelId, {
last_message_id: messageData.id,
partner_id: this.env.messaging.currentPartner.id,
});
return;
}
// In all other cases: update counter and notify if necessary
// Chat from OdooBot is considered disturbing and should only be
// shown on the menu, but no notification and no thread open.
const isChatWithOdooBot = (
channel.correspondent &&
channel.correspondent === this.env.messaging.partnerRoot
);
if (!isChatWithOdooBot) {
const isOdooFocused = this.env.services['bus_service'].isOdooFocused();
// Notify if out of focus
if (!isOdooFocused && channel.isChatChannel) {
this._notifyNewChannelMessageWhileOutOfFocus({
channel,
message,
});
}
if (channel.model === 'mail.channel' && channel.channel_type !== 'channel') {
// disabled on non-channel threads and
// on `channel` channels for performance reasons
channel.markAsFetched();
}
// open chat on receiving new message if it was not already opened or folded
if (channel.channel_type !== 'channel' && !this.env.messaging.device.isMobile && !channel.chatWindow) {
this.env.messaging.chatWindowManager.openThread(channel);
}
}
// If the channel wasn't known its correct counter was fetched at
// the start of the method, no need update it here.
if (!wasChannelExisting) {
return;
}
// manually force recompute of counter
this.messaging.messagingMenu.update();
}
/**
* Called when a channel has been seen, and the server responds with the
* last message seen. Useful in order to track last message seen.
*
* @private
* @param {integer} channelId
* @param {Object} param1
* @param {integer} param1.last_message_id
* @param {integer} param1.partner_id
*/
async _handleNotificationChannelSeen(channelId, {
last_message_id,
partner_id,
}) {
const channel = this.env.models['mail.thread'].findFromIdentifyingData({
id: channelId,
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 });
// restrict computation of seen indicator for "non-channel" channels
// for performance reasons
const shouldComputeSeenIndicators = channel.channel_type !== 'channel';
const updateData = {};
if (shouldComputeSeenIndicators) {
this.env.models['mail.thread_partner_seen_info'].insert({
channelId: channel.id,
lastSeenMessage: [['link', lastMessage]],
partnerId: partner_id,
});
Object.assign(updateData, {
// FIXME should no longer use computeId (task-2335647)
messageSeenIndicators: [['insert',
{
channelId: channel.id,
messageId: lastMessage.id,
},
]],
});
}
if (this.env.messaging.currentPartner.id === partner_id) {
Object.assign(updateData, {
lastSeenByCurrentPartnerMessageId: last_message_id,
pendingSeenMessageId: undefined,
});
}
channel.update(updateData);
if (shouldComputeSeenIndicators) {
// 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 {integer} channelId
* @param {Object} param1
* @param {boolean} param1.is_typing
* @param {integer} param1.partner_id
* @param {string} param1.partner_name
*/
_handleNotificationChannelTypingStatus(channelId, { is_typing, partner_id, partner_name }) {
const channel = this.env.models['mail.thread'].findFromIdentifyingData({
id: channelId,
model: 'mail.channel',
});
if (!channel) {
return;
}
const partner = this.env.models['mail.partner'].insert({
id: partner_id,
name: partner_name,
});
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)
);
this.env.messaging.inbox.update({ counter: increment() });
const originThread = message.originThread;
if (originThread && message.isNeedaction) {
originThread.update({ message_needaction_counter: increment() });
}
// 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 (info === 'channel_seen') {
return this._handleNotificationChannelSeen(data.channel_id, 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 escapedMessage = owl.utils.escape(data.message);
this.env.services['notification'].notify({
message: escapedMessage,
sticky: data.sticky,
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 if (!type) {
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(
Object.assign({ model: 'mail.channel' }, data)
);
if (!convertedData.members) {
// channel_info does not return all members of channel for
// performance reasons, but code is expecting to know at
// least if the current partner is member of it.
// (e.g. to know when to display "invited" notification)
// Current partner can always be assumed to be a member of
// channels received through this notification.
convertedData.members = [['link', this.env.messaging.currentPartner]];
}
let channel = this.env.models['mail.thread'].findFromIdentifyingData(convertedData);
const wasCurrentPartnerMember = (
channel &&
channel.members.includes(this.env.messaging.currentPartner)
);
channel = this.env.models['mail.thread'].insert(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)
),
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'].findFromIdentifyingData({ id });
if (message) {
if (
message.moderation_status === 'pending_moderation' &&
message.originThread.isModeratedByCurrentPartner
) {
moderationMailbox.update({ counter: decrement() });
}
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=[]]
* @param {integer} [param0.needaction_inbox_counter]
*/
_handleNotificationPartnerMarkAsRead({ channel_ids, message_ids = [], needaction_inbox_counter }) {
for (const message_id of message_ids) {
// We need to ignore all not yet known messages because we don't want them
// to be shown partially as they would be linked directly to mainCache
// Furthermore, server should not send back all message_ids marked as read
// but something like last read message_id or something like that.
// (just imagine you mark 1000 messages as read ... )
const message = this.env.models['mail.message'].findFromIdentifyingData({ id: message_id });
if (!message) {
continue;
}
// update thread counter
const originThread = message.originThread;
if (originThread && message.isNeedaction) {
originThread.update({ message_needaction_counter: decrement() });
}
// move messages from Inbox to history
message.update({
isHistory: true,
isNeedaction: false,
});
}
const inbox = this.env.messaging.inbox;
if (needaction_inbox_counter !== undefined) {
inbox.update({ counter: needaction_inbox_counter });
} else {
// kept for compatibility in stable
inbox.update({ counter: decrement(message_ids.length) });
}
if (inbox.counter > inbox.mainCache.fetchedMessages.length) {
// Force refresh Inbox because depending on what was marked as
// read the cache might become empty even though there are more
// messages on the server.
inbox.mainCache.update({ hasToLoadMessages: true });
}
// 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: increment() });
}
// 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'].findFromIdentifyingData({
id: messageId,
});
if (!message) {
continue;
}
message.update({ isStarred: starred });
starredMailbox.update({
counter: starred ? increment() : decrement(),
});
}
}
/**
* 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 lastMessageId = this.env.models['mail.message'].all().reduce(
(lastMessageId, message) => Math.max(lastMessageId, message.id),
0
);
const partnerRoot = this.env.messaging.partnerRoot;
const message = this.env.models['mail.message'].create(Object.assign(convertedData, {
author: [['link', partnerRoot]],
id: lastMessageId + 0.01,
isTransient: true,
}));
this._notifyThreadViewsMessageReceived(message);
// manually force recompute of counter
this.messaging.messagingMenu.update();
}
/**
* @private
* @param {integer} channelId
*/
_handleNotificationPartnerUnsubscribe(channelId) {
const channel = this.env.models['mail.thread'].findFromIdentifyingData({
id: channelId,
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 %s."),
owl.utils.escape(correspondent.name)
);
} else {
message = _.str.sprintf(
this.env._t("You unsubscribed from %s."),
owl.utils.escape(channel.name)
);
}
// We assume that arriving here the server has effectively
// unpinned the channel
channel.update({ isServerPinned: false });
this.env.services['notification'].notify({
message,
type: 'warning',
});
}
/**
* @private
* @param {Object} param0
* @param {string} param0.message
* @param {integer} param0.partner_id
* @param {string} param0.title
*/
async _handleNotificationPartnerUserConnection({ message, partner_id, title }) {
// If the current user invited a new user, and the new user is
// connecting for the first time while the current user is present
// then open a chat for the current user with the new user.
this.env.services['bus_service'].sendNotification(title, message);
const chat = await this.async(() =>
this.env.messaging.getChat({ partnerId: partner_id }
));
if (!chat || this.env.messaging.device.isMobile) {
return;
}
this.env.messaging.chatWindowManager.openThread(chat);
}
/**
* @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 = htmlToTextContentInline(message.body).substr(0, PREVIEW_MSG_MAX_SIZE);
this.env.services['bus_service'].sendNotification(notificationTitle, notificationContent);
messaging.update({ outOfFocusUnreadMessageCounter: increment() });
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),
});
}
/**
* Notifies threadViews about the given message being just received.
* This can allow them adjust their scroll position if applicable.
*
* @private
* @param {mail.message}
*/
_notifyThreadViewsMessageReceived(message) {
for (const thread of message.threads) {
for (const threadView of thread.threadViews) {
threadView.addComponentHint('message-received', { message });
}
}
}
}
MessagingNotificationHandler.fields = {
messaging: one2one('mail.messaging', {
inverse: 'notificationHandler',
}),
};
MessagingNotificationHandler.modelName = 'mail.messaging_notification_handler';
return MessagingNotificationHandler;
}
registerNewModel('mail.messaging_notification_handler', factory);
});