[IMP] im_livechat, *: Refactor JS Public Livechat (step 6)
*: website_livechat This commit is a step towards refactoring the JS of public livechat, so that it reuses the same architecture as the code of Discuss. This implies code that uses JS models and OWL components. Task-2896237 Part-of: odoo/odoo#94722
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
|
||||
<!-- Extend the base livechat window to include a div allowing to restart the script at the end -->
|
||||
<t t-extend="im_livechat.legacy.mail.AbstractThreadWindow">
|
||||
<t t-extend="im_livechat.legacy.PublicLivechatWindow">
|
||||
<t t-jquery='div.o_chat_mini_composer' t-operation="after">
|
||||
<div class="o_livechat_chatbot_end bg-200 font-italic text-center border" style="display: none;">
|
||||
<span>Conversation ended...</span>
|
||||
@@ -18,7 +18,7 @@
|
||||
</t>
|
||||
|
||||
<!-- Extend the base livechat window header to include a button allowing to restart the script -->
|
||||
<t t-extend="im_livechat.legacy.mail.AbstractThreadWindow.HeaderContent">
|
||||
<t t-extend="im_livechat.legacy.PublicLivechatWindow.HeaderContent">
|
||||
<t t-jquery="span.o_thread_window_title" t-operation="after">
|
||||
<a t-if="widget.isMobile() && widget.getParent() && widget.getParent()._chatbotDisplayRestartButton()"
|
||||
href="#" class="o_livechat_chatbot_main_restart fa fa-1x fa-refresh"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import WebsiteLivechatMessage from '@im_livechat/legacy/models/website_livechat_message';
|
||||
import PublicLivechatMessage from '@im_livechat/legacy/models/website_livechat_message';
|
||||
|
||||
/**
|
||||
* Override of the WebsiteLivechatMessage that includes chatbot capabilities.
|
||||
* Override of the PublicLivechatMessage that includes chatbot capabilities.
|
||||
* The main changes are:
|
||||
* - Allow to display options for the end-user to click on ("_chatbotStepAnswers")
|
||||
* - Show a different name/icon for the chatbot (instead of the 'OdooBot' operator name/icon)
|
||||
*/
|
||||
WebsiteLivechatMessage.include({
|
||||
PublicLivechatMessage.include({
|
||||
/**
|
||||
* @param {@im_livechat/legacy/widgets/livechat_button} parent
|
||||
* @param {Object} data
|
||||
@@ -62,4 +62,4 @@ WebsiteLivechatMessage.include({
|
||||
}
|
||||
});
|
||||
|
||||
export default WebsiteLivechatMessage;
|
||||
export default PublicLivechatMessage;
|
||||
|
||||
-498
@@ -1,498 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import ThreadWidget from '@im_livechat/legacy/widgets/thread/thread';
|
||||
|
||||
import config from 'web.config';
|
||||
import core from 'web.core';
|
||||
import Widget from 'web.Widget';
|
||||
|
||||
const QWeb = core.qweb;
|
||||
const _t = core._t;
|
||||
|
||||
/**
|
||||
* This is an abstract widget for rendering thread windows.
|
||||
* AbstractThreadWindow is kept for legacy reasons.
|
||||
*/
|
||||
const AbstractThreadWindow = Widget.extend({
|
||||
template: 'im_livechat.legacy.mail.AbstractThreadWindow',
|
||||
custom_events: {
|
||||
document_viewer_closed: '_onDocumentViewerClose',
|
||||
},
|
||||
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',
|
||||
},
|
||||
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
|
||||
/**
|
||||
* Children of this class must make use of `thread`, which is an object that
|
||||
* represent the thread that is linked to this thread window.
|
||||
*
|
||||
* If no thread is provided, this will represent the "blank" thread window.
|
||||
*
|
||||
* @abstract
|
||||
* @param {Widget} parent
|
||||
* @param {@im_livechat/legacy/models/abstract_thread} [thread=null] the thread that this
|
||||
* thread window is linked to. If not set, it is the "blank" thread
|
||||
* window.
|
||||
* @param {Object} [options={}]
|
||||
* @param {@im_livechat/legacy/models/abstract_thread} [options.thread]
|
||||
*/
|
||||
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;
|
||||
}
|
||||
},
|
||||
start() {
|
||||
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);
|
||||
});
|
||||
return Promise.all([this._super(), def]);
|
||||
},
|
||||
/**
|
||||
* @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);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Close this window
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
close() {},
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
/**
|
||||
* 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 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 thread window is hidden or not.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isHidden() {
|
||||
return this._hidden;
|
||||
},
|
||||
/**
|
||||
* 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.mail.AbstractThreadWindow.HeaderContent', options));
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {boolean} [folded] if not a boolean, toggle the fold state.
|
||||
* Otherwise, fold/unfold the window if set/unset.
|
||||
*/
|
||||
toggleFold(folded) {
|
||||
if (!_.isBoolean(folded)) {
|
||||
folded = !this.isFolded();
|
||||
}
|
||||
this._updateThreadFoldState(folded);
|
||||
},
|
||||
/**
|
||||
* Update the visual state of the window so that it matched the internal
|
||||
* fold state. This is useful in case the related thread has its fold state
|
||||
* that has been changed.
|
||||
*/
|
||||
updateVisualFoldState() {
|
||||
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
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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 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 AbstractThreadWindow;
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<!--
|
||||
@param {im_livechat.legacy.mail.AbstractThreadWindow} widget
|
||||
@param {im_livechat.legacy.PublicLivechatWindow} widget
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.AbstractThreadWindow">
|
||||
<t t-name="im_livechat.legacy.PublicLivechatWindow">
|
||||
<div class="o_thread_window o_in_home_menu" t-att-data-thread-id="widget.getID()">
|
||||
<div class="o_thread_window_header">
|
||||
<t t-call="im_livechat.legacy.mail.AbstractThreadWindow.HeaderContent">
|
||||
<t t-call="im_livechat.legacy.PublicLivechatWindow.HeaderContent">
|
||||
<t t-set="status" t-value="widget.getThreadStatus()"/>
|
||||
<t t-set="title" t-value="widget.getTitle()"/>
|
||||
<t t-set="unreadCounter" t-value="widget.getUnreadCounter()"/>
|
||||
@@ -32,7 +32,7 @@
|
||||
@param {function} widget.isMobile function without any param that
|
||||
states whether it should render for desktop or mobile screen.
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.AbstractThreadWindow.HeaderContent">
|
||||
<t t-name="im_livechat.legacy.PublicLivechatWindow.HeaderContent">
|
||||
<span t-if="widget.isMobile()">
|
||||
<a href="#" class="o_thread_window_close fa fa-1x fa-arrow-left" aria-label="Close chat window" title="Close chat window"/>
|
||||
</span>
|
||||
|
||||
@@ -28,7 +28,7 @@ const Feedback = Widget.extend({
|
||||
|
||||
/**
|
||||
* @param {?} parent
|
||||
* @param {@im_livechat/legacy/models/website_livechat} livechat
|
||||
* @param {@im_livechat/legacy/models/public_livechat} livechat
|
||||
*/
|
||||
init(parent, livechat) {
|
||||
this._super(parent);
|
||||
|
||||
@@ -9,9 +9,9 @@ import Widget from 'web.Widget';
|
||||
|
||||
import { LIVECHAT_COOKIE_HISTORY } from 'im_livechat.legacy.im_livechat.Constants';
|
||||
import Feedback from '@im_livechat/legacy/widgets/feedback/feedback';
|
||||
import WebsiteLivechat from '@im_livechat/legacy/models/website_livechat';
|
||||
import WebsiteLivechatMessage from '@im_livechat/legacy/models/website_livechat_message';
|
||||
import WebsiteLivechatWindow from '@im_livechat/legacy/models/website_livechat_window';
|
||||
import PublicLivechat from '@im_livechat/legacy/models/website_livechat';
|
||||
import PublicLivechatMessage from '@im_livechat/legacy/models/website_livechat_message';
|
||||
import PublicLivechatWindow from '@im_livechat/legacy/models/website_livechat_window';
|
||||
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
@@ -101,7 +101,7 @@ const LivechatButton = Widget.extend({
|
||||
options = Object.assign({}, this.options, options, {
|
||||
serverURL: this.messaging.livechatButtonView.serverUrl,
|
||||
});
|
||||
const message = new WebsiteLivechatMessage(this, data, options);
|
||||
const message = new PublicLivechatMessage(this, data, options);
|
||||
|
||||
const hasAlreadyMessage = _.some(this.messaging.livechatButtonView.messages, function (msg) {
|
||||
return message.getID() === msg.getID();
|
||||
@@ -259,7 +259,7 @@ const LivechatButton = Widget.extend({
|
||||
}
|
||||
} else {
|
||||
this.messaging.livechatButtonView.update({
|
||||
livechat: new WebsiteLivechat({
|
||||
livechat: new PublicLivechat({
|
||||
parent: this,
|
||||
data: livechatData
|
||||
}),
|
||||
@@ -319,7 +319,7 @@ const LivechatButton = Widget.extend({
|
||||
titleColor: this.messaging.livechatButtonView.titleColor,
|
||||
};
|
||||
this.messaging.livechatButtonView.update({
|
||||
chatWindow: new WebsiteLivechatWindow(this, this.messaging.livechatButtonView.livechat, options),
|
||||
chatWindow: new PublicLivechatWindow(this, this.messaging.livechatButtonView.livechat, options),
|
||||
});
|
||||
return this.messaging.livechatButtonView.chatWindow.appendTo($('body')).then(() => {
|
||||
const cssProps = { bottom: 0 };
|
||||
|
||||
@@ -100,7 +100,7 @@ const ThreadWidget = Widget.extend({
|
||||
this._super();
|
||||
},
|
||||
/**
|
||||
* @param {@im_livechat/legacy/models/abstract_thread} thread the thread to render.
|
||||
* @param {@im_livechat/legacy/models/public_livechat} thread the thread to render.
|
||||
* @param {Object} [options]
|
||||
* @param {integer} [options.displayOrder=ORDER.ASC] order of displaying
|
||||
* messages in the thread:
|
||||
@@ -434,7 +434,7 @@ const ThreadWidget = Widget.extend({
|
||||
* There is at most one such popover at any given time.
|
||||
*
|
||||
* @private
|
||||
* @param {@im_livechat/legacy/models/abstract_message[]} messages list of messages in the
|
||||
* @param {@im_livechat/legacy/models/public_livechat_message[]} messages list of messages in the
|
||||
* rendered thread, for which popover on mouseover interaction is
|
||||
* permitted.
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,13 @@ registerModel({
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {integer}
|
||||
*/
|
||||
_computeScriptId() {
|
||||
return this.data.chatbot_script_id;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Array|FieldCommand}
|
||||
@@ -64,6 +71,9 @@ registerModel({
|
||||
compute: '_computeMessageDelay',
|
||||
default: 3500, // in milliseconds
|
||||
}),
|
||||
scriptId: attr({
|
||||
compute: '_computeScriptId',
|
||||
}),
|
||||
welcomeSteps: attr({
|
||||
compute: '_computeWelcomeSteps',
|
||||
}),
|
||||
|
||||
@@ -153,6 +153,20 @@ registerModel({
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {FieldCommand}
|
||||
*/
|
||||
_computeLocalStorageChatbotState() {
|
||||
if (!this.sessionCookie) {
|
||||
return clear();
|
||||
}
|
||||
const data = localStorage.getItem(this.sessionCookieKey);
|
||||
if (!data) {
|
||||
return clear();
|
||||
}
|
||||
return JSON.parse(data);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
@@ -229,9 +243,6 @@ registerModel({
|
||||
compute: '_computeIsChatbot',
|
||||
default: false,
|
||||
}),
|
||||
isChatbotBatchWelcomeMessages: attr({
|
||||
default: false,
|
||||
}),
|
||||
isChatbotRedirecting: attr({
|
||||
default: false,
|
||||
}),
|
||||
@@ -250,7 +261,9 @@ registerModel({
|
||||
default: null,
|
||||
}),
|
||||
livechatInit: attr(),
|
||||
localStorageChatbotState: attr(),
|
||||
localStorageChatbotState: attr({
|
||||
compute: '_computeLocalStorageChatbotState',
|
||||
}),
|
||||
messages: attr({
|
||||
default: [],
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import publicWidget from 'web.public.widget';
|
||||
import utils from 'web.utils';
|
||||
|
||||
import LivechatButton from '@im_livechat/legacy/widgets/livechat_button';
|
||||
import WebsiteLivechat from '@im_livechat/legacy/models/website_livechat';
|
||||
import PublicLivechat from '@im_livechat/legacy/models/website_livechat';
|
||||
|
||||
import { insertAndReplace } from '@mail/model/model_field_command';
|
||||
|
||||
@@ -60,7 +60,7 @@ const LivechatButtonTestChatbot = LivechatButton.extend({
|
||||
*/
|
||||
_openChat: function () {
|
||||
this.messaging.livechatButtonView.update({
|
||||
livechat: new WebsiteLivechat({
|
||||
livechat: new PublicLivechat({
|
||||
parent: this,
|
||||
data: this._channelData,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user