diff --git a/addons/im_livechat/static/src/legacy/models/abstract_message.js b/addons/im_livechat/static/src/legacy/models/abstract_message.js deleted file mode 100644 index 3daf069d5d3..00000000000 --- a/addons/im_livechat/static/src/legacy/models/abstract_message.js +++ /dev/null @@ -1,462 +0,0 @@ -/** @odoo-module **/ - -import * as mailUtils from '@mail/js/utils'; - -import Class from 'web.Class'; -import core from 'web.core'; -import session from 'web.session'; -import time from 'web.time'; - -const _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. - */ -const 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(parent, data) { - // Attachments are not supported in (public) livechat. - // We ignore data from server, otherwise field commands - // will be wrongly considered as unamed attachments. - this._attachmentIDs = []; - 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; - this._serverAuthorID = data.author_id; - this._type = data.message_type || undefined; - - this._processAttachmentURL(); - this._attachmentIDs.forEach(function (attachment) { - attachment.filename = attachment.filename || attachment.name || _t("unnamed"); - }); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Get the list of files attached to this message. - * Note that attachments are stored with server-format - * - * @return {Object[]} - */ - getAttachments() { - return this._attachmentIDs; - }, - /** - * Get the server ID (number) of the author of this message - * If there are no author, return -1; - * - * @return {integer} - */ - getAuthorID() { - if (!this.hasAuthor()) { - return -1; - } - return this._serverAuthorID[0]; - }, - /** - * Threads do not have an im status by default - * - * @return {undefined} - */ - getAuthorImStatus() { - return undefined; - }, - /** - * Get the relative url of the avatar to display next to the message - * - * @abstract - * @return {string} - */ - getAvatarSource() { - if (this.hasAuthor()) { - return '/web/image/res.partner/' + this.getAuthorID() + '/avatar_128'; - } - }, - /** - * Get the body content of this message - * - * @return {string} - */ - getBody() { - return this._body; - }, - /** - * @return {moment} - */ - getDate() { - return this._date; - }, - /** - * Get the date day of this message - * - * @return {string} - */ - getDateDay() { - const 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() { - return this.hasAuthor() ? this._getAuthorName() : null; - }, - /** - * Get the server ID (number) of this message - * - * @override - * @return {integer} - */ - getID() { - return this._id; - }, - /** - * Get the list of images attached to this message. - * Note that attachments are stored with server-format - * - * @return {Object[]} - */ - getImageAttachments() { - 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() { - return _.difference(this.getAttachments(), this.getImageAttachments()); - }, - /** - * Gets the class to use as the notification icon. - * - * @returns {string} - */ - getNotificationIcon() { - if (!this.hasNotificationsError()) { - return 'fa fa-envelope-o'; - } - return 'fa fa-envelope'; - }, - /** - * Gets the list of notifications of this message, in no specific order. - * By default messages do not have notifications. - * - * @returns {Object[]} - */ - getNotifications() { - return []; - }, - /** - * Gets the text to display next to the notification icon. - * - * @returns {string} - */ - getNotificationText() { - return ''; - }, - /** - * Get the time elapsed between sent message and now - * - * @return {string} - */ - getTimeElapsed() { - 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() { - return this._type; - }, - /** - * State whether this message contains some attachments. - * - * @override - * @return {boolean} - */ - hasAttachments() { - return this.getAttachments().length > 0; - }, - /** - * State whether this message has an author - * - * @return {boolean} - */ - hasAuthor() { - return !!(this._serverAuthorID && this._serverAuthorID[0]); - }, - /** - * State whether this message has an email of its sender. - * By default, messages do not have any email of its sender. - * - * @return {string} - */ - hasEmailFrom() { - return false; - }, - /** - * State whether this image contains images attachments - * - * @return {boolean} - */ - hasImageAttachments() { - 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() { - return _.some(this.getAttachments(), function (attachment) { - return !(attachment.mimetype && attachment.mimetype.split('/')[0] === 'image'); - }); - }, - /** - * States whether this message has some notifications. - * - * @returns {boolean} - */ - hasNotifications() { - return this.getNotifications().length > 0; - }, - /** - * States whether this message has notifications that are in error. - * - * @returns {boolean} - */ - hasNotificationsError() { - return this.getNotifications().some(notif => - notif.notification_status === 'exception' || - notif.notification_status === 'bounce' - ); - }, - /** - * State whether this message originates from a channel. - * By default, messages do not originate from a channel. - * - * @override - * @return {boolean} - */ - originatesFromChannel() { - return false; - }, - /** - * State whether this message has a subject - * By default, messages do not have any subject. - * - * @return {boolean} - */ - hasSubject() { - return false; - }, - /** - * State whether this message is empty - * - * @return {boolean} - */ - isEmpty() { - return !this.hasTrackingValues() && - !this.hasAttachments() && - !this.getBody(); - }, - /** - * By default, messages do not have any subtype description - * - * @return {boolean} - */ - hasSubtypeDescription() { - return false; - }, - /** - * State whether this message contains some tracking values - * By default, messages do not have any tracking values. - * - * @return {boolean} - */ - hasTrackingValues() { - return false; - }, - /** - * State whether this message is a discussion - * - * @return {boolean} - */ - isDiscussion() { - return this._isDiscussion; - }, - /** - * State whether this message is linked to a document thread - * By default, messages are not linked to a document thread. - * - * @return {boolean} - */ - isLinkedToDocumentThread() { - return false; - }, - /** - * State whether this message is needaction - * By default, messages are not needaction. - * - * @return {boolean} - */ - isNeedaction() { - return false; - }, - /** - * State whether this message is a note (i.e. a message from "Log note") - * - * @return {boolean} - */ - isNote() { - 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() { - return this._isNotification; - }, - /** - * State whether this message is starred - * By default, messages are not starred. - * - * @return {boolean} - */ - isStarred() { - return false; - }, - /** - * State whether this message is a system notification - * By default, messages are not system notifications - * - * @override - * @return {boolean} - */ - isSystemNotification() { - return false; - }, - /** - * States whether the current message needs moderation in general. - * By default, messages do not require any moderation. - * - * @returns {boolean} - */ - needsModeration() { - return false; - }, - /** - * @params {integer[]} attachmentIDs - */ - removeAttachments(attachmentIDs) { - this._attachmentIDs = _.reject(this._attachmentIDs, function (attachment) { - return _.contains(attachmentIDs, attachment.id); - }); - }, - /** - * 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() { - return !this._isMyselfAuthor(); - }, - - //-------------------------------------------------------------------------- - // 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() { - if (!this.hasAuthor()) { - return ""; - } - return this._serverAuthorID[1]; - }, - /** - * State whether the current user is the author of this message - * - * @private - * @return {boolean} - */ - _isMyselfAuthor() { - return this.hasAuthor() && (this.getAuthorID() === session.partner_id); - }, - /** - * Compute url of attachments of this message - * - * @private - */ - _processAttachmentURL() { - for (let attachment of this.getAttachments()) { - attachment.url = '/web/content/' + attachment.id + '?download=true'; - } - }, - -}); - -export default AbstractMessage; diff --git a/addons/im_livechat/static/src/legacy/models/abstract_thread.js b/addons/im_livechat/static/src/legacy/models/abstract_thread.js deleted file mode 100644 index 70911015d17..00000000000 --- a/addons/im_livechat/static/src/legacy/models/abstract_thread.js +++ /dev/null @@ -1,194 +0,0 @@ -/** @odoo-module **/ - -import Class from 'web.Class'; -import Mixins from'web.mixins'; - -/** - * Abstract thread is the super class of all threads, either backend threads - * (which are compatible with mail service) or website livechats. - * - * Abstract threads contain abstract messages - */ -const AbstractThread = Class.extend(Mixins.EventDispatcherMixin, { - /** - * @param {Object} params - * @param {Object} params.data - * @param {integer|string} params.data.id the ID of this thread - * @param {string} params.data.name the name of this thread - * @param {string} [params.data.status=''] the status of this thread - * @param {Object} params.parent Object with the event-dispatcher mixin - * (@see {web.mixins.EventDispatcherMixin}) - */ - init(params) { - Mixins.EventDispatcherMixin.init.call(this, arguments); - this.setParent(params.parent); - - this._folded = false; // threads are unfolded by default - this._id = params.data.id; - this._name = params.data.name; - this._status = params.data.status || ''; - this._unreadCounter = 0; // amount of messages not yet been read - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Add a message to this thread. - * - * @param {@im_livechat/legacy/models/abstract_message} message - */ - addMessage(message) { - this._addMessage(...arguments); - this.trigger('message_added', message); - }, - /** - * Updates the folded state of the thread - * - * @param {boolean} folded - */ - fold(folded) { - this._folded = folded; - }, - /** - * Get the ID of this thread - * - * @returns {integer|string} - */ - getID() { - return this._id; - }, - /** - * @abstract - * @returns {@im_livechat/legacy/models/abstract_message[]} - */ - getMessages() {}, - /** - * Get the name of this thread. If the name of the thread has been created - * by the user from an input, it may be escaped. - * - * @returns {string} - */ - getName() { - return this._name; - }, - /** - * Get the status of the thread (e.g. 'online', 'offline', etc.) - * - * @returns {string} - */ - getStatus() { - return this._status; - }, - /** - * Returns the title to display in thread window's headers. - * - * @returns {string} the name of the thread by default (see @getName) - */ - getTitle() { - return this.getName(); - }, - getType() {}, - /** - * @returns {integer} - */ - getUnreadCounter() { - return this._unreadCounter; - }, - /** - * @returns {boolean} - */ - hasMessages() { - return !_.isEmpty(this.getMessages()); - }, - /** - * States whether this thread is folded or not. - * - * @return {boolean} - */ - isFolded() { - return this._folded; - }, - /** - * Mark the thread as read, which resets the unread counter to 0. This is - * only performed if the unread counter is not 0. - * - * @returns {Promise} - */ - markAsRead() { - if (this._unreadCounter > 0) { - return this._markAsRead(); - } - return Promise.resolve(); - }, - /** - * Post a message on this thread - * - * @returns {Promise} resolved with the message object to be sent to the - * server - */ - postMessage() { - return this._postMessage(...arguments) - .then(this.trigger.bind(this, 'message_posted')); - }, - /** - * Resets the unread counter of this thread to 0. - */ - resetUnreadCounter() { - this._unreadCounter = 0; - this._warnUpdatedUnreadCounter(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add a message to this thread. - * - * @abstract - * @private - * @param {@im_livechat/legacy/models/abstract_message} message - */ - _addMessage(message) {}, - /** - * Increments the unread counter of this thread by 1 unit. - * - * @private - */ - _incrementUnreadCounter() { - this._unreadCounter++; - }, - /** - * Mark the thread as read - * - * @private - * @returns {Promise} - */ - _markAsRead() { - this.resetUnreadCounter(); - return Promise.resolve(); - }, - /** - * Post a message on this thread - * - * @abstract - * @private - * @returns {Promise} resolved with the message object to be sent to the - * server - */ - _postMessage() { - return Promise.resolve(); - }, - /** - * Warn views (e.g. discuss app, thread window, etc.) to update visually - * the unread counter of this thread. - * - * @abstract - * @private - */ - _warnUpdatedUnreadCounter() {}, -}); - -export default AbstractThread; diff --git a/addons/im_livechat/static/src/legacy/models/website_livechat.js b/addons/im_livechat/static/src/legacy/models/website_livechat.js index 2fda5df2dfb..42e346b5411 100644 --- a/addons/im_livechat/static/src/legacy/models/website_livechat.js +++ b/addons/im_livechat/static/src/legacy/models/website_livechat.js @@ -1,15 +1,16 @@ /** @odoo-module **/ -import AbstractThread from '@im_livechat/legacy/models/abstract_thread'; import ThreadTypingMixin from '@im_livechat/legacy/models/thread_typing_mixin'; +import Class from 'web.Class'; import session from 'web.session'; +import Mixins from 'web.mixins'; /** * Thread model that represents a livechat on the website-side. This livechat * is not linked to the mail service. */ -const WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, { +const PublicLivechat = Class.extend(Mixins.EventDispatcherMixin, ThreadTypingMixin, { /** * @override @@ -26,11 +27,19 @@ const WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, { * @param {string} params.data.name the name of this livechat. * @param {string} [params.data.state] if 'folded', the livechat is folded. * This is ignored if `folded` is provided and is a boolean value. + * @param {string} [params.data.status=''] the status of this thread * @param {string} params.data.uuid the UUID of this livechat. * @param {@im_livechat/legacy/widgets/livechat_button} params.parent */ init(params) { - this._super(...arguments); + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(params.parent); + + this._folded = false; // threads are unfolded by default + this._id = params.data.id; + this._name = params.data.name; + this._status = params.data.status || ''; + this._unreadCounter = 0; // amount of messages not yet been read ThreadTypingMixin.init.call(this, arguments); this._members = []; @@ -59,26 +68,87 @@ const WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, { // Public //-------------------------------------------------------------------------- + /** + * Add a message to this thread. + * + * @param {@im_livechat/legacy/models/public_livechat_message} message + */ + addMessage(message) { + this.trigger('message_added', message); + }, + /** + * Updates the folded state of the thread + * + * @param {boolean} folded + */ + fold(folded) { + this._folded = folded; + }, + /** + * Get the ID of this thread + * + * @returns {integer|string} + */ + getID() { + return this._id; + }, /** * @override - * @returns {@im_livechat/legacy/models/website_livechat_message[]} + * @returns {@im_livechat/legacy/models/public_livechat_message[]} */ getMessages() { // ignore removed messages return this._messages.filter(message => !message.isEmpty()); }, + /** + * Get the name of this thread. If the name of the thread has been created + * by the user from an input, it may be escaped. + * + * @returns {string} + */ + getName() { + return this._name; + }, /** * @returns {Array} */ getOperatorPID() { return this._operatorPID; }, + /** + * Get the status of the thread (e.g. 'online', 'offline', etc.) + * + * @returns {string} + */ + getStatus() { + return this._status; + }, + /** + * Returns the title to display in thread window's headers. + * + * @returns {string} the name of the thread by default (see @getName) + */ + getTitle() { + return this.getName(); + }, + /** + * @returns {integer} + */ + getUnreadCounter() { + return this._unreadCounter; + }, /** * @returns {string} */ getUUID() { return this._uuid; }, + /** + * @returns {boolean} + */ + hasMessages() { + return !_.isEmpty(this.getMessages()); + }, /** * Increments the unread counter of this livechat by 1 unit. * @@ -89,10 +159,47 @@ const WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, { incrementUnreadCounter() { this._incrementUnreadCounter(); }, + /** + * States whether this thread is folded or not. + * + * @return {boolean} + */ + isFolded() { + return this._folded; + }, + /** + * Mark the thread as read, which resets the unread counter to 0. This is + * only performed if the unread counter is not 0. + * + * @returns {Promise} + */ + markAsRead() { + if (this._unreadCounter > 0) { + return this._markAsRead(); + } + return Promise.resolve(); + }, + /** + * Post a message on this thread + * + * @returns {Promise} resolved with the message object to be sent to the + * server + */ + postMessage() { + return this._postMessage(...arguments) + .then(this.trigger.bind(this, 'message_posted')); + }, + /** + * Resets the unread counter of this thread to 0. + */ + resetUnreadCounter() { + this._unreadCounter = 0; + this._warnUpdatedUnreadCounter(); + }, /** * AKU: hack for the moment * - * @param {@im_livechat/legacy/models/website_livechat_message[]} messages + * @param {@im_livechat/legacy/models/public_livechat_message[]} messages */ setMessages(messages) { this._messages = messages; @@ -115,6 +222,14 @@ const WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, { // Private //-------------------------------------------------------------------------- + /** + * Increments the unread counter of this thread by 1 unit. + * + * @private + */ + _incrementUnreadCounter() { + this._unreadCounter++; + }, /** * @override {mail.model.ThreadTypingMixin} * @private @@ -125,6 +240,16 @@ const WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, { _isTypingMyselfInfo(params) { return params.isWebsiteUser; }, + /** + * Mark the thread as read + * + * @private + * @returns {Promise} + */ + _markAsRead() { + this.resetUnreadCounter(); + return Promise.resolve(); + }, /** * @override {mail.model.ThreadTypingMixin} * @private @@ -138,6 +263,16 @@ const WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, { is_typing: params.typing, }, { shadow: true }); }, + /** + * Post a message on this thread + * + * @private + * @returns {Promise} resolved with the message object to be sent to the + * server + */ + _postMessage() { + return Promise.resolve(); + }, /** * Warn views that the list of users that are currently typing on this * livechat has been updated. @@ -151,7 +286,6 @@ const WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, { /** * Warn that the unread counter has been updated on this livechat * - * @override * @private */ _warnUpdatedUnreadCounter() { @@ -181,4 +315,4 @@ const WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, { }, }); -export default WebsiteLivechat; +export default PublicLivechat; diff --git a/addons/im_livechat/static/src/legacy/models/website_livechat_message.js b/addons/im_livechat/static/src/legacy/models/website_livechat_message.js index 051fa90a205..f35889065e2 100644 --- a/addons/im_livechat/static/src/legacy/models/website_livechat_message.js +++ b/addons/im_livechat/static/src/legacy/models/website_livechat_message.js @@ -1,26 +1,55 @@ /** @odoo-module **/ -import AbstractMessage from '@im_livechat/legacy/models/abstract_message'; +import * as mailUtils from '@mail/js/utils'; + +import Class from 'web.Class'; +import { _t } from 'web.core'; +import session from 'web.session'; +import time from 'web.time'; /** * This is a message that is handled by im_livechat, without making use of the * mail.Manager. The purpose of this is to make im_livechat compatible with * mail.widget.Thread. * - * @see @im_livechat/legacy/models/abstract_message for more information. + * @see @im_livechat/legacy/models/public_livechat_message for more information. */ -const WebsiteLivechatMessage = AbstractMessage.extend({ +const PublicLivechatMessage = Class.extend({ /** * @param {@im_livechat/legacy/widgets/livechat_button} 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] * @param {Object} options * @param {string} options.default_username * @param {string} options.serverURL */ init(parent, data, options) { - this._super(...arguments); + // Attachments are not supported in (public) livechat. + // We ignore data from server, otherwise field commands + // will be wrongly considered as unamed attachments. + this._attachmentIDs = []; + 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; + this._serverAuthorID = data.author_id; + this._type = data.message_type || undefined; + this._processAttachmentURL(); + this._attachmentIDs.forEach(function (attachment) { + attachment.filename = attachment.filename || attachment.name || _t("unnamed"); + }); this._defaultUsername = options.default_username; this._serverURL = options.serverURL; }, @@ -29,13 +58,41 @@ const WebsiteLivechatMessage = AbstractMessage.extend({ // Public //-------------------------------------------------------------------------- + /** + * Get the list of files attached to this message. + * Note that attachments are stored with server-format + * + * @return {Object[]} + */ + getAttachments() { + return this._attachmentIDs; + }, + /** + * Get the server ID (number) of the author of this message + * If there are no author, return -1; + * + * @return {integer} + */ + getAuthorID() { + if (!this.hasAuthor()) { + return -1; + } + return this._serverAuthorID[0]; + }, + /** + * Threads do not have an im status by default + * + * @return {undefined} + */ + getAuthorImStatus() { + return undefined; + }, /** * Get the relative url of the avatar to display next to the message * - * @override * @return {string} */ - getAvatarSource() { + getAvatarSource() { let source = this._serverURL; if (this.hasAuthor()) { source += `/im_livechat/operator/${this.getAuthorID()}/avatar`; @@ -44,6 +101,34 @@ const WebsiteLivechatMessage = AbstractMessage.extend({ } return source; }, + /** + * Get the body content of this message + * + * @return {string} + */ + getBody() { + return this._body; + }, + /** + * @return {moment} + */ + getDate() { + return this._date; + }, + /** + * Get the date day of this message + * + * @return {string} + */ + getDateDay() { + const 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 text to display for the author of the message * @@ -51,13 +136,327 @@ const WebsiteLivechatMessage = AbstractMessage.extend({ * * author name > default usernane * - * @override * @return {string} */ getDisplayedAuthor() { - return this._super(...arguments) || this._defaultUsername; + return (this.hasAuthor() ? this._getAuthorName() : null) || this._defaultUsername; + }, + /** + * Get the server ID (number) of this message + * + * @return {integer} + */ + getID() { + return this._id; + }, + /** + * Get the list of images attached to this message. + * Note that attachments are stored with server-format + * + * @return {Object[]} + */ + getImageAttachments() { + 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() { + return _.difference(this.getAttachments(), this.getImageAttachments()); + }, + /** + * Gets the class to use as the notification icon. + * + * @returns {string} + */ + getNotificationIcon() { + if (!this.hasNotificationsError()) { + return 'fa fa-envelope-o'; + } + return 'fa fa-envelope'; + }, + /** + * Gets the list of notifications of this message, in no specific order. + * By default messages do not have notifications. + * + * @returns {Object[]} + */ + getNotifications() { + return []; + }, + /** + * Gets the text to display next to the notification icon. + * + * @returns {string} + */ + getNotificationText() { + return ''; + }, + /** + * Get the time elapsed between sent message and now + * + * @return {string} + */ + getTimeElapsed() { + return mailUtils.timeFromNow(this.getDate()); + }, + /** + * Get the type of message (e.g. 'comment', 'email', 'notification', ...) + * By default, messages are of type 'undefined' + * + * @return {string|undefined} + */ + getType() { + return this._type; + }, + /** + * State whether this message contains some attachments. + * + * @return {boolean} + */ + hasAttachments() { + return this.getAttachments().length > 0; + }, + /** + * State whether this message has an author + * + * @return {boolean} + */ + hasAuthor() { + return !!(this._serverAuthorID && this._serverAuthorID[0]); + }, + /** + * State whether this message has an email of its sender. + * By default, messages do not have any email of its sender. + * + * @return {string} + */ + hasEmailFrom() { + return false; + }, + /** + * State whether this image contains images attachments + * + * @return {boolean} + */ + hasImageAttachments() { + 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() { + return _.some(this.getAttachments(), function (attachment) { + return !(attachment.mimetype && attachment.mimetype.split('/')[0] === 'image'); + }); + }, + /** + * States whether this message has some notifications. + * + * @returns {boolean} + */ + hasNotifications() { + return this.getNotifications().length > 0; + }, + /** + * States whether this message has notifications that are in error. + * + * @returns {boolean} + */ + hasNotificationsError() { + return this.getNotifications().some(notif => + notif.notification_status === 'exception' || + notif.notification_status === 'bounce' + ); + }, + /** + * State whether this message originates from a channel. + * By default, messages do not originate from a channel. + * + * @return {boolean} + */ + originatesFromChannel() { + return false; + }, + /** + * State whether this message has a subject + * By default, messages do not have any subject. + * + * @return {boolean} + */ + hasSubject() { + return false; + }, + /** + * State whether this message is empty + * + * @return {boolean} + */ + isEmpty() { + return !this.hasTrackingValues() && + !this.hasAttachments() && + !this.getBody(); + }, + /** + * By default, messages do not have any subtype description + * + * @return {boolean} + */ + hasSubtypeDescription() { + return false; + }, + /** + * State whether this message contains some tracking values + * By default, messages do not have any tracking values. + * + * @return {boolean} + */ + hasTrackingValues() { + return false; + }, + /** + * State whether this message is a discussion + * + * @return {boolean} + */ + isDiscussion() { + return this._isDiscussion; + }, + /** + * State whether this message is linked to a document thread + * By default, messages are not linked to a document thread. + * + * @return {boolean} + */ + isLinkedToDocumentThread() { + return false; + }, + /** + * State whether this message is needaction + * By default, messages are not needaction. + * + * @return {boolean} + */ + isNeedaction() { + return false; + }, + /** + * State whether this message is a note (i.e. a message from "Log note") + * + * @return {boolean} + */ + isNote() { + 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() { + return this._isNotification; + }, + /** + * State whether this message is starred + * By default, messages are not starred. + * + * @return {boolean} + */ + isStarred() { + return false; + }, + /** + * State whether this message is a system notification + * By default, messages are not system notifications + * + * @return {boolean} + */ + isSystemNotification() { + return false; + }, + /** + * States whether the current message needs moderation in general. + * By default, messages do not require any moderation. + * + * @returns {boolean} + */ + needsModeration() { + return false; + }, + /** + * @params {integer[]} attachmentIDs + */ + removeAttachments(attachmentIDs) { + this._attachmentIDs = _.reject(this._attachmentIDs, function (attachment) { + return _.contains(attachmentIDs, attachment.id); + }); + }, + /** + * 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() { + return !this._isMyselfAuthor(); + }, + + //-------------------------------------------------------------------------- + // 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() { + if (!this.hasAuthor()) { + return ""; + } + return this._serverAuthorID[1]; + }, + /** + * State whether the current user is the author of this message + * + * @private + * @return {boolean} + */ + _isMyselfAuthor() { + return this.hasAuthor() && (this.getAuthorID() === session.partner_id); + }, + /** + * Compute url of attachments of this message + * + * @private + */ + _processAttachmentURL() { + for (let attachment of this.getAttachments()) { + attachment.url = '/web/content/' + attachment.id + '?download=true'; + } }, }); -export default WebsiteLivechatMessage; +export default PublicLivechatMessage; diff --git a/addons/im_livechat/static/src/legacy/models/website_livechat_window.js b/addons/im_livechat/static/src/legacy/models/website_livechat_window.js index f1c1fb06fa5..38766d75a17 100644 --- a/addons/im_livechat/static/src/legacy/models/website_livechat_window.js +++ b/addons/im_livechat/static/src/legacy/models/website_livechat_window.js @@ -1,26 +1,60 @@ /** @odoo-module **/ -import AbstractThreadWindow from '@im_livechat/legacy/widgets/abstract_thread_window/abstract_thread_window'; +import ThreadWidget from '@im_livechat/legacy/widgets/thread/thread'; + +import config from 'web.config'; +import { _t, qweb } from 'web.core'; +import Widget from 'web.Widget'; /** * This is the widget that represent windows of livechat in the frontend. * * @see @im_livechat/legacy/widgets/abstract_thread_window for more information */ -const LivechatWindow = AbstractThreadWindow.extend({ - events: Object.assign(AbstractThreadWindow.prototype.events, { +const PublicLivechatWindow = Widget.extend({ + FOLD_ANIMATION_DURATION: 200, // duration in ms for (un)fold transition + HEIGHT_OPEN: '400px', // height in px of thread window when open + HEIGHT_FOLDED: '34px', // height, in px, of thread window when folded + template: 'im_livechat.legacy.PublicLivechatWindow', + events: { + 'click .o_thread_window_close': '_onClickClose', + 'click .o_thread_window_title': '_onClickFold', + 'click .o_composer_text_field': '_onComposerClick', + 'click .o_mail_thread': '_onThreadWindowClicked', + 'keydown .o_composer_text_field': '_onKeydown', + 'keypress .o_composer_text_field': '_onKeypress', 'input .o_composer_text_field': '_onInput', - }), + }, + custom_events: { + document_viewer_closed: '_onDocumentViewerClose', + }, /** - * @override - * @param {@im_livechat/legacy/widgets/livechat_button} parent - * @param {@im_livechat/legacy/models/website_livechat} thread + * @param {Widget} parent + * @param {@im_livechat/legacy/models/public_livechat} thread * @param {Object} [options={}] * @param {string} [options.headerBackgroundColor] * @param {string} [options.titleColor] */ - init(parent, thread, options = {}) { - this._super(...arguments); + init(parent, thread, options) { + this._super(parent); + + this.options = _.defaults(options || {}, { + autofocus: true, + displayStars: true, + displayReplyIcons: false, + displayNotificationIcons: false, + placeholder: _t("Say something"), + }); + + this._hidden = false; + this._thread = thread || null; + + this._debouncedOnScroll = _.debounce(this._onScroll.bind(this), 100); + + if (!this.hasThread()) { + // internal fold state of thread window without any thread + this._folded = false; + } this._thread = thread; }, /** @@ -28,7 +62,32 @@ const LivechatWindow = AbstractThreadWindow.extend({ * @return {Promise} */ async start() { - await this._super(...arguments); + this.$input = this.$('.o_composer_text_field'); + this.$header = this.$('.o_thread_window_header'); + const options = { + displayMarkAsRead: false, + displayStars: this.options.displayStars, + }; + if (this._thread && this._thread._type === 'document_thread') { + options.displayDocumentLinks = false; + } + this._threadWidget = new ThreadWidget(this, options); + + // animate the (un)folding of thread windows + this.$el.css({ transition: 'height ' + this.FOLD_ANIMATION_DURATION + 'ms linear' }); + if (this.isFolded()) { + this.$el.css('height', this.HEIGHT_FOLDED); + } else if (this.options.autofocus) { + this._focusInput(); + } + if (!config.device.isMobile) { + const margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right"; + this.$el.css(margin_dir, $.position.scrollbarWidth()); + } + const def = this._threadWidget.replace(this.$('.o_thread_window_content')).then(() => { + this._threadWidget.$el.on('scroll', this, this._debouncedOnScroll); + }); + await Promise.all([this._super(), def]); if (this.options.headerBackgroundColor) { this.$('.o_thread_window_header').css('background-color', this.options.headerBackgroundColor); } @@ -36,6 +95,27 @@ const LivechatWindow = AbstractThreadWindow.extend({ this.$('.o_thread_window_header').css('color', this.options.titleColor); } }, + /** + * @override + */ + do_hide() { + this._hidden = true; + this._super(...arguments); + }, + /** + * @override + */ + do_show() { + this._hidden = false; + this._super(...arguments); + }, + /** + * @override + */ + do_toggle(display) { + this._hidden = _.isBoolean(display) ? !display : !this._hidden; + this._super(...arguments); + }, //-------------------------------------------------------------------------- @@ -48,6 +128,142 @@ const LivechatWindow = AbstractThreadWindow.extend({ close() { this.trigger_up('close_chat_window'); }, + /** + * 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. + * + * @returns {boolean} + */ + hasThread() { + return !! this._thread; + }, + /** + * Get the ID of the thread window, which is equivalent to the ID of the + * thread related to this window + * + * @returns {integer|string} + */ + getID() { + return this._getThreadID(); + }, + /** + * @returns {mail.model.Thread|undefined} + */ + getThread() { + if (!this.hasThread()) { + return undefined; + } + return this._thread; + }, + /** + * Get the status of the thread, such as the im status of a DM chat + * ('online', 'offline', etc.). If this window has no thread, returns + * `undefined`. + * + * @returns {string|undefined} + */ + getThreadStatus() { + if (!this.hasThread()) { + return undefined; + } + return this._thread.getStatus(); + }, + /** + * Get the title of the thread window, which usually contains the name of + * the thread. + * + * @returns {string} + */ + getTitle() { + if (!this.hasThread()) { + return _t("Undefined"); + } + return this._thread.getTitle(); + }, + /** + * Get the unread counter of the related thread. If there are no thread + * linked to this window, returns 0. + * + * @returns {integer} + */ + getUnreadCounter() { + if (!this.hasThread()) { + return 0; + } + return this._thread.getUnreadCounter(); + }, + /** + * Tells whether the bottom of the thread in the thread window is visible + * or not. + * + * @returns {boolean} + */ + isAtBottom() { + return this._threadWidget.isAtBottom(); + }, + /** + * 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. + * + * @returns {boolean} + */ + isFolded() { + if (!this.hasThread()) { + return this._folded; + } + return this._thread.isFolded(); + }, + /** + * States whether the thread window is hidden or not. + * + * @returns {boolean} + */ + isHidden() { + return this._hidden; + }, + /** + * 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() { + return config.device.isMobile; + }, + /** + * States whether the input of the thread window should be displayed or not. + * By default, any thread window with a thread needs a composer. + * + * @returns {boolean} + */ + needsComposer() { + return this.hasThread(); + }, + /** + * Render the thread window + */ + render() { + this.renderHeader(); + if (this.hasThread()) { + this._threadWidget.render(this._thread, { displayLoadMore: false }); + } + }, + /** + * Render 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. + * + * @private + */ + renderHeader() { + const options = this._getHeaderRenderingOptions(); + this.$header.html( + qweb.render('im_livechat.legacy.PublicLivechatWindow.HeaderContent', options)); + }, /** * Replace the thread content with provided new content * @@ -57,35 +273,194 @@ const LivechatWindow = AbstractThreadWindow.extend({ $element.replace(this._threadWidget.$el); }, /** + * Scroll to the bottom of the thread in the thread window + */ + scrollToBottom() { + this._threadWidget.scrollToBottom(); + }, + /** + * Toggle the fold state of this thread window. Also update the fold state + * of the thread model. If the boolean parameter `folded` is provided, it + * folds/unfolds the window when it is set/unset. + * * Warn the parent widget (LivechatButton) * - * @override - * @param {boolean} folded + * @param {boolean} [folded] if not a boolean, toggle the fold state. + * Otherwise, fold/unfold the window if set/unset. */ - toggleFold() { - this._super(...arguments); + toggleFold(folded) { + if (!_.isBoolean(folded)) { + folded = !this.isFolded(); + } + this._updateThreadFoldState(folded); this.trigger_up('save_chat_window'); this.updateVisualFoldState(); }, + /** + * 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() { + if (!this.isFolded()) { + this._threadWidget.scrollToBottom(); + if (this.options.autofocus) { + this._focusInput(); + } + } + const height = this.isFolded() ? this.HEIGHT_FOLDED : this.HEIGHT_OPEN; + this.$el.css({ height }); + }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** - * @override + * 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() { + 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() { + return { + status: this.getThreadStatus(), + thread: this.getThread(), + title: this.getTitle(), + unreadCounter: this.getUnreadCounter(), + widget: this, + }; + }, + /** + * Get 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. + * + * @private + * @returns {integer|string} the threadID, or '_blank' for the window that + * is not related to any thread. + */ + _getThreadID() { + if (!this.hasThread()) { + return '_blank'; + } + return this._thread.getID(); + }, + /** + * Tells whether there is focus on this thread. Note that a thread that has + * the focus means the input has focus. + * + * @private + * @returns {boolean} + */ + _hasFocus() { + return this.$input.is(':focus'); + }, + /** + * Post a message on this thread window, and auto-scroll to the bottom of + * the thread. + * * @private * @param {Object} messageData */ _postMessage(messageData) { this.trigger_up('post_message_chat_window', { messageData }); - this._super(...arguments); + if (!this.hasThread()) { + return; + } + this._thread.postMessage(messageData) + .then(() => { + this._threadWidget.scrollToBottom(); + }); + }, + /** + * Update the fold state of the thread. + * + * This function is called when toggling the fold state of this window. + * If there is no thread linked to this window, it means this is the + * "blank" thread window, therefore we use the internal state 'folded' + * + * @private + * @param {boolean} folded + */ + _updateThreadFoldState(folded) { + if (this.hasThread()) { + this._thread.fold(folded); + } else { + this._folded = folded; + this.updateVisualFoldState(); + } }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- + /** + * Close the thread window. + * Mark the thread as read if the thread window was open. + * + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + ev.stopPropagation(); + ev.preventDefault(); + if ( + this.hasThread() && + this._thread.getUnreadCounter() > 0 && + !this.isFolded() + ) { + this._thread.markAsRead(); + } + this.close(); + }, + /** + * Fold/unfold the thread window. + * Also mark the thread as read. + * + * @private + */ + _onClickFold() { + 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(ev) { + if ($(ev.target).closest('a, button').length) { + return; + } + this._focusInput(); + }, + /** + * @private + */ + _onDocumentViewerClose() { + this._focusInput(); + }, /** * Called when the input in the composer changes * @@ -97,6 +472,55 @@ const LivechatWindow = AbstractThreadWindow.extend({ this._thread.setMyselfTyping({ typing: isTyping }); } }, + /** + * Called when typing something on the composer of this thread window. + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + ev.stopPropagation(); // to prevent jquery's blockUI to cancel event + // ENTER key (avoid requiring jquery ui for external livechat) + if (ev.which === 13) { + const content = _.str.trim(this.$input.val()); + const messageData = { + content, + attachment_ids: [], + partner_ids: [], + }; + this.$input.val(''); + if (content) { + this._postMessage(messageData); + } + } + }, + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeypress(ev) { + ev.stopPropagation(); // to prevent jquery's blockUI to cancel event + }, + /** + * @private + */ + _onScroll() { + if (this.hasThread() && this.isAtBottom()) { + this._thread.markAsRead(); + } + }, + /** + * 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() { + const selectObj = window.getSelection(); + if (selectObj.anchorOffset === selectObj.focusOffset) { + this.$input.focus(); + } + }, }); -export default LivechatWindow; +export default PublicLivechatWindow; diff --git a/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js index 914488e002b..a24785e8454 100644 --- a/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js +++ b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js @@ -85,7 +85,6 @@ const QWeb = core.qweb; utils.set_cookie('im_livechat_auto_popup', '', -1); this.messaging.livechatButtonView.update({ history: clear() }); this.messaging.livechatButtonView.update({ rule: this.messaging.livechatButtonView.livechatInit.rule }); - this.messaging.livechatButtonView.update({ isChatbotBatchWelcomeMessages: true }); } else if (this.messaging.livechatButtonView.chatbotState === 'restore_session') { // we landed on a website page and a chatbot script is currently running // -> restore the user's session (see '_chatbotRestoreSession') @@ -166,7 +165,7 @@ const QWeb = core.qweb; const postedWelcomeMessages = await session.rpc('/chatbot/post_welcome_steps', { channel_uuid: this.messaging.livechatButtonView.livechat.getUUID(), - chatbot_script_id: this.messaging.livechatButtonView.chatbot.data.chatbot_script_id, + chatbot_script_id: this.messaging.livechatButtonView.chatbot.scriptId, }); const welcomeMessagesIds = welcomeMessages.map(welcomeMessage => welcomeMessage._id); @@ -367,7 +366,6 @@ const QWeb = core.qweb; let chatbotState = localStorage.getItem(this.messaging.livechatButtonView.sessionCookieKey); if (chatbotState) { - this.messaging.livechatButtonView.update({ localStorageChatbotState: JSON.parse(chatbotState) }); this.messaging.livechatButtonView.chatbot.update({ currentStep: insertAndReplace({ data: this.messaging.livechatButtonView.localStorageChatbotState._chatbotCurrentStep }) }); } }, @@ -516,7 +514,7 @@ const QWeb = core.qweb; const nextStep = await session.rpc('/chatbot/step/trigger', { channel_uuid:this.messaging.livechatButtonView.livechat.getUUID(), - chatbot_script_id: this.messaging.livechatButtonView.chatbot.data.chatbot_script_id, + chatbot_script_id: this.messaging.livechatButtonView.chatbot.scriptId, }); if (nextStep) { @@ -625,7 +623,7 @@ const QWeb = core.qweb; const parameters = this._super(...arguments); if (this.messaging.livechatButtonView.isChatbot) { - parameters.chatbot_script_id = this.messaging.livechatButtonView.chatbot.data.chatbot_script_id; + parameters.chatbot_script_id = this.messaging.livechatButtonView.chatbot.scriptId; } return parameters; @@ -720,7 +718,7 @@ const QWeb = core.qweb; if (this.messaging.livechatButtonView.isChatbot) { this._sendWelcomeChatbotMessage( 0, - this.messaging.livechatButtonView.isChatbotBatchWelcomeMessages ? 0 : this.messaging.livechatButtonView.chatbot.messageDelay, + this.messaging.livechatButtonView.chatbotState === 'welcome' ? 0 : this.messaging.livechatButtonView.chatbot.messageDelay, ); } else { this._super(...arguments); @@ -815,7 +813,7 @@ const QWeb = core.qweb; const postedMessage = await session.rpc('/chatbot/restart', { channel_uuid: this.messaging.livechatButtonView.livechat.getUUID(), - chatbot_script_id: this.messaging.livechatButtonView.chatbot.data.chatbot_script_id, + chatbot_script_id: this.messaging.livechatButtonView.chatbot.scriptId, }); if (postedMessage) { diff --git a/addons/im_livechat/static/src/legacy/public_livechat_chatbot.xml b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.xml index 150a2397b64..6dcc46c5e0e 100644 --- a/addons/im_livechat/static/src/legacy/public_livechat_chatbot.xml +++ b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.xml @@ -8,7 +8,7 @@ --> - +