[IMP] im_livechat, *: Refactor JS Public Livechat (step 12)

*: 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-2942001

closes odoo/odoo#97354

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Alexandre Kühn
2022-08-03 06:34:21 +02:00
parent b2f4511454
commit 3bceb9159b
14 changed files with 425 additions and 333 deletions
@@ -107,17 +107,6 @@ const PublicLivechat = Class.extend(Mixins.EventDispatcherMixin, {
this.on('message_added', this, this._onTypingMessageAdded);
this.on('message_posted', this, this._onTypingMessagePosted);
// Necessary for thread typing mixin to display is typing notification
// bar text (at least, for the operator in the members).
this._members = (
this.messaging.publicLivechatGlobal.publicLivechat.operator
? [{
id: this.messaging.publicLivechatGlobal.publicLivechat.operator.id,
name: this.messaging.publicLivechatGlobal.publicLivechat.operator.name,
}]
: []
);
if (params.data.message_unread_counter !== undefined) {
this.messaging.publicLivechatGlobal.publicLivechat.update({
unreadCounter: params.data.message_unread_counter
@@ -149,7 +138,7 @@ const PublicLivechat = Class.extend(Mixins.EventDispatcherMixin, {
*/
getMessages() {
// ignore removed messages
return this._messages.filter(message => !message.isEmpty());
return this.messaging.publicLivechatGlobal.livechatButtonView.messages.filter(message => !message.legacyPublicLivechatMessage.isEmpty()).map(message => message.legacyPublicLivechatMessage);
},
/**
* Get the text to display when some partners are typing something on the
@@ -178,9 +167,11 @@ const PublicLivechat = Class.extend(Mixins.EventDispatcherMixin, {
*/
getTypingMembersToText() {
const typingPartnerIDs = this._typingPartnerIDs;
const typingMembers = _.filter(this._members, function (member) {
return _.contains(typingPartnerIDs, member.id);
});
const typingMembers = (
this.messaging.publicLivechatGlobal.publicLivechat.operator && this._typingPartnerIDs.includes(this.messaging.publicLivechatGlobal.publicLivechat.operator.id)
? [this.messaging.publicLivechatGlobal.publicLivechat.operator]
: []
);
const sortedTypingMembers = _.sortBy(typingMembers, function (member) {
return _.indexOf(typingPartnerIDs, member.id);
});
@@ -10,7 +10,6 @@ import LivechatButton from '@im_livechat/legacy/widgets/livechat_button';
import { clear, increment, insertAndReplace, replace } from '@mail/model/model_field_command';
const _t = core._t;
const QWeb = core.qweb;
/**
* Override of the LivechatButton to include chatbot capabilities.
@@ -107,7 +106,7 @@ const QWeb = core.qweb;
_chatbotAddMessage(message, options) {
message.body = utils.Markup(message.body);
this.messaging.publicLivechatGlobal.livechatButtonView.addMessage(message, options);
if (this.messaging.publicLivechatGlobal.publicLivechat.isFolded || !this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow._publicLivechatView.isAtBottom()) {
if (this.messaging.publicLivechatGlobal.publicLivechat.isFolded || !this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.isAtBottom()) {
this.messaging.publicLivechatGlobal.publicLivechat.update({ unreadCounter: increment() });
}
@@ -131,10 +130,10 @@ const QWeb = core.qweb;
*/
_chatbotAwaitUserInput() {
if (this._isLastMessageFromCustomer()) {
if (this._chatbotShouldEndScript()) {
this._chatbotEndScript();
if (this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.shouldEndScript) {
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotEndScript();
} else {
this._chatbotSetIsTyping();
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotSetIsTyping();
this.messaging.publicLivechatGlobal.livechatButtonView.update({
chatbotNextStepTimeout: setTimeout(
this._chatbotTriggerNextStep.bind(this),
@@ -224,54 +223,6 @@ const QWeb = core.qweb;
$composerTextField.on('keydown', this.chatbotInputKeyDownHandler);
}
},
/**
* Once the script ends, adds a visual element at the end of the chat window allowing to restart
* the whole script.
*
* @private
*/
_chatbotEndScript() {
if (
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep &&
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data &&
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.conversation_closed
) {
// don't touch anything if the user has closed the conversation, let the chat window
// handle the display
return;
}
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.$('.o_composer_text_field').addClass('d-none');
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.$('.o_livechat_chatbot_end').show();
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.$('.o_livechat_chatbot_restart').one('click',
this._onChatbotRestartScript.bind(this));
},
/**
* Will display a "Restart script" button in the conversation toolbar.
*
* Side-case: if the conversation has been forwarded to a human operator, we don't want to
* display that restart button.
*
* @private
*/
_chatbotDisplayRestartButton() {
return this.messaging.publicLivechatGlobal.livechatButtonView.isChatbot && (!this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep ||
(this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_step_type !== 'forward_operator' ||
!this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_operator_found));
},
/**
* Works as a hook since other modules can add their own step types.
*
* @private
*/
_chatbotIsExpectingUserInput() {
return [
'question_phone',
'question_email',
'free_input_single',
'free_input_multi',
].includes(this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_step_type);
},
/**
* Processes the step, depending on the current state of the script and the author of the last
* message that was typed into the conversation.
@@ -300,15 +251,15 @@ const QWeb = core.qweb;
* @private
*/
_chatbotProcessStep() {
if (this._chatbotShouldEndScript()) {
this._chatbotEndScript();
if (this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.shouldEndScript) {
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotEndScript();
} else if (this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_step_type === 'forward_operator'
&& this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_operator_found) {
this._chatbotEnableInput();
} else if (this._chatbotIsExpectingUserInput()) {
} else if (this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.isExpectingUserInput) {
if (this._isLastMessageFromCustomer()) {
// user has already typed a message in -> trigger next step
this._chatbotSetIsTyping();
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotSetIsTyping();
this.messaging.publicLivechatGlobal.livechatButtonView.update({
chatbotNextStepTimeout: setTimeout(
this._chatbotTriggerNextStep.bind(this),
@@ -337,7 +288,7 @@ const QWeb = core.qweb;
// -> in that case, don't wait and trigger the next step immediately
nextStepDelay = 0;
} else {
this._chatbotSetIsTyping();
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotSetIsTyping();
}
this.messaging.publicLivechatGlobal.livechatButtonView.update({
@@ -349,7 +300,7 @@ const QWeb = core.qweb;
}
}
if (!this._chatbotDisplayRestartButton()) {
if (!this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.hasRestartButton) {
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.$('.o_livechat_chatbot_main_restart').addClass('d-none');
}
},
@@ -368,82 +319,6 @@ const QWeb = core.qweb;
'_chatbotCurrentStep': this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data,
}));
},
/**
* Adds a small "is typing" animation into the chat window.
*
* @private
*/
_chatbotSetIsTyping(isWelcomeMessage=false) {
if (this.messaging.publicLivechatGlobal.livechatButtonView.isTypingTimeout) {
clearTimeout(this.isTypingTimeout);
}
this._chatbotDisableInput('');
this.messaging.publicLivechatGlobal.livechatButtonView.update({
isTypingTimeout: setTimeout(() => {
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.$('.o_mail_thread_content').append(
$(QWeb.render('im_livechat.legacy.chatbot.is_typing_message', {
'chatbotImageSrc': `/im_livechat/operator/${
this.messaging.publicLivechatGlobal.publicLivechat.operator.id
}/avatar`,
'chatbotName': this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.name,
'isWelcomeMessage': isWelcomeMessage,
}))
);
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow._publicLivechatView.scrollToBottom();
}, this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.messageDelay / 3),
});
},
/**
* Helper method that checks if the script should be ended or not.
* If the user has closed the conversation -> script has ended.
*
* Otherwise, there are 2 use cases where we want to end the script:
*
* If the current step is the last one AND the conversation was not taken over by a human operator
* 1. AND we expect a user input (or we are on a selection)
* AND the user has already answered
* 2. AND we don't expect a user input
*
* @returns {Boolean}
* @private
*/
_chatbotShouldEndScript() {
if (this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.conversation_closed) {
return true;
}
if (this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_step_is_last &&
(this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_step_type !== 'forward_operator' ||
!this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_operator_found)
) {
if (this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_step_type === 'question_email'
&& !this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.is_email_valid
) {
// email is not (yet) valid, let the user answer / try again
return false;
} else if (
(this._chatbotIsExpectingUserInput() ||
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_step_type === 'question_selection') &&
this.messaging.publicLivechatGlobal.livechatButtonView.messages.length !== 0
) {
const lastMessage = this.messaging.publicLivechatGlobal.livechatButtonView.messages[this.messaging.publicLivechatGlobal.livechatButtonView.messages.length - 1];
if (lastMessage.legacyPublicLivechatMessage.getAuthorID() !== this.messaging.publicLivechatGlobal.publicLivechat.operator.id) {
// we are on the last step of the script, expect a user input and the user has
// already answered
// -> end the script
return true;
}
} else if (!this._chatbotIsExpectingUserInput()) {
// we are on the last step of the script and we do not expect a user input
// -> end the script
return true;
}
}
return false;
},
/**
* A special case is handled for email steps, where we first validate the email (server side)
* and we allow the user to try again in case the format is incorrect.
@@ -512,7 +387,7 @@ const QWeb = core.qweb;
// did not find next step -> end the script
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_step_is_last = true;
this._renderMessages();
this._chatbotEndScript();
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotEndScript();
}
this._chatbotSaveSession();
@@ -537,37 +412,13 @@ const QWeb = core.qweb;
*/
_isLastMessageFromCustomer() {
const lastMessage = this.messaging.publicLivechatGlobal.livechatButtonView.messages.length !== 0 ? this.messaging.publicLivechatGlobal.livechatButtonView.messages[this.messaging.publicLivechatGlobal.livechatButtonView.messages.length - 1] : null;
return lastMessage && lastMessage.legacyPublicLivechatMessage.getAuthorID() !== this.messaging.publicLivechatGlobal.publicLivechat.operator.id;
return lastMessage && lastMessage.authorId !== this.messaging.publicLivechatGlobal.publicLivechat.operator.id;
},
//--------------------------------------------------------------------------
// Private - LiveChat Overrides
//--------------------------------------------------------------------------
/**
* When we enter the "ask feedback" process of the chat, we hide some elements that become
* unnecessary and irrelevant (restart / end messages, any text field values, ...).
*
* @override
* @private
*/
_askFeedback() {
this._super(...arguments);
if (
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot &&
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep &&
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data
) {
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.conversation_closed = true;
this._chatbotSaveSession();
}
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.$('.o_livechat_chatbot_main_restart').addClass('d-none');
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.$('.o_livechat_chatbot_end').hide();
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.$('.o_composer_text_field')
.removeClass('d-none')
.val('');
},
/**
* Resuming the chatbot script if we are currently running one.
*
@@ -583,7 +434,7 @@ const QWeb = core.qweb;
return this._super(...arguments).then(() => {
window.addEventListener('resize', () => {
if (this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow) {
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow._publicLivechatView.scrollToBottom();
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.scrollToBottom();
}
});
@@ -681,8 +532,8 @@ const QWeb = core.qweb;
return; // operator has taken over the conversation, let them speak
} else if (this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.currentStep.data.chatbot_step_type === 'free_input_multi') {
this._debouncedChatbotAwaitUserInput();
} else if (!this._chatbotShouldEndScript()) {
this._chatbotSetIsTyping();
} else if (!this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.shouldEndScript) {
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotSetIsTyping();
this.messaging.publicLivechatGlobal.livechatButtonView.update({
chatbotNextStepTimeout: setTimeout(
this._chatbotTriggerNextStep.bind(this),
@@ -690,7 +541,7 @@ const QWeb = core.qweb;
),
});
} else {
this._chatbotEndScript();
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotEndScript();
}
this._chatbotSaveSession();
@@ -761,7 +612,7 @@ const QWeb = core.qweb;
if (stepIndex + 1 < this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.welcomeSteps.length) {
if (welcomeMessageDelay !== 0) {
this._chatbotSetIsTyping(true);
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotSetIsTyping(true);
}
this.messaging.publicLivechatGlobal.livechatButtonView.update({
@@ -817,7 +668,7 @@ const QWeb = core.qweb;
}
this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.update({ currentStep: clear() });
this._chatbotSetIsTyping();
this.messaging.publicLivechatGlobal.livechatButtonView.chatbotSetIsTyping();
this.messaging.publicLivechatGlobal.livechatButtonView.update({
chatbotNextStepTimeout: setTimeout(
this._chatbotTriggerNextStep.bind(this),
@@ -20,12 +20,12 @@
<!-- Extend the base livechat window header to include a button allowing to restart the script -->
<t t-extend="im_livechat.legacy.PublicLivechatWindow.HeaderContent">
<t t-jquery="span.o_thread_window_title" t-operation="after">
<a t-if="widget.isMobile() &amp;&amp; widget.getParent() &amp;&amp; widget.getParent()._chatbotDisplayRestartButton()"
<a t-if="widget.isMobile() &amp;&amp; widget.messaging.publicLivechatGlobal.livechatButtonView.chatbot &amp;&amp; widget.messaging.publicLivechatGlobal.livechatButtonView.chatbot.hasRestartButton"
href="#" class="o_livechat_chatbot_main_restart fa fa-1x fa-refresh"
title="Restart Conversation"/>
</t>
<t t-jquery="span.o_thread_window_buttons a.o_thread_window_close" t-operation="before">
<a t-if="widget.getParent() &amp;&amp; widget.getParent()._chatbotDisplayRestartButton()"
<a t-if="widget.messaging.publicLivechatGlobal.livechatButtonView.chatbot &amp;&amp; widget.messaging.publicLivechatGlobal.livechatButtonView.chatbot.hasRestartButton"
href="#" class="o_livechat_chatbot_main_restart fa fa-refresh"
title="Restart Conversation"/>
</t>
@@ -7,7 +7,7 @@ import time from 'web.time';
import utils from 'web.utils';
import Widget from 'web.Widget';
import { clear, insertAndReplace } from '@mail/model/model_field_command';
import { insertAndReplace } from '@mail/model/model_field_command';
const _t = core._t;
@@ -21,7 +21,7 @@ const LivechatButton = Widget.extend({
'updated_unread_counter': '_onUpdatedUnreadCounter',
},
events: {
'click': '_openChat'
'click': '_onClick'
},
init(parent, messaging) {
this._super(parent);
@@ -53,12 +53,15 @@ const LivechatButton = Widget.extend({
for (const m of this.messaging.publicLivechatGlobal.livechatButtonView.history) {
this.messaging.publicLivechatGlobal.livechatButtonView.addMessage(m);
}
this._openChat();
this.messaging.publicLivechatGlobal.livechatButtonView.openChat();
} else if (!config.device.isMobile && this.messaging.publicLivechatGlobal.livechatButtonView.rule.action === 'auto_popup') {
const autoPopupCookie = utils.get_cookie('im_livechat_auto_popup');
if (!autoPopupCookie || JSON.parse(autoPopupCookie)) {
this.messaging.publicLivechatGlobal.livechatButtonView.update({
autoOpenChatTimeout: setTimeout(this._openChat.bind(this), this.messaging.publicLivechatGlobal.livechatButtonView.rule.auto_popup_timer * 1000),
autoOpenChatTimeout: setTimeout(
this.messaging.publicLivechatGlobal.livechatButtonView.openChat,
this.messaging.publicLivechatGlobal.livechatButtonView.rule.auto_popup_timer * 1000,
),
});
}
}
@@ -82,85 +85,6 @@ const LivechatButton = Widget.extend({
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_askFeedback() {
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.$('.o_thread_composer input').prop('disabled', true);
this.messaging.publicLivechatGlobal.update({ feedbackView: insertAndReplace() });
},
/**
* @private
*/
_closeChat() {
this.messaging.publicLivechatGlobal.livechatButtonView.update({ chatWindow: clear() });
utils.set_cookie('im_livechat_session', "", -1); // remove cookie
},
/**
* @private
*/
_openChat: _.debounce(function () {
if (this.messaging.publicLivechatGlobal.livechatButtonView.isOpeningChat) {
return;
}
const cookie = utils.get_cookie('im_livechat_session');
let def;
this.messaging.publicLivechatGlobal.livechatButtonView.update({ isOpeningChat: true });
clearTimeout(this.messaging.publicLivechatGlobal.livechatButtonView.autoOpenChatTimeout);
if (cookie) {
def = Promise.resolve(JSON.parse(cookie));
} else {
// re-initialize messages cache
this.messaging.publicLivechatGlobal.livechatButtonView.update({ messages: clear() });
def = session.rpc(
'/im_livechat/get_session',
this._prepareGetSessionParameters(),
{ shadow: true },
);
}
def.then((livechatData) => {
if (!livechatData || !livechatData.operator_pid) {
try {
this.displayNotification({
message: _t("No available collaborator, please try again later."),
sticky: true,
});
} catch (_err) {
/**
* Failure in displaying notification happens when
* notification service doesn't exist, which is the case in
* external lib. We don't want notifications in external
* lib at the moment because they use bootstrap toast and
* we don't want to include boostrap in external lib.
*/
console.warn(_t("No available collaborator, please try again later."));
}
} else {
this.messaging.publicLivechatGlobal.update({
publicLivechat: insertAndReplace({ data: livechatData }),
});
return this._openChatWindow().then(() => {
if (!this.messaging.publicLivechatGlobal.livechatButtonView.history) {
this._sendWelcomeMessage();
}
this._renderMessages();
this.messaging.publicLivechatGlobal.update({ notificationHandler: insertAndReplace() });
utils.set_cookie('im_livechat_session', utils.unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat.toData()), true), 60 * 60);
utils.set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60 * 60);
if (this.messaging.publicLivechatGlobal.publicLivechat.operator) {
const operatorPidId = this.messaging.publicLivechatGlobal.publicLivechat.operator.id;
const oneWeek = 7 * 24 * 60 * 60;
utils.set_cookie('im_livechat_previous_operator_pid', operatorPidId, oneWeek);
}
});
}
}).then(() => {
this.messaging.publicLivechatGlobal.livechatButtonView.update({ isOpeningChat: false });
}).guardedCatch(() => {
this.messaging.publicLivechatGlobal.livechatButtonView.update({ isOpeningChat: false });
});
}, 200, true),
/**
* Will try to get a previous operator for this visitor.
* If the visitor already had visitor A, it's better for his user experience
@@ -206,11 +130,10 @@ const LivechatButton = Widget.extend({
* @private
*/
_renderMessages() {
const shouldScroll = !this.messaging.publicLivechatGlobal.publicLivechat.isFolded && this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow._publicLivechatView.isAtBottom();
this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat._messages = this.messaging.publicLivechatGlobal.livechatButtonView.messages.map(message => message.legacyPublicLivechatMessage);
const shouldScroll = !this.messaging.publicLivechatGlobal.publicLivechat.isFolded && this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.isAtBottom();
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.render();
if (shouldScroll) {
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow._publicLivechatView.scrollToBottom();
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.scrollToBottom();
}
},
/**
@@ -240,9 +163,9 @@ const LivechatButton = Widget.extend({
*/
console.warn(_t("Session expired... Please refresh and try again."));
}
this._closeChat();
this.messaging.publicLivechatGlobal.livechatButtonView.closeChat();
}
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow._publicLivechatView.scrollToBottom();
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.scrollToBottom();
});
},
/**
@@ -269,6 +192,12 @@ const LivechatButton = Widget.extend({
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onClick() {
this.messaging.publicLivechatGlobal.livechatButtonView.openChat();
},
/**
* @private
* @param {OdooEvent} ev
@@ -281,11 +210,11 @@ const LivechatButton = Widget.extend({
});
if (shouldAskFeedback) {
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.toggleFold(false);
this._askFeedback();
this.messaging.publicLivechatGlobal.livechatButtonView.askFeedback();
} else {
this._closeChat();
this.messaging.publicLivechatGlobal.livechatButtonView.closeChat();
}
this._visitorLeaveSession();
this.messaging.publicLivechatGlobal.livechatButtonView.leaveSession();
},
/**
* @private
@@ -324,19 +253,6 @@ const LivechatButton = Widget.extend({
ev.stopPropagation();
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow.renderHeader();
},
/**
* @private
* Called when the visitor leaves the livechat chatter the first time (first click on X button)
* this will deactivate the mail_channel, notify operator that visitor has left the channel.
*/
_visitorLeaveSession() {
const cookie = utils.get_cookie('im_livechat_session');
if (cookie) {
const channel = JSON.parse(cookie);
session.rpc('/im_livechat/visitor_leave_session', {uuid: channel.uuid});
utils.set_cookie('im_livechat_session', "", -1); // remove cookie
}
},
});
export default LivechatButton;
@@ -1,7 +1,5 @@
/** @odoo-module **/
import PublicLivechatView from '@im_livechat/legacy/widgets/public_livechat_view/public_livechat_view';
import config from 'web.config';
import { _t, qweb } from 'web.core';
import Widget from 'web.Widget';
@@ -46,13 +44,6 @@ const PublicLivechatWindow = Widget.extend({
async start() {
this.$input = this.$('.o_composer_text_field');
this.$header = this.$('.o_thread_window_header');
const options = {
displayMarkAsRead: false,
};
if (this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat._type === 'document_thread') {
options.displayDocumentLinks = false;
}
this._publicLivechatView = new PublicLivechatView(this, this.messaging, options);
// animate the (un)folding of thread windows
this.$el.css({ transition: 'height ' + this.FOLD_ANIMATION_DURATION + 'ms linear' });
@@ -65,8 +56,8 @@ const PublicLivechatWindow = Widget.extend({
const margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right";
this.$el.css(margin_dir, $.position.scrollbarWidth());
}
const def = this._publicLivechatView.replace(this.$('.o_thread_window_content')).then(() => {
this._publicLivechatView.$el.on('scroll', this, this._debouncedOnScroll);
const def = this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.replace(this.$('.o_thread_window_content')).then(() => {
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.$el.on('scroll', this, this._debouncedOnScroll);
});
await Promise.all([this._super(), def]);
if (this.messaging.publicLivechatGlobal.livechatButtonView.headerBackgroundColor) {
@@ -102,7 +93,7 @@ const PublicLivechatWindow = Widget.extend({
*/
render() {
this.renderHeader();
this._publicLivechatView.render({ displayLoadMore: false });
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.render({ displayLoadMore: false });
},
/**
* Render the header of this thread window.
@@ -128,7 +119,7 @@ const PublicLivechatWindow = Widget.extend({
* @param {$.Element} $element
*/
replaceContentWith($element) {
$element.replace(this._publicLivechatView.$el);
$element.replace(this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.$el);
},
/**
* Toggle the fold state of this thread window. Also update the fold state
@@ -155,7 +146,7 @@ const PublicLivechatWindow = Widget.extend({
*/
updateVisualFoldState() {
if (!this.messaging.publicLivechatGlobal.publicLivechat.isFolded) {
this._publicLivechatView.scrollToBottom();
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.scrollToBottom();
this._focusInput();
}
const height = this.messaging.publicLivechatGlobal.publicLivechat.isFolded ? this.HEIGHT_FOLDED : this.HEIGHT_OPEN;
@@ -203,7 +194,7 @@ const PublicLivechatWindow = Widget.extend({
this.trigger_up('post_message_chat_window', { messageData });
this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat.postMessage(messageData)
.then(() => {
this._publicLivechatView.scrollToBottom();
this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.scrollToBottom();
});
},
@@ -301,7 +292,7 @@ const PublicLivechatWindow = Widget.extend({
* @private
*/
_onScroll() {
if (this._publicLivechatView.isAtBottom()) {
if (this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.isAtBottom()) {
this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat.markAsRead();
}
},
@@ -8,6 +8,21 @@ registerModel({
name: 'Chatbot',
identifyingFields: ['livechatButtonViewOwner'],
recordMethods: {
/**
* @private
* @returns {boolean}
*/
_computeIsExpectingUserInput() {
if (!this.currentStep) {
return clear();
}
return [
'question_phone',
'question_email',
'free_input_single',
'free_input_multi',
].includes(this.currentStep.data.chatbot_step_type);
},
/**
* @private
* @returns {string}
@@ -15,6 +30,24 @@ registerModel({
_computeName() {
return this.data.name;
},
/**
* Will display a "Restart script" button in the conversation toolbar.
*
* Side-case: if the conversation has been forwarded to a human operator, we don't want to
* display that restart button.
*
* @private
* @returns {boolean}
*/
_computeHasRestartButton() {
return Boolean(
!this.currentStep ||
(
this.currentStep.data.chatbot_step_type !== 'forward_operator' ||
!this.currentStep.data.chatbot_operator_found
)
);
},
/**
* @private
* @returns {Array|FieldCommand}
@@ -42,6 +75,56 @@ registerModel({
_computeScriptId() {
return this.data.chatbot_script_id;
},
/**
* Helper method that checks if the script should be ended or not.
* If the user has closed the conversation -> script has ended.
*
* Otherwise, there are 2 use cases where we want to end the script:
*
* If the current step is the last one AND the conversation was not taken over by a human operator
* 1. AND we expect a user input (or we are on a selection)
* AND the user has already answered
* 2. AND we don't expect a user input
*
* @private
* @returns {boolean}
*/
_computeShouldEndScript() {
if (!this.currentStep) {
return clear();
}
if (this.currentStep.data.conversation_closed) {
return true;
}
if (this.currentStep.data.chatbot_step_is_last &&
(this.currentStep.data.chatbot_step_type !== 'forward_operator' ||
!this.currentStep.data.chatbot_operator_found)
) {
if (this.currentStep.data.chatbot_step_type === 'question_email'
&& !this.currentStep.data.is_email_valid
) {
// email is not (yet) valid, let the user answer / try again
return false;
} else if (
(this.isExpectingUserInput ||
this.currentStep.data.chatbot_step_type === 'question_selection') &&
this.messaging.publicLivechatGlobal.livechatButtonView.messages.length !== 0
) {
const lastMessage = this.messaging.publicLivechatGlobal.livechatButtonView.messages[this.messaging.publicLivechatGlobal.livechatButtonView.messages.length - 1];
if (lastMessage.authorId !== this.messaging.publicLivechatGlobal.publicLivechat.operator.id) {
// we are on the last step of the script, expect a user input and the user has
// already answered
// -> end the script
return true;
}
} else if (!this.isExpectingUserInput) {
// we are on the last step of the script and we do not expect a user input
// -> end the script
return true;
}
}
return false;
},
/**
* @private
* @returns {Array|FieldCommand}
@@ -56,6 +139,14 @@ registerModel({
inverse: 'chabotOwner',
isCausal: true,
}),
hasRestartButton: attr({
compute: '_computeHasRestartButton',
default: false,
}),
isExpectingUserInput: attr({
compute: '_computeIsExpectingUserInput',
default: false,
}),
lastWelcomeStep: attr({
compute: '_computeLastWelcomeStep',
}),
@@ -74,6 +165,10 @@ registerModel({
scriptId: attr({
compute: '_computeScriptId',
}),
shouldEndScript: attr({
compute: '_computeShouldEndScript',
default: false,
}),
welcomeSteps: attr({
compute: '_computeWelcomeSteps',
}),
@@ -6,6 +6,9 @@ import { registerModel } from '@mail/model/model_core';
import { attr, many, one } from '@mail/model/model_field';
import { clear, insertAndReplace, replace } from '@mail/model/model_field_command';
import { qweb } from 'web.core';
import { get_cookie, set_cookie, unaccent } from 'web.utils';
registerModel({
name: 'LivechatButtonView',
identifyingFields: ['publicLivechatGlobalOwner'],
@@ -43,6 +46,46 @@ registerModel({
});
}
},
askFeedback() {
this.chatWindow.legacyChatWindow.$('.o_thread_composer input').prop('disabled', true);
this.messaging.publicLivechatGlobal.update({ feedbackView: insertAndReplace() });
/**
* When we enter the "ask feedback" process of the chat, we hide some elements that become
* unnecessary and irrelevant (restart / end messages, any text field values, ...).
*/
if (
this.chatbot &&
this.chatbot.currentStep &&
this.chatbot.currentStep.data
) {
this.chatbot.currentStep.data.conversation_closed = true;
this.widget._chatbotSaveSession();
}
this.chatWindow.legacyChatWindow.$('.o_livechat_chatbot_main_restart').addClass('d-none');
this.chatWindow.legacyChatWindow.$('.o_livechat_chatbot_end').hide();
this.chatWindow.legacyChatWindow.$('.o_composer_text_field')
.removeClass('d-none')
.val('');
},
/**
* Once the script ends, adds a visual element at the end of the chat window allowing to restart
* the whole script.
*/
chatbotEndScript() {
if (
this.chatbot.currentStep &&
this.chatbot.currentStep.data &&
this.chatbot.currentStep.data.conversation_closed
) {
// don't touch anything if the user has closed the conversation, let the chat window
// handle the display
return;
}
this.chatWindow.legacyChatWindow.$('.o_composer_text_field').addClass('d-none');
this.chatWindow.legacyChatWindow.$('.o_livechat_chatbot_end').show();
this.chatWindow.legacyChatWindow.$('.o_livechat_chatbot_restart').one('click',
this.widget._onChatbotRestartScript.bind(this.widget));
},
/**
* See '_chatbotSaveSession'.
*
@@ -68,6 +111,57 @@ registerModel({
this.chatbot.update({ currentStep: insertAndReplace({ data: this.localStorageChatbotState._chatbotCurrentStep }) });
}
},
closeChat() {
this.update({ chatWindow: clear() });
set_cookie('im_livechat_session', "", -1); // remove cookie
},
/**
* Called when the visitor leaves the livechat chatter the first time (first click on X button)
* this will deactivate the mail_channel, notify operator that visitor has left the channel.
*/
leaveSession() {
const cookie = get_cookie('im_livechat_session');
if (cookie) {
const channel = JSON.parse(cookie);
this.messaging.rpc({ route: '/im_livechat/visitor_leave_session', params: { uuid: channel.uuid } });
set_cookie('im_livechat_session', "", -1); // remove cookie
}
},
openChat() {
if (this.isOpenChatDebounced) {
this.openChatDebounced();
} else {
this._openChat();
}
},
/**
* Adds a small "is typing" animation into the chat window.
*
* @param {boolean} [isWelcomeMessage=false]
*/
chatbotSetIsTyping(isWelcomeMessage = false) {
if (this.isTypingTimeout) {
clearTimeout(this.isTypingTimeout);
}
this.widget._chatbotDisableInput('');
this.update({
isTypingTimeout: setTimeout(
() => {
this.chatWindow.legacyChatWindow.$('.o_mail_thread_content').append(
$(qweb.render('im_livechat.legacy.chatbot.is_typing_message', {
'chatbotImageSrc': `/im_livechat/operator/${
this.messaging.publicLivechatGlobal.publicLivechat.operator.id
}/avatar`,
'chatbotName': this.chatbot.name,
'isWelcomeMessage': isWelcomeMessage,
}))
);
this.chatWindow.publicLivechatView.widget.scrollToBottom();
},
this.chatbot.messageDelay / 3,
),
});
},
/**
* @private
* @returns {string}
@@ -213,6 +307,13 @@ registerModel({
}
return clear();
},
/**
* @private
* @returns {FieldCommand}
*/
_computeIsOpenChatDebounced() {
return clear();
},
/**
* @private
* @returns {FieldCommand}
@@ -227,6 +328,13 @@ registerModel({
}
return JSON.parse(data);
},
/**
* @private
* @returns {_.debounce}
*/
_computeOpenChatDebounced() {
return _.debounce(this._openChat, 200, true);
},
/**
* @private
* @returns {string}
@@ -253,6 +361,70 @@ registerModel({
_computeTitleColor() {
return this.messaging.publicLivechatGlobal.options.title_color;
},
/**
* @private
*/
_openChat() {
if (this.isOpeningChat) {
return;
}
const cookie = get_cookie('im_livechat_session');
let def;
this.update({ isOpeningChat: true });
clearTimeout(this.autoOpenChatTimeout);
if (cookie) {
def = Promise.resolve(JSON.parse(cookie));
} else {
// re-initialize messages cache
this.update({ messages: clear() });
def = this.messaging.rpc({
route: '/im_livechat/get_session',
params: this.widget._prepareGetSessionParameters(),
}, { silent: true });
}
def.then((livechatData) => {
if (!livechatData || !livechatData.operator_pid) {
try {
this.widget.displayNotification({
message: this.env._t("No available collaborator, please try again later."),
sticky: true,
});
} catch (_err) {
/**
* Failure in displaying notification happens when
* notification service doesn't exist, which is the case in
* external lib. We don't want notifications in external
* lib at the moment because they use bootstrap toast and
* we don't want to include boostrap in external lib.
*/
console.warn(this.env._t("No available collaborator, please try again later."));
}
} else {
this.messaging.publicLivechatGlobal.update({
publicLivechat: insertAndReplace({ data: livechatData }),
});
return this.widget._openChatWindow().then(() => {
if (!this.history) {
this.widget._sendWelcomeMessage();
}
this.widget._renderMessages();
this.messaging.publicLivechatGlobal.update({ notificationHandler: insertAndReplace() });
set_cookie('im_livechat_session', unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat.toData()), true), 60 * 60);
set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60 * 60);
if (this.messaging.publicLivechatGlobal.publicLivechat.operator) {
const operatorPidId = this.messaging.publicLivechatGlobal.publicLivechat.operator.id;
const oneWeek = 7 * 24 * 60 * 60;
set_cookie('im_livechat_previous_operator_pid', operatorPidId, oneWeek);
}
});
}
}).then(() => {
this.update({ isOpeningChat: false });
}).guardedCatch(() => {
this.update({ isOpeningChat: false });
});
},
},
fields: {
autoOpenChatTimeout: attr(),
@@ -311,6 +483,10 @@ registerModel({
isChatbotRedirecting: attr({
default: false,
}),
isOpenChatDebounced: attr({
compute: '_computeIsOpenChatDebounced',
default: true,
}),
isOpeningChat: attr({
default: false,
}),
@@ -326,6 +502,9 @@ registerModel({
compute: '_computeLocalStorageChatbotState',
}),
messages: many('PublicLivechatMessage'),
openChatDebounced: attr({
compute: '_computeOpenChatDebounced',
}),
publicLivechatGlobalOwner: one('PublicLivechatGlobal', {
inverse: 'livechatButtonView',
readonly: true,
@@ -26,8 +26,8 @@ registerModel({
},
},
recordMethods: {
_onFeedbackSent(...args) {
this.messaging.publicLivechatGlobal.livechatButtonView.widget._closeChat(...args);
_onFeedbackSent() {
this.messaging.publicLivechatGlobal.livechatButtonView.closeChat();
},
_onSendMessage(...args) {
this.messaging.publicLivechatGlobal.livechatButtonView.widget._sendMessage(...args);
@@ -66,7 +66,7 @@ registerModel({
}
notificationData.body = utils.Markup(notificationData.body);
this.messaging.publicLivechatGlobal.livechatButtonView.addMessage(notificationData);
if (this.messaging.publicLivechatGlobal.publicLivechat.isFolded || !this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.legacyChatWindow._publicLivechatView.isAtBottom()) {
if (this.messaging.publicLivechatGlobal.publicLivechat.isFolded || !this.messaging.publicLivechatGlobal.livechatButtonView.chatWindow.publicLivechatView.widget.isAtBottom()) {
this.messaging.publicLivechatGlobal.publicLivechat.update({ unreadCounter: increment() });
}
this.messaging.publicLivechatGlobal.livechatButtonView.widget._renderMessages();
@@ -2,11 +2,27 @@
import { registerModel } from '@mail/model/model_core';
import { attr } from '@mail/model/model_field';
import { clear } from '@mail/model/model_field_command';
registerModel({
name: 'PublicLivechatMessage',
identifyingFields: ['id'],
recordMethods: {
/**
* @private
* @returns {FieldCommand|integer}
*/
_computeAuthorId() {
if (!(this.data.author_id && this.data.author_id[0])) {
return clear();
}
return this.data.author_id[0];
},
},
fields: {
authorId: attr({
compute: '_computeAuthorId',
}),
data: attr(),
id: attr({
readonly: true,
@@ -0,0 +1,29 @@
/** @odoo-module **/
import PublicLivechatView from '@im_livechat/legacy/widgets/public_livechat_view/public_livechat_view';
import { registerModel } from '@mail/model/model_core';
import { attr, one } from '@mail/model/model_field';
registerModel({
name: 'PublicLivechatView',
identifyingFields: ['publicLivechatWindowOwner'],
lifecycleHooks: {
_created() {
this.update({
widget: new PublicLivechatView(this, this.messaging, { displayMarkAsRead: false }),
});
},
_willDelete() {
this.widget.destroy();
},
},
fields: {
publicLivechatWindowOwner: one('PublicLivechatWindow', {
inverse: 'publicLivechatView',
readonly: true,
required: true,
}),
widget: attr(),
},
});
@@ -4,6 +4,7 @@ import PublicLivechatWindow from '@im_livechat/legacy/widgets/public_livechat_wi
import { registerModel } from '@mail/model/model_core';
import { attr, one } from '@mail/model/model_field';
import { insertAndReplace } from '@mail/model/model_field_command';
registerModel({
name: 'PublicLivechatWindow',
@@ -46,5 +47,10 @@ registerModel({
readonly: true,
required: true,
}),
publicLivechatView: one('PublicLivechatView', {
default: insertAndReplace(),
inverse: 'publicLivechatWindowOwner',
isCausal: true,
}),
},
});
@@ -34,7 +34,6 @@ const LivechatButtonTestChatbot = LivechatButton.extend({
data: this.messaging.publicLivechatGlobal.livechatButtonView.chatbot.lastWelcomeStep,
}),
});
this._channelData = this.messaging.publicLivechatGlobal.livechatButtonView.testChatbotData.channel;
},
/**
@@ -46,31 +45,6 @@ const LivechatButtonTestChatbot = LivechatButton.extend({
willStart: function () {
return this.messaging.publicLivechatGlobal.loadQWebTemplate();
},
/**
* Overridden to avoid calling the "get_session" endpoint as it requires a im_livechat.channel
* linked to work properly.
*
* Here, we already have a mail.channel created (see 'website_livechat_chatbot_test_script') so we
* use its configuration to create the 'WebsiteLivechat' Widget.
*
* @private
* @override
*/
_openChat: function () {
this.messaging.publicLivechatGlobal.update({
publicLivechat: insertAndReplace({ data: this._channelData }),
});
return this._openChatWindow().then(() => {
this._sendWelcomeMessage();
this._renderMessages();
this.call('bus_service', 'addChannel', this.messaging.publicLivechatGlobal.publicLivechat.uuid);
this.call('bus_service', 'startPolling');
utils.set_cookie('im_livechat_session', utils.unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat.toData()), true), 60 * 60);
this.messaging.publicLivechatGlobal.livechatButtonView.update({ isOpeningChat: false });
});
},
});
publicWidget.registry.livechatChatbotTestScript = publicWidget.Widget.extend({
@@ -0,0 +1,44 @@
/** @odoo-module **/
import { patchRecordMethods } from '@mail/model/model_core';
import { insertAndReplace } from '@mail/model/model_field_command';
// ensure that the model definition is loaded before the patch
import '@im_livechat/models/livechat_button_view';
import { set_cookie, unaccent } from 'web.utils';
patchRecordMethods('LivechatButtonView', {
/**
* @override
*/
_computeIsOpenChatDebounced() {
if (this.isTestChatbot) {
return false;
}
return this._super();
},
/**
* Overridden to avoid calling the "get_session" endpoint as it requires a im_livechat.channel
* linked to work properly.
*
* Here, we already have a mail.channel created (see 'website_livechat_chatbot_test_script') so we
* use its configuration to create the 'WebsiteLivechat' Widget.
*
* @override
*/
async _openChat() {
if (!this.isTestChatbot) {
return this._super();
}
this.messaging.publicLivechatGlobal.update({
publicLivechat: insertAndReplace({ data: this.testChatbotData.channel }),
});
await this.widget._openChatWindow();
this.widget._sendWelcomeMessage();
this.widget._renderMessages();
this.env.services.bus_service.addChannel(this.messaging.publicLivechatGlobal.publicLivechat.uuid);
this.env.services.bus_service.startPolling();
set_cookie('im_livechat_session', unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.legacyPublicLivechat.toData()), true), 60 * 60);
this.update({ isOpeningChat: false });
},
});