mirror of
https://github.com/bringout/oca-ocb-mail.git
synced 2026-04-22 12:42:04 +02:00
Initial commit: Mail packages
This commit is contained in:
commit
4e53507711
1948 changed files with 751201 additions and 0 deletions
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
|
|
@ -0,0 +1,27 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70">
|
||||
<defs>
|
||||
<path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/>
|
||||
<linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#B06161"/>
|
||||
<stop offset="45.785%" stop-color="#984E4E"/>
|
||||
<stop offset="100%" stop-color="#7C3838"/>
|
||||
</linearGradient>
|
||||
<path id="icon-d" d="M43.2723381,47 L4.024685,47 C2.0123425,47 0,45.9810543 0,42.9242174 L1.48938458e-14,18.2012896 L16.225629,0 L25.9851921,2.3438768 L45.134626,15.7216414 L52.320905,8.28006517 L57.3517613,13.3747934 L52.0323386,25.8943714 L59,31.065596 L43.4971227,46.7652986 L43.2723381,47 Z"/>
|
||||
<path id="icon-e" d="M29.8148059,45.4374895 C26.7685921,45.4374895 23.8982694,44.8827122 21.3772984,43.9035051 C18.8353437,45.9172903 15.7166184,47.1443958 12.4033677,47.4961458 C12.3794138,47.4986872 12.3553463,47.4999741 12.3312612,47.5000012 C12.0285762,47.5000012 11.7551388,47.2950872 11.6817361,47.0028098 C11.602338,46.6778841 11.8509027,46.4778919 12.0970368,46.2395091 C13.3136913,45.0550598 14.7886327,44.1239231 15.3654843,40.1448333 C13.0451961,37.8929934 11.6666667,35.0822747 11.6666667,32.0312864 C11.6666667,24.6264075 19.792577,18.6250012 29.8148059,18.6250012 C39.8370347,18.6250012 47.962945,24.6263255 47.962945,32.0312864 C47.962864,39.4413333 39.8370347,45.4374895 29.8148059,45.4374895 Z M57.9336461,54.2291067 C56.8039245,53.1522825 55.4343071,52.305802 54.8986939,48.68847 C60.4734134,43.3918762 59.125509,35.8148958 51.8464038,31.7026692 C51.8489153,31.8120989 51.851751,31.9214466 51.851751,32.0312864 C51.851751,42.0795403 41.3531335,49.7823567 28.8220864,49.3580911 C31.9105919,51.8978606 36.4369322,53.500013 41.4813857,53.500013 C44.3100649,53.500013 46.9753298,52.9956848 49.3161967,52.1054817 C51.676589,53.9361731 54.5725135,55.0517161 57.6491903,55.3714739 C57.9559262,55.4038762 58.2457293,55.2096262 58.3192131,54.9230091 C58.3930209,54.6276145 58.1621993,54.4458333 57.9336461,54.2291067 Z"/>
|
||||
</defs>
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<mask id="icon-b" fill="#fff">
|
||||
<use xlink:href="#icon-a"/>
|
||||
</mask>
|
||||
<g mask="url(#icon-b)">
|
||||
<rect width="70" height="70" fill="url(#icon-c)"/>
|
||||
<path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/>
|
||||
<g transform="translate(0 22)">
|
||||
<use fill="#000" fill-opacity=".151" xlink:href="#icon-d"/>
|
||||
</g>
|
||||
<path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/>
|
||||
<use fill="#000" fill-rule="nonzero" opacity=".345" xlink:href="#icon-e"/>
|
||||
<path fill="#FFF" fill-rule="nonzero" d="M29.8148059,43.4374895 C26.7685921,43.4374895 23.8982694,42.8827122 21.3772984,41.9035051 C18.8353437,43.9172903 15.7166184,45.1443958 12.4033677,45.4961458 C12.3794138,45.4986872 12.3553463,45.4999741 12.3312612,45.5000012 C12.0285762,45.5000012 11.7551388,45.2950872 11.6817361,45.0028098 C11.602338,44.6778841 11.8509027,44.4778919 12.0970368,44.2395091 C13.3136913,43.0550598 14.7886327,42.1239231 15.3654843,38.1448333 C13.0451961,35.8929934 11.6666667,33.0822747 11.6666667,30.0312864 C11.6666667,22.6264075 19.792577,16.6250012 29.8148059,16.6250012 C39.8370347,16.6250012 47.962945,22.6263255 47.962945,30.0312864 C47.962864,37.4413333 39.8370347,43.4374895 29.8148059,43.4374895 Z M57.9336461,52.2291067 C56.8039245,51.1522825 55.4343071,50.305802 54.8986939,46.68847 C60.4734134,41.3918762 59.125509,33.8148958 51.8464038,29.7026692 C51.8489153,29.8120989 51.851751,29.9214466 51.851751,30.0312864 C51.851751,40.0795403 41.3531335,47.7823567 28.8220864,47.3580911 C31.9105919,49.8978606 36.4369322,51.500013 41.4813857,51.500013 C44.3100649,51.500013 46.9753298,50.9956848 49.3161967,50.1054817 C51.676589,51.9361731 54.5725135,53.0517161 57.6491903,53.3714739 C57.9559262,53.4038762 58.2457293,53.2096262 58.3192131,52.9230091 C58.3930209,52.6276145 58.1621993,52.4458333 57.9336461,52.2291067 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-inherit="mail.Composer" t-inherit-mode="extension">
|
||||
<xpath expr="//*[hasclass('o_Composer_buttonAttachment')]" position="replace">
|
||||
<t t-if="!composerView.composer.activeThread or !composerView.composer.activeThread.channel or composerView.composer.activeThread.channel.channel_type !== 'livechat'">$0</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.DiscussSidebar" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='beforeCategoryChat']" position="before">
|
||||
<t t-set="categoryLivechat" t-value="discussView.discuss.categoryLivechat"/>
|
||||
<t t-if="categoryLivechat and categoryLivechat.categoryItems.length">
|
||||
<DiscussSidebarCategory
|
||||
className="'o_DiscussSidebar_category o_DiscussSidebar_categoryLivechat'"
|
||||
record="categoryLivechat"
|
||||
/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ThreadIcon" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='root']" position="inside">
|
||||
<t t-elif="thread.channel and thread.channel.channel_type === 'livechat'">
|
||||
<t t-if="thread.orderedOtherTypingMembers.length > 0">
|
||||
<ThreadTypingIcon
|
||||
className="'o_ThreadIcon_typing'"
|
||||
animation="'pulse'"
|
||||
title="thread.typingStatusText"
|
||||
/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="fa fa-fw fa-comments" title="Livechat"/>
|
||||
</t>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { ThreadNeedactionPreview } from '@mail/components/thread_needaction_preview/thread_needaction_preview';
|
||||
|
||||
import { patch } from 'web.utils';
|
||||
|
||||
const components = { ThreadNeedactionPreview };
|
||||
|
||||
patch(components.ThreadNeedactionPreview.prototype, 'thread_needaction_preview', {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
image(...args) {
|
||||
if (this.threadNeedactionPreviewView.thread.channel && this.threadNeedactionPreviewView.thread.channel.channel_type === 'livechat') {
|
||||
return '/mail/static/src/img/smiley/avatar.jpg';
|
||||
}
|
||||
return this._super(...args);
|
||||
}
|
||||
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -0,0 +1,12 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { assets } from "@web/core/assets";
|
||||
|
||||
/**
|
||||
* This file should be used in the context of an external widget loading (e.g: live chat in a non-Odoo website)
|
||||
* It overrides the 'loadJS' method that is supposed to load additional scripts, based on a relative URL (e.g: '/web/webclient/locale/en_US')
|
||||
* As we're not in an Odoo website context, the calls will not work, and we avoid a 404 request.
|
||||
*/
|
||||
assets.loadJS = function (url) {
|
||||
console.log('Tried to load the following script on an external website: ' + url);
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class ColorsResetButton extends Component {
|
||||
onColorsResetButtonClick() {
|
||||
this.props.record.update(this.props.default_colors);
|
||||
}
|
||||
}
|
||||
ColorsResetButton.template = `im_livechat.ColorsResetButton`;
|
||||
ColorsResetButton.props = {
|
||||
...standardWidgetProps,
|
||||
default_colors: { type: Object },
|
||||
};
|
||||
ColorsResetButton.extractProps = ({ attrs }) => {
|
||||
// Note: `options` should have `default_colors`. It's specified when using the widget.
|
||||
return attrs.options;
|
||||
};
|
||||
|
||||
registry.category('view_widgets').add('colors_reset_button', ColorsResetButton);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="im_livechat.ColorsResetButton" owl="1">
|
||||
<button class="btn btn-link oe_edit_only" t-on-click="onColorsResetButtonClick" aria-label="Reset to default colors" title="Reset to default colors">
|
||||
<span class="fa fa-refresh mb-4"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Many2ManyTagsField } from "@web/views/fields/many2many_tags/many2many_tags_field";
|
||||
|
||||
const fieldRegistry = registry.category("fields");
|
||||
|
||||
export class ChatbotScriptTriggeringAnswersMany2Many extends Many2ManyTagsField {
|
||||
/**
|
||||
* Force the chatbot script ID we are currently editing into the context.
|
||||
* This allows to filter triggering question answers on steps of this script.
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
if (this.props.record.model.root.data.id) {
|
||||
this.env.services.user.updateContext({
|
||||
force_domain_chatbot_script_id: this.props.record.model.root.data.id
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fieldRegistry.add("chatbot_triggering_answers_widget", ChatbotScriptTriggeringAnswersMany2Many);
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { ListRenderer } from "@web/views/list/list_renderer";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { patch } from '@web/core/utils/patch';
|
||||
import { useX2ManyCrud, useOpenX2ManyRecord, X2ManyFieldDialog } from "@web/views/fields/relational_utils";
|
||||
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
|
||||
|
||||
const fieldRegistry = registry.category("fields");
|
||||
|
||||
patch(X2ManyFieldDialog.prototype, 'chatbot_script_step_sequence', {
|
||||
/**
|
||||
* Dirty patching of the 'X2ManyFieldDialog'.
|
||||
* It is done to force the "save and new" to close the dialog first, and then click again on
|
||||
* the "Add a line" link.
|
||||
*
|
||||
* This is the only way (or at least the least complicated) to correctly compute the sequence
|
||||
* field, which is crucial when creating chatbot.steps, as they depend on each other.
|
||||
*
|
||||
*/
|
||||
async save({ saveAndNew }) {
|
||||
if (this.record.resModel !== 'chatbot.script.step') {
|
||||
return this._super(...arguments);
|
||||
}
|
||||
|
||||
if (await this.record.checkValidity()) {
|
||||
this.record = (await this.props.save(this.record, { saveAndNew })) || this.record;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.props.close();
|
||||
|
||||
if (saveAndNew) {
|
||||
document.querySelector('.o_field_x2many_list_row_add a').click();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
export class ChatbotStepsOne2manyRenderer extends ListRenderer {
|
||||
/**
|
||||
* Small override to force column entries to be non-sortable.
|
||||
* Indeed, we want to force it being sorted on "sequence" at all times.
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
for (const [, properties] of Object.entries(this.fields)) {
|
||||
properties.sortable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatbotStepsOne2many extends X2ManyField {
|
||||
/**
|
||||
* Overrides the "openRecord" method to overload the save.
|
||||
*
|
||||
* Every time we save a sub-chatbot.script.step, we want to save the whole chatbot.script record
|
||||
* and form view.
|
||||
*
|
||||
* This allows the end-user to easily chain steps, otherwise he would have to save the
|
||||
* enclosing form view in-between each step addition.
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
const { saveRecord, updateRecord } = useX2ManyCrud(
|
||||
() => this.list,
|
||||
this.isMany2Many
|
||||
);
|
||||
|
||||
const openRecord = useOpenX2ManyRecord({
|
||||
resModel: this.list.resModel,
|
||||
activeField: this.activeField,
|
||||
activeActions: this.activeActions,
|
||||
getList: () => this.list,
|
||||
saveRecord: async (record) => {
|
||||
await saveRecord(record);
|
||||
await this.props.record.save({stayInEdition: true});
|
||||
},
|
||||
updateRecord: updateRecord,
|
||||
});
|
||||
|
||||
this._openRecord = (params) => {
|
||||
const activeElement = document.activeElement;
|
||||
openRecord({
|
||||
...params,
|
||||
onClose: () => {
|
||||
if (activeElement) {
|
||||
activeElement.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
fieldRegistry.add("chatbot_steps_one2many", ChatbotStepsOne2many);
|
||||
|
||||
ChatbotStepsOne2many.components = {
|
||||
...X2ManyField.components,
|
||||
ListRenderer: ChatbotStepsOne2manyRenderer
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import CCThrottleFunctionObject from '@im_livechat/legacy/models/cc_throttle_function_object';
|
||||
|
||||
/**
|
||||
* A function that creates a cancellable and clearable (CC) throttle version
|
||||
* of a provided function.
|
||||
*
|
||||
* This throttle mechanism allows calling a function at most once during a
|
||||
* certain period:
|
||||
*
|
||||
* - When a function call is made, it enters a 'cooldown' phase, in which any
|
||||
* attempt to call the function is buffered until the cooldown phase ends.
|
||||
* - At most 1 function call can be buffered during the cooldown phase, and the
|
||||
* latest one in this phase will be considered at its end.
|
||||
* - When a cooldown phase ends, any buffered function call will be performed
|
||||
* and another cooldown phase will follow up.
|
||||
*
|
||||
* This throttle version has the following interesting properties:
|
||||
*
|
||||
* - cancellable: it allows removing a buffered function call during the
|
||||
* cooldown phase, but it keeps the cooldown phase running.
|
||||
* - clearable: it allows to clear the internal clock of the throttled function,
|
||||
* so that any cooldown phase is immediately ending.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {integer} params.duration a duration for the throttled behaviour,
|
||||
* in milli-seconds.
|
||||
* @param {function} params.func the function to throttle
|
||||
* @returns {function} the cancellable and clearable throttle version of the
|
||||
* provided function in argument.
|
||||
*/
|
||||
const CCThrottleFunction = function (params) {
|
||||
const duration = params.duration;
|
||||
const func = params.func;
|
||||
|
||||
const throttleFunctionObject = new CCThrottleFunctionObject({
|
||||
duration,
|
||||
func,
|
||||
});
|
||||
|
||||
const callable = function () {
|
||||
return throttleFunctionObject.do(...arguments);
|
||||
};
|
||||
callable.cancel = function () {
|
||||
throttleFunctionObject.cancel();
|
||||
};
|
||||
callable.clear = function () {
|
||||
throttleFunctionObject.clear();
|
||||
};
|
||||
|
||||
return callable;
|
||||
};
|
||||
|
||||
export default CCThrottleFunction;
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import Class from 'web.Class';
|
||||
|
||||
/**
|
||||
* This object models the behaviour of the clearable and cancellable (CC)
|
||||
* throttle version of a provided function.
|
||||
*/
|
||||
const CCThrottleFunctionObject = Class.extend({
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {integer} params.duration duration of the 'cooldown' phase, i.e.
|
||||
* the minimum duration between the most recent function call that has
|
||||
* been made and the following function call.
|
||||
* @param {function} params.func provided function for making the CC
|
||||
* throttled version.
|
||||
*/
|
||||
init(params) {
|
||||
this._arguments = undefined;
|
||||
this._cooldownTimeout = undefined;
|
||||
this._duration = params.duration;
|
||||
this._func = params.func;
|
||||
this._shouldCallFunctionAfterCD = false;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Cancel any buffered function call, but keep the cooldown phase running.
|
||||
*/
|
||||
cancel() {
|
||||
this._arguments = undefined;
|
||||
this._shouldCallFunctionAfterCD = false;
|
||||
},
|
||||
/**
|
||||
* Clear the internal throttle timer, so that the following function call
|
||||
* is immediate. For instance, if there is a cooldown stage, it is aborted.
|
||||
*/
|
||||
clear() {
|
||||
if (this._cooldownTimeout) {
|
||||
clearTimeout(this._cooldownTimeout);
|
||||
this._onCooldownTimeout();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when there is a call to the function. This function is throttled,
|
||||
* so the time it is called depends on whether the "cooldown stage" occurs
|
||||
* or not:
|
||||
*
|
||||
* - no cooldown stage: function is called immediately, and it starts
|
||||
* the cooldown stage when successful.
|
||||
* - in cooldown stage: function is called when the cooldown stage has
|
||||
* ended from timeout.
|
||||
*
|
||||
* Note that after the cooldown stage, only the last attempted function
|
||||
* call will be considered.
|
||||
*/
|
||||
do() {
|
||||
this._arguments = Array.prototype.slice.call(arguments);
|
||||
if (this._cooldownTimeout === undefined) {
|
||||
this._callFunction();
|
||||
} else {
|
||||
this._shouldCallFunctionAfterCD = true;
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Immediately calls the function with arguments of last buffered function
|
||||
* call. It initiates the cooldown stage after this function call.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_callFunction() {
|
||||
this._func.apply(null, this._arguments);
|
||||
this._cooldown();
|
||||
},
|
||||
/**
|
||||
* Called when the function has been successfully called. The following
|
||||
* calls to the function with this object should suffer a "cooldown stage",
|
||||
* which prevents the function from being called until this stage has ended.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_cooldown() {
|
||||
this.cancel();
|
||||
this._cooldownTimeout = setTimeout(
|
||||
this._onCooldownTimeout.bind(this),
|
||||
this._duration
|
||||
);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when the cooldown stage ended from timeout. Calls the function if
|
||||
* a function call was buffered.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onCooldownTimeout() {
|
||||
if (this._shouldCallFunctionAfterCD) {
|
||||
this._callFunction();
|
||||
} else {
|
||||
this._cooldownTimeout = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default CCThrottleFunctionObject;
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import CCThrottleFunction from '@im_livechat/legacy/models/cc_throttle_function';
|
||||
import Timer from '@im_livechat/legacy/models/timer';
|
||||
import Timers from '@im_livechat/legacy/models/timers';
|
||||
|
||||
import Class from 'web.Class';
|
||||
import { _t } from 'web.core';
|
||||
import session from 'web.session';
|
||||
import Mixins from 'web.mixins';
|
||||
import { sprintf } from 'web.utils';
|
||||
|
||||
/**
|
||||
* Thread model that represents a livechat on the website-side. This livechat
|
||||
* is not linked to the mail service.
|
||||
*/
|
||||
const PublicLivechat = Class.extend(Mixins.EventDispatcherMixin, {
|
||||
/**
|
||||
* @private
|
||||
* @param {Messaging} messaging
|
||||
* @param {Object} params
|
||||
* @param {Object} params.data
|
||||
* @param {boolean} [params.data.folded] states whether the livechat is
|
||||
* folded or not. It is considered only if this is defined and it is a
|
||||
* boolean.
|
||||
* @param {integer} params.data.id the ID of this livechat.
|
||||
* @param {integer} [params.data.message_unread_counter] the unread counter
|
||||
* of this livechat.
|
||||
* @param {Array} params.data.operator_pid
|
||||
* @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(messaging, params) {
|
||||
Mixins.EventDispatcherMixin.init.call(this, arguments);
|
||||
this.setParent(params.parent);
|
||||
this.messaging = messaging;
|
||||
|
||||
/**
|
||||
* Initialize the internal data for typing feature on threads.
|
||||
*/
|
||||
|
||||
// Store the last "myself typing" status that has been sent to the
|
||||
// server. This is useful in order to not notify the same typing
|
||||
// status multiple times.
|
||||
this._lastNotifiedMyselfTyping = false;
|
||||
|
||||
// Timer of current user that is typing a very long text. When the
|
||||
// receivers do not receive any typing notification for a long time,
|
||||
// they assume that the related partner is no longer typing
|
||||
// something (e.g. they have closed the browser tab).
|
||||
// This is a timer to let others know that we are still typing
|
||||
// something, so that they do not assume we stopped typing
|
||||
// something.
|
||||
this._myselfLongTypingTimer = new Timer({
|
||||
duration: 50 * 1000,
|
||||
onTimeout: this._onMyselfLongTypingTimeout.bind(this),
|
||||
});
|
||||
|
||||
// Timer of current user that was currently typing something, but
|
||||
// there is no change on the input for several time. This is used
|
||||
// in order to automatically notify other users that we have stopped
|
||||
// typing something, due to making no changes on the composer for
|
||||
// some time.
|
||||
this._myselfTypingInactivityTimer = new Timer({
|
||||
duration: 5 * 1000,
|
||||
onTimeout: this._onMyselfTypingInactivityTimeout.bind(this),
|
||||
});
|
||||
|
||||
// Timers of users currently typing in the thread. This is useful
|
||||
// in order to automatically unregister typing users when we do not
|
||||
// receive any typing notification after a long time. Timers are
|
||||
// internally indexed by partnerID. The current user is ignored in
|
||||
// this list of timers.
|
||||
this._othersTypingTimers = new Timers({
|
||||
duration: 60 * 1000,
|
||||
onTimeout: this._onOthersTypingTimeout.bind(this),
|
||||
});
|
||||
|
||||
// Clearable and cancellable throttled version of the
|
||||
// `doNotifyMyselfTyping` method. (basically `notifyMyselfTyping`
|
||||
// with slight pre- and post-processing)
|
||||
// @see {mail.model.ResetableThrottleFunction}
|
||||
// This is useful when the user posts a message and types something
|
||||
// else: he must notify immediately that he is typing something,
|
||||
// instead of waiting for the throttle internal timer.
|
||||
this._throttleNotifyMyselfTyping = CCThrottleFunction({
|
||||
duration: 2.5 * 1000,
|
||||
func: this._onNotifyMyselfTyping.bind(this),
|
||||
});
|
||||
|
||||
// This is used to track the order of registered partners typing
|
||||
// something, in order to display the oldest typing partners.
|
||||
this._typingPartnerIDs = [];
|
||||
|
||||
if (params.data.message_unread_counter !== undefined) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.update({
|
||||
unreadCounter: params.data.message_unread_counter
|
||||
});
|
||||
}
|
||||
|
||||
if (_.isBoolean(params.data.folded)) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.update({ isFolded: params.data.folded });
|
||||
} else {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.update({ isFolded: params.data.state === 'folded' });
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when a new message is added to the thread
|
||||
* On receiving a message from a typing partner, unregister this partner
|
||||
* from typing partners (otherwise, it will still display it until timeout).
|
||||
*
|
||||
* Note that it only unregister typing operators.
|
||||
*
|
||||
* Note that in the frontend, there is no way to identify a message that is
|
||||
* from the current user, because there is no partner ID in the session and
|
||||
* a message with an author sets the partner ID of the author.
|
||||
*
|
||||
* @param {@im_livechat/legacy/models/public_livechat_message} message
|
||||
*/
|
||||
addMessage(message) {
|
||||
const operatorID = this.messaging.publicLivechatGlobal.publicLivechat.operator.id;
|
||||
if (message.hasAuthor() && message.getAuthorID() === operatorID) {
|
||||
this.unregisterTyping({ partnerID: operatorID });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @returns {@im_livechat/legacy/models/public_livechat_message[]}
|
||||
*/
|
||||
getMessages() {
|
||||
// ignore removed messages
|
||||
return this.messaging.publicLivechatGlobal.messages.filter(message => !message.widget.isEmpty()).map(message => message.widget);
|
||||
},
|
||||
/**
|
||||
* Get the text to display when some partners are typing something on the
|
||||
* thread:
|
||||
*
|
||||
* - single typing partner:
|
||||
*
|
||||
* A is typing...
|
||||
*
|
||||
* - two typing partners:
|
||||
*
|
||||
* A and B are typing...
|
||||
*
|
||||
* - three or more typing partners:
|
||||
*
|
||||
* A, B and more are typing...
|
||||
*
|
||||
* The choice of the members name for display is not random: it displays
|
||||
* the user that have been typing for the longest time. Also, this function
|
||||
* is hard-coded to display at most 2 partners. This limitation comes from
|
||||
* how translation works in Odoo, for which unevaluated string cannot be
|
||||
* translated.
|
||||
*
|
||||
* @returns {string} list of members that are typing something on the thread
|
||||
* (excluding the current user).
|
||||
*/
|
||||
getTypingMembersToText() {
|
||||
const typingPartnerIDs = this._typingPartnerIDs;
|
||||
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);
|
||||
});
|
||||
const displayableTypingMembers = sortedTypingMembers.slice(0, 3);
|
||||
|
||||
if (displayableTypingMembers.length === 0) {
|
||||
return '';
|
||||
} else if (displayableTypingMembers.length === 1) {
|
||||
return sprintf(_t("%s is typing..."), displayableTypingMembers[0].name);
|
||||
} else if (displayableTypingMembers.length === 2) {
|
||||
return sprintf(_t("%s and %s are typing..."),
|
||||
displayableTypingMembers[0].name,
|
||||
displayableTypingMembers[1].name);
|
||||
} else {
|
||||
return sprintf(_t("%s, %s and more are typing..."),
|
||||
displayableTypingMembers[0].name,
|
||||
displayableTypingMembers[1].name);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasMessages() {
|
||||
return !_.isEmpty(this.getMessages());
|
||||
},
|
||||
/**
|
||||
* Tells if someone other than current user is typing something on this
|
||||
* thread.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSomeoneTyping() {
|
||||
return !(_.isEmpty(this._typingPartnerIDs));
|
||||
},
|
||||
/**
|
||||
* 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.messaging.publicLivechatGlobal.publicLivechat.unreadCounter > 0) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.update({ unreadCounter: 0 });
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.renderHeader();
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* Called when current user has posted a message on this thread.
|
||||
*
|
||||
* The current user receives the possibility to immediately notify the
|
||||
* other users if he is typing something else.
|
||||
*
|
||||
* Refresh the context for the current user to notify that he starts or
|
||||
* stops typing something. In other words, when this function is called and
|
||||
* then the current user types something, it immediately notifies the
|
||||
* server as if it is the first time he is typing something.
|
||||
*/
|
||||
async postMessage() {
|
||||
this._lastNotifiedMyselfTyping = false;
|
||||
this._throttleNotifyMyselfTyping.clear();
|
||||
this._myselfLongTypingTimer.clear();
|
||||
this._myselfTypingInactivityTimer.clear();
|
||||
},
|
||||
/**
|
||||
* Register someone that is currently typing something in this thread.
|
||||
* If this is the current user that is typing something, don't do anything
|
||||
* (we do not have to display anything)
|
||||
*
|
||||
* This method is ignored if we try to register the current user.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {integer} params.partnerID ID of the partner linked to the user
|
||||
* currently typing something on the thread.
|
||||
*/
|
||||
registerTyping(params) {
|
||||
if (params.isWebsiteUser) {
|
||||
return;
|
||||
}
|
||||
const partnerID = params.partnerID;
|
||||
this._othersTypingTimers.registerTimer({
|
||||
timeoutCallbackArguments: [partnerID],
|
||||
timerID: partnerID,
|
||||
});
|
||||
if (_.contains(this._typingPartnerIDs, partnerID)) {
|
||||
return;
|
||||
}
|
||||
this._typingPartnerIDs.push(partnerID);
|
||||
this._warnUpdatedTypingPartners();
|
||||
},
|
||||
/**
|
||||
* This method must be called when the user starts or stops typing something
|
||||
* in the composer of the thread.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.typing tell whether the current is typing or not.
|
||||
*/
|
||||
setMyselfTyping(params) {
|
||||
const typing = params.typing;
|
||||
if (this._lastNotifiedMyselfTyping === typing) {
|
||||
this._throttleNotifyMyselfTyping.cancel();
|
||||
} else {
|
||||
this._throttleNotifyMyselfTyping(params);
|
||||
}
|
||||
|
||||
if (typing) {
|
||||
this._myselfTypingInactivityTimer.reset();
|
||||
} else {
|
||||
this._myselfTypingInactivityTimer.clear();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @returns {Object}
|
||||
*/
|
||||
toData() {
|
||||
return {
|
||||
visitor_uid: this.messaging.publicLivechatGlobal.getVisitorUserId(),
|
||||
chatbot_script_id: this.messaging.publicLivechatGlobal.publicLivechat.data.chatbot_script_id,
|
||||
folded: this.messaging.publicLivechatGlobal.publicLivechat.isFolded,
|
||||
id: this.messaging.publicLivechatGlobal.publicLivechat.id,
|
||||
message_unread_counter: this.messaging.publicLivechatGlobal.publicLivechat.unreadCounter,
|
||||
operator_pid: (
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.operator
|
||||
? [
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.operator.id,
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.operator.name,
|
||||
]
|
||||
: []
|
||||
),
|
||||
name: this.messaging.publicLivechatGlobal.publicLivechat.name,
|
||||
uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Unregister someone from currently typing something in this thread.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {integer} params.partnerID ID of the partner related to the user
|
||||
* that is currently typing something
|
||||
*/
|
||||
unregisterTyping(params) {
|
||||
const partnerID = params.partnerID;
|
||||
this._othersTypingTimers.unregisterTimer({ timerID: partnerID });
|
||||
if (!_.contains(this._typingPartnerIDs, partnerID)) {
|
||||
return;
|
||||
}
|
||||
this._typingPartnerIDs = _.reject(this._typingPartnerIDs, function (id) {
|
||||
return id === partnerID;
|
||||
});
|
||||
this._warnUpdatedTypingPartners();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Notify to the server that the current user either starts or stops typing
|
||||
* something.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.typing whether we are typing something or not
|
||||
* @returns {Promise} resolved if the server is notified, rejected
|
||||
* otherwise
|
||||
*/
|
||||
_notifyMyselfTyping(params) {
|
||||
if (this.messaging.publicLivechatGlobal.publicLivechat.isTemporary) {
|
||||
// channel is not created yet, it will be when first message is
|
||||
// sent. Until then, do not notify visitor is typing.
|
||||
return;
|
||||
}
|
||||
return session.rpc('/im_livechat/notify_typing', {
|
||||
uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
|
||||
is_typing: params.typing,
|
||||
}, { shadow: true });
|
||||
},
|
||||
/**
|
||||
* Warn views that the list of users that are currently typing on this
|
||||
* livechat has been updated.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_warnUpdatedTypingPartners() {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.renderHeader();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handler
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when current user is typing something for a long time. In order
|
||||
* to not let other users assume that we are no longer typing something, we
|
||||
* must notify again that we are typing something.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onMyselfLongTypingTimeout() {
|
||||
this._throttleNotifyMyselfTyping.clear();
|
||||
this._throttleNotifyMyselfTyping({ typing: true });
|
||||
},
|
||||
/**
|
||||
* Called when current user has something typed in the composer, but is
|
||||
* inactive for some time. In this case, he automatically notifies that he
|
||||
* is no longer typing something
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onMyselfTypingInactivityTimeout() {
|
||||
this._throttleNotifyMyselfTyping.clear();
|
||||
this._throttleNotifyMyselfTyping({ typing: false });
|
||||
},
|
||||
/**
|
||||
* Called by throttled version of notify myself typing
|
||||
*
|
||||
* Notify to the server that the current user either starts or stops typing
|
||||
* something. Remember last notified stuff from the server, and update
|
||||
* related typing timers.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.typing whether we are typing something or not.
|
||||
*/
|
||||
_onNotifyMyselfTyping(params) {
|
||||
const typing = params.typing;
|
||||
this._lastNotifiedMyselfTyping = typing;
|
||||
this._notifyMyselfTyping(params);
|
||||
if (typing) {
|
||||
this._myselfLongTypingTimer.reset();
|
||||
} else {
|
||||
this._myselfLongTypingTimer.clear();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when current user do not receive a typing notification of someone
|
||||
* else typing for a long time. In this case, we assume that this person is
|
||||
* no longer typing something.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} partnerID partnerID of the person we assume he is no
|
||||
* longer typing something.
|
||||
*/
|
||||
_onOthersTypingTimeout(partnerID) {
|
||||
this.unregisterTyping({ partnerID });
|
||||
},
|
||||
});
|
||||
|
||||
export default PublicLivechat;
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
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/public_livechat_message for more information.
|
||||
*/
|
||||
const PublicLivechatMessage = Class.extend({
|
||||
|
||||
/**
|
||||
* @param {@im_livechat/legacy/widgets/livechat_button} parent
|
||||
* @param {Messaging} messaging
|
||||
* @param {Object} data
|
||||
* @param {Object|Array} [data.author]
|
||||
* @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, messaging, data) {
|
||||
this.messaging = messaging;
|
||||
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._serverAuthor = data.author;
|
||||
this._type = data.message_type || undefined;
|
||||
|
||||
this._defaultUsername = this.messaging.publicLivechatGlobal.options.default_username;
|
||||
this._serverURL = this.messaging.publicLivechatGlobal.serverUrl;
|
||||
|
||||
if (this.messaging.publicLivechatGlobal.chatbot.isActive) {
|
||||
this._chatbotStepId = data.chatbot_script_step_id;
|
||||
this._chatbotStepAnswers = data.chatbot_step_answers;
|
||||
this._chatbotStepAnswerId = data.chatbot_selected_answer_id;
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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._serverAuthor.id;
|
||||
},
|
||||
/**
|
||||
* Get the relative url of the avatar to display next to the message
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
getAvatarSource() {
|
||||
let source = this._serverURL;
|
||||
if (this.isOperatorTheAuthor()) {
|
||||
source += `/im_livechat/operator/${this.getAuthorID()}/avatar`;
|
||||
} else if (this.hasAuthor() && session.user_id) {
|
||||
source += `/web/image/res.partner/${this.getAuthorID()}/avatar_128`;
|
||||
} else {
|
||||
source += '/mail/static/src/img/smiley/avatar.jpg';
|
||||
}
|
||||
return source;
|
||||
},
|
||||
/**
|
||||
* Get the body content of this message
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
getBody() {
|
||||
return this._body;
|
||||
},
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
getChatbotStepId() {
|
||||
return this._chatbotStepId;
|
||||
},
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
getChatbotStepAnswers() {
|
||||
return this._chatbotStepAnswers;
|
||||
},
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
getChatbotStepAnswerId() {
|
||||
return this._chatbotStepAnswerId;
|
||||
},
|
||||
/**
|
||||
* @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
|
||||
*
|
||||
* Rule of precedence for the displayed author::
|
||||
*
|
||||
* author name > default usernane
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
getDisplayedAuthor() {
|
||||
return (this.hasAuthor() ? this._getAuthorName() : null) || this._defaultUsername;
|
||||
},
|
||||
/**
|
||||
* Get the server ID (number) of this message
|
||||
*
|
||||
* @return {integer}
|
||||
*/
|
||||
getID() {
|
||||
return this._id;
|
||||
},
|
||||
/**
|
||||
* 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 has an author
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasAuthor() {
|
||||
return Boolean(this._serverAuthor && this._serverAuthor.id);
|
||||
},
|
||||
/**
|
||||
* State whether this message is empty
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isEmpty() {
|
||||
return !this.getBody();
|
||||
},
|
||||
/**
|
||||
* State whether this message is a discussion
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isDiscussion() {
|
||||
return this._isDiscussion;
|
||||
},
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
setChatbotStepAnswerId(chatbotStepAnswerId) {
|
||||
this._chatbotStepAnswerId = chatbotStepAnswerId;
|
||||
},
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
|
||||
isVisitorTheAuthor() {
|
||||
return !this.hasAuthor() || this._isMyselfAuthor();
|
||||
},
|
||||
|
||||
isOperatorTheAuthor() {
|
||||
return this.hasAuthor() && !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._serverAuthor.name || this._serverAuthor.user_livechat_username;
|
||||
},
|
||||
/**
|
||||
* State whether the current user is the author of this message
|
||||
*
|
||||
* @private
|
||||
* @return {boolean}
|
||||
*/
|
||||
_isMyselfAuthor() {
|
||||
return this.hasAuthor() && (this.getAuthorID() === this.messaging.publicLivechatGlobal.options.current_partner_id);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default PublicLivechatMessage;
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import Class from 'web.Class';
|
||||
|
||||
/**
|
||||
* This class creates a timer which, when times out, calls a function.
|
||||
*/
|
||||
const Timer = Class.extend({
|
||||
|
||||
/**
|
||||
* Instantiate a new timer. Note that the timer is not started on
|
||||
* initialization (@see start method).
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} params.duration duration of timer before timeout in
|
||||
* milli-seconds.
|
||||
* @param {function} params.onTimeout function that is called when the
|
||||
* timer times out.
|
||||
*/
|
||||
init(params) {
|
||||
this._duration = params.duration;
|
||||
this._timeout = undefined;
|
||||
this._timeoutCallback = params.onTimeout;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Clears the countdown of the timer.
|
||||
*/
|
||||
clear() {
|
||||
clearTimeout(this._timeout);
|
||||
},
|
||||
/**
|
||||
* Resets the timer, i.e. resets its duration.
|
||||
*/
|
||||
reset() {
|
||||
this.clear();
|
||||
this.start();
|
||||
},
|
||||
/**
|
||||
* Starts the timer, i.e. after a certain duration, it times out and calls
|
||||
* a function back.
|
||||
*/
|
||||
start() {
|
||||
this._timeout = setTimeout(this._onTimeout.bind(this), this._duration);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handler
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when the timer times out, calls back a function on timeout.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onTimeout() {
|
||||
this._timeoutCallback();
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default Timer;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import Timer from '@im_livechat/legacy/models/timer';
|
||||
|
||||
import Class from 'web.Class';
|
||||
|
||||
/**
|
||||
* This class lists several timers that use a same callback and duration.
|
||||
*/
|
||||
const Timers = Class.extend({
|
||||
|
||||
/**
|
||||
* Instantiate a new list of timers
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {integer} params.duration duration of the underlying timers from
|
||||
* start to timeout, in milli-seconds.
|
||||
* @param {function} params.onTimeout a function to call back for underlying
|
||||
* timers on timeout.
|
||||
*/
|
||||
init(params) {
|
||||
this._duration = params.duration;
|
||||
this._timeoutCallback = params.onTimeout;
|
||||
this._timers = {};
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a timer with ID `timerID` to start.
|
||||
*
|
||||
* - an already registered timer with this ID is reset.
|
||||
* - (optional) can provide a list of arguments that is passed to the
|
||||
* function callback when timer times out.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Array} [params.timeoutCallbackArguments]
|
||||
* @param {integer} params.timerID
|
||||
*/
|
||||
registerTimer(params) {
|
||||
const timerID = params.timerID;
|
||||
if (this._timers[timerID]) {
|
||||
this._timers[timerID].clear();
|
||||
}
|
||||
const timerParams = {
|
||||
duration: this._duration,
|
||||
onTimeout: this._timeoutCallback,
|
||||
};
|
||||
if ('timeoutCallbackArguments' in params) {
|
||||
timerParams.onTimeout = this._timeoutCallback.bind.apply(
|
||||
this._timeoutCallback,
|
||||
[null, ...params.timeoutCallbackArguments]
|
||||
);
|
||||
} else {
|
||||
timerParams.onTimeout = this._timeoutCallback;
|
||||
}
|
||||
this._timers[timerID] = new Timer(timerParams);
|
||||
this._timers[timerID].start();
|
||||
},
|
||||
/**
|
||||
* Unregister a timer with ID `timerID`. The unregistered timer is aborted
|
||||
* and will not time out.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {integer} params.timerID
|
||||
*/
|
||||
unregisterTimer(params) {
|
||||
const timerID = params.timerID;
|
||||
if (this._timers[timerID]) {
|
||||
this._timers[timerID].clear();
|
||||
delete this._timers[timerID];
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default Timers;
|
||||
|
|
@ -0,0 +1,715 @@
|
|||
//-------------------------------------
|
||||
// legacy: mail/public_livechat_window.scss
|
||||
//-------------------------------------
|
||||
|
||||
$o-mail-thread-window-zindex: $zindex-modal + 1 !default;
|
||||
|
||||
.o_thread_window {
|
||||
direction: ltr;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
position: fixed;
|
||||
width: $o-mail-thread-window-width;
|
||||
max-width: 100%;
|
||||
height: 460px;
|
||||
max-height: 100%;
|
||||
font-size: 12px;
|
||||
background-color: $o-mail-thread-window-bg;
|
||||
border-radius: 6px 6px 0 0;
|
||||
z-index: $o-mail-thread-window-zindex;
|
||||
box-shadow: -5px -5px 10px rgba(black, 0.18);
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
width: 100%;
|
||||
height: 100%!important;
|
||||
box-shadow: none;
|
||||
|
||||
.o_frontend_to_backend_nav:not(.d-none) ~ & .o_thread_window_header {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
&.o_folded {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_thread_window_header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
color: white;
|
||||
padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap;
|
||||
border-radius: 3px 3px 0 0;
|
||||
border-bottom: 1px solid map-get($grays, '300');
|
||||
background-color: $o-brand-odoo;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
align-items: center;
|
||||
height: $o-mail-chat-header-height;
|
||||
border-radius: 0px;
|
||||
.o_thread_window_title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.o_thread_window_close {
|
||||
$o-close-font-size: 17px;
|
||||
padding: (($o-mail-chat-header-height - $o-close-font-size) / 2);
|
||||
font-size: $o-close-font-size;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.o_thread_window_avatar {
|
||||
margin: -6px 6px -6px 0;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
span {
|
||||
bottom: -4px;
|
||||
right: -2px;
|
||||
position: absolute;
|
||||
|
||||
.fa-circle-o {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_window_title {
|
||||
flex: 1 1 auto;
|
||||
@include o-text-overflow;
|
||||
|
||||
.o_mail_thread_typing_icon {
|
||||
padding-left: 2px;
|
||||
|
||||
.o_mail_thread_typing_icon_dot {
|
||||
background: map-get($grays, '300');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_window_buttons {
|
||||
flex: 0 0 auto;
|
||||
.o_thread_window_close {
|
||||
color: white;
|
||||
padding: 0px 3px;
|
||||
margin-left: 5px;
|
||||
@include o-hover-opacity(0.7, 1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.o_mail_thread {
|
||||
flex: 1 1 100%;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
|
||||
|
||||
.o_thread_date_separator {
|
||||
margin: 0px 0px 15px 0px;
|
||||
.o_thread_date {
|
||||
background-color: $o-mail-thread-window-bg;
|
||||
}
|
||||
}
|
||||
.o_thread_message {
|
||||
padding: 4px 5px;
|
||||
.o_thread_message_sidebar {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.o_attachment {
|
||||
@include media-breakpoint-up(md) {
|
||||
width: percentage(1/3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_composer input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_window_dropdown {
|
||||
width: auto;
|
||||
height: 28px;
|
||||
color: white;
|
||||
background-color: map-get($grays, '900');
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_thread_window_header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.o_thread_window_dropdown_toggler {
|
||||
padding: 5px;
|
||||
|
||||
.o_total_unread_counter {
|
||||
@include o-position-absolute(-10px, 0, auto, auto);
|
||||
background-color: $o-brand-primary;
|
||||
padding: 0 2px;
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
&.show .o_thread_window_dropdown_toggler .o_total_unread_counter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> ul {
|
||||
max-width: $o-mail-thread-window-width;
|
||||
padding: 0;
|
||||
|
||||
> li.o_thread_window_header {
|
||||
font-size: 12px;
|
||||
padding: 3px 5px;
|
||||
&~li.o_thread_window_header {
|
||||
border-top: 1px solid white;
|
||||
}
|
||||
&:hover {
|
||||
background-color: darken($o-brand-odoo, 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_ui_blocked .o_thread_window {
|
||||
// We cannot put the z-index of thread windows directly to be greater than
|
||||
// blockUI's as ui-autocomplete dropdowns (which are below blockUI) would
|
||||
// appear under the thread windows (and ui-autocomplete is used to choose the
|
||||
// person you want to chat with). So we only raise the z-index value when
|
||||
// the ui is really blocked (in that case, the ui-autocomplete dropdowns
|
||||
// will disappear under the thread windows but this is not really an issue as
|
||||
// there should not be any at that time).
|
||||
z-index: 1101; // blockUI's z-index is 1100
|
||||
}
|
||||
|
||||
//-------------------------------------
|
||||
// legacy: mail/thread.scss
|
||||
//-------------------------------------
|
||||
|
||||
|
||||
.o_mail_thread_loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.o_mail_thread_loading_icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.o_mail_thread, .o_mail_activity {
|
||||
.o_thread_show_more {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_mail_thread_content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.o_thread_bottom_free_space {
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.o_thread_date_separator {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 30px;
|
||||
@include media-breakpoint-down(md) {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
border-bottom: 1px solid map-get($grays, '400');
|
||||
text-align: center;
|
||||
|
||||
.o_thread_date {
|
||||
position: relative;
|
||||
top: 10px;
|
||||
margin: 0 auto;
|
||||
padding: 0 10px;
|
||||
font-weight: bold;
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_new_messages_separator {
|
||||
margin-bottom: 15px;
|
||||
border-bottom: solid lighten($o-brand-odoo, 15%) 1px;
|
||||
text-align: right;
|
||||
.o_thread_separator_label {
|
||||
position: relative;
|
||||
top: 8px;
|
||||
padding: 0 10px;
|
||||
background: white;
|
||||
color: lighten($o-brand-odoo, 15%);
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_message {
|
||||
display: flex;
|
||||
padding: 4px $o-horizontal-padding;
|
||||
margin-bottom: 0px;
|
||||
|
||||
&.o_mail_not_discussion {
|
||||
background-color: rgba(map-get($grays, '300'), 0.5);
|
||||
border-bottom: 1px solid map-get($grays, '400');
|
||||
}
|
||||
|
||||
.o_thread_message_sidebar {
|
||||
flex: 0 0 $o-mail-thread-avatar-size;
|
||||
margin-right: 10px;
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
font-size: smaller;
|
||||
.o_thread_message_sidebar_image {
|
||||
position: relative;
|
||||
height: $o-mail-thread-avatar-size;
|
||||
|
||||
.o_updatable_im_status {
|
||||
width: $o-mail-thread-avatar-size;
|
||||
}
|
||||
.o_mail_user_status {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
&.fa-circle-o {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
margin-top: 4px;
|
||||
font-size: x-small;
|
||||
}
|
||||
|
||||
.o_thread_message_avatar {
|
||||
width: $o-mail-thread-avatar-size;
|
||||
height: $o-mail-thread-avatar-size;
|
||||
object-fit: cover;
|
||||
}
|
||||
.o_thread_message_side_date {
|
||||
display: none;
|
||||
margin-left: -5px;
|
||||
}
|
||||
.o_thread_message_star {
|
||||
display: none;
|
||||
margin-right: -5px;
|
||||
}
|
||||
|
||||
.o_thread_message_side_date {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.o_thread_icon {
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
&.fa-star {
|
||||
opacity: $o-mail-thread-icon-opacity;
|
||||
color: gold;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover, &.o_thread_selected_message {
|
||||
.o_thread_message_side_date {
|
||||
display: inline-block;
|
||||
opacity: $o-mail-thread-side-date-opacity;
|
||||
}
|
||||
.o_thread_icon {
|
||||
display: inline-block;
|
||||
opacity: $o-mail-thread-icon-opacity;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_mail_redirect {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_thread_message_core {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
> pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.o_mail_note_title {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.o_mail_subject {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.o_mail_notification {
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
[summary~=o_mail_notification] { // name conflicts with channel notifications, but is odoo notification buttons to hide in chatter if present
|
||||
display: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 9px; // Required by the old design to override a general rule on p's
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
a {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
}
|
||||
:not(.o_image_box) > img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.o_mail_body_long {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_mail_info {
|
||||
margin-bottom: 2px;
|
||||
|
||||
strong {
|
||||
color: $headings-color;
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply, .o_thread_message_notification {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.o_thread_message_notification {
|
||||
color: grey;
|
||||
&.o_thread_message_notification_error {
|
||||
color: red;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.o_attachments_list, .o_attachments_previews {
|
||||
&:last-child {
|
||||
margin-bottom: $grid-gutter-width;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_thread_message .o_thread_message_core .o_mail_read_more {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.o_web_client.o_touch_device {
|
||||
.o_mail_thread .o_thread_icon {
|
||||
opacity: $o-mail-thread-icon-opacity;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Thread typing icon: shared between discuss and chat windows
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
.o_mail_thread_typing_icon {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
.o_mail_thread_typing_icon_dot {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: map-get($grays, '800');
|
||||
animation: o_mail_thread_typing_icon_dot 1.5s linear infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: -1.35s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: -1.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes o_mail_thread_typing_icon_dot {
|
||||
0%, 40%, 100% {
|
||||
transform: initial;
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
//-------------------------------------
|
||||
// legacy: im_livechat/im_livechat.scss
|
||||
//-------------------------------------
|
||||
|
||||
.o_livechat_button {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin-right: 12px;
|
||||
min-width: 100px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
background-color: rgba(60, 60, 60, 0.6);
|
||||
font-family: 'Lucida Grande', 'Lucida Sans Unicode', Arial, Verdana, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
padding: 10px;
|
||||
color: white;
|
||||
text-shadow: rgb(59, 76, 88) 1px 1px 0px;
|
||||
border: 1px solid rgb(80, 80, 80);
|
||||
border-bottom: 0px;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.o_thread_window {
|
||||
z-index: $zindex-modal - 9; // to go over the navbar
|
||||
.o_thread_date_separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
color: #FFFFFF;
|
||||
background-color: #30908e;
|
||||
border-color: #2d8685;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.0625rem 0.3125rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.o_livechat_rating {
|
||||
/* Livechat Rating : feedback smiley */
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 15px;
|
||||
font-size: 14px;
|
||||
|
||||
.o_livechat_email {
|
||||
font-size: 12px;
|
||||
> div {
|
||||
display: flex;
|
||||
padding: 5px 0;
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: calc(1.5em + 0.75rem + 2px);
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
background-color: #FFFFFF;
|
||||
background-clip: padding-box;
|
||||
border: 1px solid #CED4DA;
|
||||
}
|
||||
button {
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_no_feedback {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.o_livechat_rating_box {
|
||||
margin: 40px 0 30px 0;
|
||||
}
|
||||
|
||||
.o_livechat_rating_choices {
|
||||
margin: 10px 0;
|
||||
|
||||
> img {
|
||||
width: 65px;
|
||||
opacity: 0.60;
|
||||
cursor: pointer;
|
||||
margin: 10px;
|
||||
&:hover, &.selected {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* feedback reason */
|
||||
.o_livechat_rating_reason {
|
||||
margin: 10px 0px 25px 0px;
|
||||
display: none; /* hidden by default */
|
||||
|
||||
> textarea {
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_rating_reason_button > button {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.o_composer_text_field {
|
||||
line-height: 1.3em;
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_operator_avatar {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.o_livechat_no_rating {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.o_PublicLivechatMessage {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
&.o-isVisitorTheAuthor {
|
||||
flex-direction: row-reverse !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o_PublicLivechatMessage_content {
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.o_PublicLivechatMessage_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.o-isVisitorTheAuthor {
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o_PublicLivechatMessage_headerAuthor {
|
||||
margin-right: map-get($spacers, 2);
|
||||
}
|
||||
|
||||
.o_PublicLivechatMessage_headerDate {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
.o_PublicLivechatMessage_headerDatePrefix {
|
||||
opacity: 50%;
|
||||
margin-right: map-get($spacers, 1) / 2;
|
||||
}
|
||||
|
||||
.o_PublicLivechatMessage_bubbleWrap {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: flex-start;
|
||||
|
||||
&.o-isVisitorTheAuthor {
|
||||
justify-content: flex-end;
|
||||
margin-left: map-get($spacers, 4);
|
||||
}
|
||||
|
||||
&.o-isOperatorTheAuthor {
|
||||
margin-right: map-get($spacers, 4);
|
||||
}
|
||||
}
|
||||
|
||||
.o_PublicLivechatMessage_bubble {
|
||||
position: relative;
|
||||
|
||||
&.o-isContentNonEmpty {
|
||||
padding: map-get($spacers, 4) / 2;
|
||||
}
|
||||
|
||||
&.o-isVisitorTheAuthor {
|
||||
margin-left: map-get($spacers, 2);
|
||||
}
|
||||
|
||||
&.o-isOperatorTheAuthor {
|
||||
margin-right: map-get($spacers, 2);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.o_PublicLivechatMessage_background {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: opacity .5s ease-out;
|
||||
margin-right: map-get($spacers, 5);
|
||||
border-bottom-right-radius: 0.6rem !important;
|
||||
border-bottom-left-radius: 0.6rem !important;
|
||||
border: 1px solid #C9CCD2 !important;
|
||||
|
||||
&.o-isVisitorTheAuthor {
|
||||
border-bottom-left-radius: 0.6rem !important;
|
||||
border-top-left-radius: 0.6rem !important;
|
||||
opacity: 50%;
|
||||
border-color: #198754 !important;
|
||||
background-color: rgba(40, 167, 69, 0.5) !important;
|
||||
color: #000;
|
||||
opacity: 25%;
|
||||
}
|
||||
|
||||
&.o-isOperatorTheAuthor {
|
||||
border-top-right-radius: 0.6rem !important;
|
||||
border-bottom-right-radius: 0.6rem !important;
|
||||
border-color: #17a2b8 !important;
|
||||
background-color: rgba(23, 162, 184, 0.5) !important;
|
||||
color: #000;
|
||||
opacity: 15%
|
||||
}
|
||||
}
|
||||
|
||||
.o_PublicLivechatWindow_composer {
|
||||
padding: map-get($spacers, 2);
|
||||
outline: none;
|
||||
border: 0;
|
||||
border-top: 1px solid lightgray;
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import session from 'web.session';
|
||||
import time from 'web.time';
|
||||
import utils from 'web.utils';
|
||||
|
||||
import LivechatButton from '@im_livechat/legacy/widgets/livechat_button';
|
||||
|
||||
/**
|
||||
* Override of the LivechatButton to include chatbot capabilities.
|
||||
* Main changes / hooking points are:
|
||||
* - Show a custom welcome message that is in fact the first message of the chatbot script
|
||||
* - When messages are rendered, add click handles to chatbot options
|
||||
* - When the user picks an option or answers to the chatbot, display a "chatbot is typing..."
|
||||
* message for a couple seconds and then trigger the next step of the script
|
||||
*/
|
||||
LivechatButton.include({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private - LiveChat Overrides
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @override
|
||||
*/
|
||||
_prepareGetSessionParameters() {
|
||||
const parameters = this._super(...arguments);
|
||||
|
||||
const { publicLivechat } = this.messaging.publicLivechatGlobal;
|
||||
if (publicLivechat && publicLivechat.isTemporary && !publicLivechat.data.chatbot_script_id) {
|
||||
return parameters;
|
||||
} else if (publicLivechat && publicLivechat.data.chatbot_script_id) {
|
||||
parameters.chatbot_script_id = publicLivechat.data.chatbot_script_id;
|
||||
} else if (this.messaging.publicLivechatGlobal.chatbot.isActive) {
|
||||
parameters.chatbot_script_id = this.messaging.publicLivechatGlobal.chatbot.scriptId;
|
||||
}
|
||||
|
||||
return parameters;
|
||||
},
|
||||
/**
|
||||
* Small override to handle chatbot welcome message(s).
|
||||
* @private
|
||||
*/
|
||||
_sendWelcomeMessage() {
|
||||
if (this.messaging.publicLivechatGlobal.chatbot.isActive) {
|
||||
this._sendWelcomeChatbotMessage(
|
||||
0,
|
||||
this.messaging.publicLivechatGlobal.chatbot.state === 'welcome' ? 0 : this.messaging.publicLivechatGlobal.chatbot.messageDelay,
|
||||
);
|
||||
} else {
|
||||
this._super(...arguments);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The bot can say multiple messages in quick succession as "welcome messages".
|
||||
* (See chatbot.script#_get_welcome_steps() for more details).
|
||||
*
|
||||
* It is important that those messages are sent as "welcome messages", meaning manually added
|
||||
* within the template, without creating actual mail.messages in the mail.channel.
|
||||
*
|
||||
* Indeed, if the end-user never interacts with the bot, those empty mail.channels are deleted
|
||||
* by a garbage collector mechanism.
|
||||
*
|
||||
* About "welcomeMessageDelay":
|
||||
*
|
||||
* The first time we open the chat, we want to bot to slowly input those messages in one at a
|
||||
* time, with pauses during which the end-user sees ("The bot is typing...").
|
||||
*
|
||||
* However, if the user navigates within the website (meaning he has an opened mail.channel),
|
||||
* then we input all the welcome messages at once without pauses, to avoid having that annoying
|
||||
* slow effect on every page / refresh.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_sendWelcomeChatbotMessage(stepIndex, welcomeMessageDelay) {
|
||||
const chatbotStep = this.messaging.publicLivechatGlobal.chatbot.welcomeSteps[stepIndex];
|
||||
this.messaging.publicLivechatGlobal.chatbot.update({ currentStep: { data: chatbotStep } });
|
||||
|
||||
if (chatbotStep.chatbot_step_message) {
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.addMessage({
|
||||
id: '_welcome_' + stepIndex,
|
||||
is_discussion: true, // important for css style -> we only want white background for chatbot
|
||||
author: (
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.operator
|
||||
? {
|
||||
id: this.messaging.publicLivechatGlobal.publicLivechat.operator.id,
|
||||
name: this.messaging.publicLivechatGlobal.publicLivechat.operator.name,
|
||||
}
|
||||
: [['clear']]
|
||||
),
|
||||
body: utils.Markup(chatbotStep.chatbot_step_message),
|
||||
chatbot_script_step_id: chatbotStep.chatbot_script_step_id,
|
||||
chatbot_step_answers: chatbotStep.chatbot_step_answers,
|
||||
date: time.datetime_to_str(new Date()),
|
||||
model: "mail.channel",
|
||||
message_type: "comment",
|
||||
res_id: this.messaging.publicLivechatGlobal.publicLivechat.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (stepIndex + 1 < this.messaging.publicLivechatGlobal.chatbot.welcomeSteps.length) {
|
||||
if (welcomeMessageDelay !== 0) {
|
||||
this.messaging.publicLivechatGlobal.chatbot.setIsTyping(true);
|
||||
}
|
||||
|
||||
this.messaging.publicLivechatGlobal.chatbot.update({
|
||||
welcomeMessageTimeout: setTimeout(() => {
|
||||
if (!this.messaging.publicLivechatGlobal.chatWindow || !this.messaging.publicLivechatGlobal.chatWindow.exists()) {
|
||||
return;
|
||||
}
|
||||
this._sendWelcomeChatbotMessage(stepIndex + 1, welcomeMessageDelay);
|
||||
this.messaging.publicLivechatGlobal.chatWindow.renderMessages();
|
||||
}, welcomeMessageDelay),
|
||||
});
|
||||
} else {
|
||||
if (this.messaging.publicLivechatGlobal.chatbot.currentStep.data.chatbot_step_type === 'forward_operator') {
|
||||
// special case when the last welcome message is a forward to an operator
|
||||
// we need to save the welcome messages before continuing the script
|
||||
// indeed, if there are no operator available, the script will continue
|
||||
// with steps that are NOT included in the welcome messages
|
||||
// (hence why we need to have those welcome messages posted BEFORE that)
|
||||
this.messaging.publicLivechatGlobal.chatbot.postWelcomeMessages();
|
||||
}
|
||||
|
||||
// we are done posting welcome messages, let's start the actual script
|
||||
this.messaging.publicLivechatGlobal.chatbot.processStep();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Saves the selected chatbot.script.answer onto our chatbot.message.
|
||||
* Will update the state of the related message (in this.messaging.publicLivechatGlobal.messages) to set the selected option
|
||||
* as well which will in turn adapt the display to not show options anymore.
|
||||
*
|
||||
* This method also handles an optional redirection link placed on the chatbot.script.answer and
|
||||
* will make sure to properly save the selected choice before redirecting.
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
* @private
|
||||
*/
|
||||
async _onChatbotOptionClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const $target = $(ev.currentTarget);
|
||||
const stepId = $target.closest('ul').data('chatbotStepId');
|
||||
const selectedAnswer = $target.data('chatbotStepAnswerId');
|
||||
|
||||
const redirectLink = $target.data('chatbotStepRedirectLink');
|
||||
let isRedirecting = false;
|
||||
if (redirectLink && URL.canParse(redirectLink, window.location.href)) {
|
||||
const url = new URL(window.location.href);
|
||||
const nextURL = new URL(redirectLink, window.location.href);
|
||||
isRedirecting = url.pathname !== nextURL.pathname || url.origin !== nextURL.origin;
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.chatbot.update({ isRedirecting });
|
||||
|
||||
await this.messaging.publicLivechatGlobal.livechatButtonView.sendMessage({
|
||||
content: $target.text().trim(),
|
||||
});
|
||||
|
||||
let stepMessage = null;
|
||||
for (const message of this.messaging.publicLivechatGlobal.messages) {
|
||||
// we do NOT want to use a 'find' here because we want the LAST message that respects
|
||||
// this condition.
|
||||
// indeed, if you restart the script, you can have multiple messages with the same step id,
|
||||
// but here we only care about the very last one (the current step of the script)
|
||||
// reversing the this.messages variable seems like a bad idea because it could have
|
||||
// bad implications for other flows (as the reverse is in-place, not in a copy)
|
||||
if (message.widget.getChatbotStepId() === stepId) {
|
||||
stepMessage = message;
|
||||
}
|
||||
}
|
||||
const messageId = stepMessage.id;
|
||||
stepMessage.widget.setChatbotStepAnswerId(selectedAnswer);
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep.data.chatbot_selected_answer_id = selectedAnswer;
|
||||
this.messaging.publicLivechatGlobal.chatWindow.renderMessages();
|
||||
this.messaging.publicLivechatGlobal.chatbot.saveSession();
|
||||
|
||||
const saveAnswerPromise = session.rpc('/chatbot/answer/save', {
|
||||
channel_uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
|
||||
message_id: messageId,
|
||||
selected_answer_id: selectedAnswer,
|
||||
});
|
||||
|
||||
if (redirectLink) {
|
||||
await saveAnswerPromise; // ensure answer is saved before redirecting
|
||||
window.location = redirectLink;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default LivechatButton;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
.o_thread_window {
|
||||
.o_livechat_chatbot_main_restart {
|
||||
color: white;
|
||||
@include o-hover-opacity(0.7, 1);
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
// copied from 'o_thread_window_close'
|
||||
$o-close-font-size: 17px;
|
||||
padding: (($o-mail-chat-header-height - $o-close-font-size) / 2);
|
||||
font-size: $o-close-font-size;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.o_livechat_chatbot_options {
|
||||
list-style: none;
|
||||
margin-block-start: 0px;
|
||||
margin-block-end: 0px;
|
||||
padding-inline-start: 0px;
|
||||
|
||||
li {
|
||||
user-select: none;
|
||||
&:not(.disabled) {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background-color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<!--
|
||||
=============================================
|
||||
Chatbot overrides to base livechat templates
|
||||
=============================================
|
||||
-->
|
||||
|
||||
<!-- Extend the base livechat window to include a div allowing to restart the script at the end -->
|
||||
<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 fst-italic text-center border" style="display: none;">
|
||||
<span>Conversation ended...</span>
|
||||
<a href="#" class="o_livechat_chatbot_restart">Restart</a>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- 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() && widget.messaging.publicLivechatGlobal.chatbot.data && widget.messaging.publicLivechatGlobal.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.messaging.publicLivechatGlobal.chatbot.data && widget.messaging.publicLivechatGlobal.chatbot.hasRestartButton"
|
||||
href="#" class="o_livechat_chatbot_main_restart fa fa-refresh"
|
||||
title="Restart Conversation"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Extend the base livechat message to allow displaying options for the user to select -->
|
||||
<t t-extend="im_livechat.legacy.mail.widget.Thread.Message">
|
||||
<t t-jquery='div.o_thread_message_content' t-operation="append">
|
||||
<ul t-if="message.getChatbotStepAnswers() && message.getChatbotStepAnswers().length !== 0 && !message.getChatbotStepAnswerId()"
|
||||
class="o_livechat_chatbot_options"
|
||||
t-att-data-message-id="message.getID()"
|
||||
t-att-data-chatbot-step-id="message.getChatbotStepId()">
|
||||
<t t-foreach="message.getChatbotStepAnswers()" t-as="stepAnswer">
|
||||
<li t-att-data-chatbot-step-answer-id="stepAnswer.id"
|
||||
t-att-data-chatbot-step-redirect-link="stepAnswer.redirect_link"
|
||||
class="o_livechat_chatbot_stepAnswer d-inline-block border border-primary rounded p-2 me-3 mb-1 fw-bold">
|
||||
<t t-out="stepAnswer.label"/>
|
||||
</li>
|
||||
<br/>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
=============================================
|
||||
Tooling / Utils
|
||||
=============================================
|
||||
-->
|
||||
|
||||
<!--
|
||||
This small template simulates a fake message from the bot.
|
||||
The goal is to have something like:
|
||||
[image] The bot
|
||||
. . .
|
||||
|
||||
With a small animation on the dots to make them bounce.
|
||||
This fake message is then removed when the chat window is refreshed with the real message.
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.chatbot.is_typing_message">
|
||||
<div class="o_thread_message">
|
||||
<div class="o_thread_message_sidebar">
|
||||
<img alt="chatbot_image" t-att-src="chatbotImageSrc"
|
||||
class="o_thread_message_avatar rounded-circle"/>
|
||||
</div>
|
||||
<div class="o_thread_message_core">
|
||||
<p class="o_mail_info text-muted">
|
||||
<strong class="o_thread_author" t-out="chatbotName"/>
|
||||
</p>
|
||||
<div class="o_thread_message_content o_PublicLivechatMessage_content">
|
||||
<img class="o_livechat_chatbot_typing"
|
||||
t-att-src="chatbotIsTypingImageSrc"
|
||||
width="30" alt="is typing"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import concurrency from 'web.concurrency';
|
||||
import core from 'web.core';
|
||||
import session from 'web.session';
|
||||
import utils from 'web.utils';
|
||||
import Widget from 'web.Widget';
|
||||
|
||||
const _t = core._t;
|
||||
/*
|
||||
* Rating for Livechat
|
||||
*
|
||||
* This widget displays the 3 rating smileys, and a textarea to add a reason
|
||||
* (only for red smiley), and sends the user feedback to the server.
|
||||
*/
|
||||
const Feedback = Widget.extend({
|
||||
template: 'im_livechat.legacy.im_livechat.FeedBack',
|
||||
|
||||
events: {
|
||||
'click .o_livechat_rating_choices img': '_onClickSmiley',
|
||||
'click .o_livechat_no_feedback span': '_onClickNoFeedback',
|
||||
'click .o_rating_submit_button': '_onClickSend',
|
||||
'click .o_email_chat_button': '_onEmailChat',
|
||||
'click .o_livechat_email_error .alert-link': '_onTryAgain',
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {?} parent
|
||||
* @param {Messaging} messaging
|
||||
* @param {@im_livechat/legacy/models/public_livechat} livechat
|
||||
*/
|
||||
init(parent, messaging, livechat) {
|
||||
this._super(parent);
|
||||
this.messaging = messaging;
|
||||
this.server_origin = session.origin;
|
||||
this.rating = undefined;
|
||||
this.dp = new concurrency.DropPrevious();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} options
|
||||
*/
|
||||
_sendFeedback(reason) {
|
||||
const args = {
|
||||
uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
|
||||
rate: this.rating,
|
||||
reason,
|
||||
};
|
||||
this.dp.add(session.rpc('/im_livechat/feedback', args)).then((response) => {
|
||||
const emoji = this.messaging.publicLivechatGlobal.RATING_TO_EMOJI[this.rating] || "??";
|
||||
let content;
|
||||
if (!reason) {
|
||||
content = utils.sprintf(_t("Rating: %s"), emoji);
|
||||
}
|
||||
else {
|
||||
content = "Rating reason: \n" + reason;
|
||||
}
|
||||
this.trigger('send_message', { content, isFeedback: true });
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_showThanksMessage() {
|
||||
this.$('.o_livechat_rating_box').empty().append($('<div />', {
|
||||
text: _t('Thank you for your feedback'),
|
||||
class: 'text-muted'
|
||||
}));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickNoFeedback() {
|
||||
this.trigger('feedback_sent'); // will close the chat
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickSend() {
|
||||
this.$('.o_livechat_rating_reason').hide();
|
||||
this._showThanksMessage();
|
||||
if (_.isNumber(this.rating)) {
|
||||
this._sendFeedback(this.$('textarea').val());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickSmiley(ev) {
|
||||
this.rating = parseInt($(ev.currentTarget).data('value'));
|
||||
this.$('.o_livechat_rating_choices img').removeClass('selected');
|
||||
this.$('.o_livechat_rating_choices img[data-value="' + this.rating + '"]').addClass('selected');
|
||||
|
||||
// only display textearea if bad smiley selected
|
||||
if (this.rating !== 5) {
|
||||
this._sendFeedback();
|
||||
this.$('.o_livechat_rating_reason').show();
|
||||
} else {
|
||||
this.$('.o_livechat_rating_reason').hide();
|
||||
this._showThanksMessage();
|
||||
this._sendFeedback();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onEmailChat() {
|
||||
const $email = this.$('#o_email');
|
||||
|
||||
if (utils.is_email($email.val())) {
|
||||
$email.removeAttr('title').removeClass('is-invalid').prop('disabled', true);
|
||||
this.$('.o_email_chat_button').prop('disabled', true);
|
||||
this._rpc({
|
||||
route: '/im_livechat/email_livechat_transcript',
|
||||
params: {
|
||||
uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
|
||||
email: $email.val(),
|
||||
}
|
||||
}).then(() => {
|
||||
this.$('o_livechat_email_sentLabel').show();
|
||||
this.$('o_livechat_email_receiveCopyLabel').hide();
|
||||
this.$('o_livechat_email_receiveCopyForm').hide();
|
||||
}).guardedCatch(() => {
|
||||
this.$('.o_livechat_email').hide();
|
||||
this.$('.o_livechat_email_error').show();
|
||||
});
|
||||
} else {
|
||||
$email.addClass('is-invalid').prop('title', _t('Invalid email address'));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onTryAgain() {
|
||||
this.$('#o_email').prop('disabled', false);
|
||||
this.$('.o_email_chat_button').prop('disabled', false);
|
||||
this.$('.o_livechat_email_error').hide();
|
||||
this.$('.o_livechat_email').show();
|
||||
},
|
||||
});
|
||||
|
||||
export default Feedback;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="im_livechat.legacy.im_livechat.FeedBack">
|
||||
<div class="o_livechat_rating text-center">
|
||||
<div class="o_livechat_rating_box">
|
||||
<div class="o_livechat_rating_feedback_text">
|
||||
Did we correctly answer your question ?
|
||||
</div>
|
||||
<div class="o_livechat_rating_choices">
|
||||
<img t-att-src="widget.server_origin + '/rating/static/src/img/rating_5.png'" alt="Good" data-value="5"/>
|
||||
<img t-att-src="widget.server_origin + '/rating/static/src/img/rating_3.png'" alt="OK" data-value="3"/>
|
||||
<img t-att-src="widget.server_origin + '/rating/static/src/img/rating_1.png'" alt="Bad" data-value="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_livechat_rating_reason">
|
||||
<textarea id="reason" placeholder="Explain your note"></textarea>
|
||||
<div class="o_livechat_rating_reason_button">
|
||||
<button type="button" class="btn btn-primary btn-sm o_rating_submit_button">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_livechat_email text-start">
|
||||
<strong class="o_livechat_email_sentLabel" style="display: none;">Conversation Sent</strong>
|
||||
<span class="o_livechat_email_receiveCopyLabel text-muted">Receive a copy of this conversation</span>
|
||||
<div class="o_livechat_email_receiveCopyForm input-group">
|
||||
<input id="o_email" type="text" class="form-control" placeholder="mail@example.com"/>
|
||||
<button type="button" class="o_email_chat_button btn btn-primary rounded-0">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-danger px-0 o_livechat_email_error" style="display: none;" role="alert">
|
||||
Oops! Something went wrong.<br />Please check your internet connection.<br />
|
||||
<a href="#" class="alert-link">Try again</a>
|
||||
</div>
|
||||
<div class="o_livechat_no_feedback text-muted">
|
||||
<span>Close conversation</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import time from 'web.time';
|
||||
import {getCookie} from 'web.utils.cookies';
|
||||
import Widget from 'web.Widget';
|
||||
|
||||
const LivechatButton = Widget.extend({
|
||||
className: 'openerp o_livechat_button d-print-none',
|
||||
events: {
|
||||
'click': '_onClick'
|
||||
},
|
||||
init(parent, messaging) {
|
||||
this._super(parent);
|
||||
this.messaging = messaging;
|
||||
},
|
||||
start() {
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.start();
|
||||
return this._super();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Will try to get a previous operator for this visitor.
|
||||
* If the visitor already had visitor A, it's better for his user experience
|
||||
* to get operator A again.
|
||||
*
|
||||
* The information is stored in the 'im_livechat_previous_operator_pid' cookie.
|
||||
*
|
||||
* @private
|
||||
* @return {integer} operator_id.partner_id.id if the cookie is set
|
||||
*/
|
||||
_get_previous_operator_id() {
|
||||
const cookie = getCookie('im_livechat_previous_operator_pid');
|
||||
if (cookie) {
|
||||
return cookie;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_prepareGetSessionParameters() {
|
||||
return {
|
||||
channel_id: this.messaging.publicLivechatGlobal.channelId,
|
||||
anonymous_name: this.messaging.publicLivechatGlobal.livechatButtonView.defaultUsername,
|
||||
previous_operator_id: this._get_previous_operator_id(),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_sendWelcomeMessage() {
|
||||
if (this.messaging.publicLivechatGlobal.livechatButtonView.defaultMessage) {
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.addMessage({
|
||||
id: '_welcome',
|
||||
author: {
|
||||
id: this.messaging.publicLivechatGlobal.publicLivechat.operator.id,
|
||||
name: this.messaging.publicLivechatGlobal.publicLivechat.operator.name,
|
||||
},
|
||||
body: this.messaging.publicLivechatGlobal.livechatButtonView.defaultMessage,
|
||||
date: time.datetime_to_str(new Date()),
|
||||
model: "mail.channel",
|
||||
res_id: this.messaging.publicLivechatGlobal.publicLivechat.id,
|
||||
}, { prepend: true });
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClick() {
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.openChat();
|
||||
},
|
||||
});
|
||||
|
||||
export default LivechatButton;
|
||||
|
|
@ -0,0 +1,451 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import * as mailUtils from '@mail/js/utils';
|
||||
|
||||
import core from 'web.core';
|
||||
import time from 'web.time';
|
||||
import Widget from 'web.Widget';
|
||||
|
||||
const QWeb = core.qweb;
|
||||
const _lt = core._lt;
|
||||
|
||||
const ORDER = {
|
||||
ASC: 1, // visually, ascending order of message IDs (from top to bottom)
|
||||
DESC: -1, // visually, descending order of message IDs (from top to bottom)
|
||||
};
|
||||
|
||||
const READ_MORE = _lt("read more");
|
||||
const READ_LESS = _lt("read less");
|
||||
|
||||
/**
|
||||
* This is a generic widget to render a thread.
|
||||
* Any thread that extends mail.model.AbstractThread can be used with this
|
||||
* widget.
|
||||
*/
|
||||
const PublicLivechatView = Widget.extend({
|
||||
className: 'o_mail_thread',
|
||||
|
||||
events: {
|
||||
'click a': '_onClickRedirect',
|
||||
'click img': '_onClickRedirect',
|
||||
'click strong': '_onClickRedirect',
|
||||
'click .o_thread_show_more': '_onClickShowMore',
|
||||
'click .o_thread_message_needaction': '_onClickMessageNeedaction',
|
||||
'click .o_thread_message_star': '_onClickMessageStar',
|
||||
'click .o_thread_message_reply': '_onClickMessageReply',
|
||||
'click .oe_mail_expand': '_onClickMailExpand',
|
||||
'click .o_thread_message': '_onClickMessage',
|
||||
'click': '_onClick',
|
||||
'click .o_thread_message_notification_error': '_onClickMessageNotificationError',
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {widget} parent
|
||||
* @param {Messaging} messaging
|
||||
* @param {Object} options
|
||||
*/
|
||||
init(parent, messaging, options) {
|
||||
this._super(...arguments);
|
||||
this.messaging = messaging;
|
||||
// options when the thread is enabled (e.g. can send message,
|
||||
// interact on messages, etc.)
|
||||
this._enabledOptions = _.defaults(options || {}, {
|
||||
displayOrder: ORDER.ASC,
|
||||
displayMarkAsRead: true,
|
||||
displayDocumentLinks: true,
|
||||
displayAvatars: true,
|
||||
squashCloseMessages: true,
|
||||
loadMoreOnScroll: false,
|
||||
});
|
||||
this._selectedMessageID = null;
|
||||
this._currentThreadID = null;
|
||||
},
|
||||
/**
|
||||
* The message mail popover may still be shown at this moment. If we do not
|
||||
* remove it, it stays visible on the page until a page reload.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
destroy() {
|
||||
clearInterval(this._updateTimestampsInterval);
|
||||
this._super();
|
||||
},
|
||||
/**
|
||||
* @param {Object} [options]
|
||||
* @param {integer} [options.displayOrder=ORDER.ASC] order of displaying
|
||||
* messages in the thread:
|
||||
* - ORDER.ASC: last message is at the bottom of the thread
|
||||
* - ORDER.DESC: last message is at the top of the thread
|
||||
* @param {boolean} [options.displayLoadMore]
|
||||
* @param {Array} [options.domain=[]] the domain for the messages in the
|
||||
* thread.
|
||||
* @param {boolean} [options.scrollToBottom=false]
|
||||
* @param {boolean} [options.squashCloseMessages]
|
||||
*/
|
||||
render(options) {
|
||||
let shouldScrollToBottomAfterRendering = false;
|
||||
if (this._currentThreadID === this.messaging.publicLivechatGlobal.publicLivechat.id && this.isAtBottom()) {
|
||||
shouldScrollToBottomAfterRendering = true;
|
||||
}
|
||||
this._currentThreadID = this.messaging.publicLivechatGlobal.publicLivechat.id;
|
||||
|
||||
// copy so that reverse do not alter order in the thread object
|
||||
const messages = _.clone(this.messaging.publicLivechatGlobal.publicLivechat.widget.getMessages());
|
||||
|
||||
const modeOptions = this._enabledOptions;
|
||||
|
||||
options = Object.assign({}, modeOptions, options, {
|
||||
selectedMessageID: this._selectedMessageID,
|
||||
});
|
||||
|
||||
// dict where key is message ID, and value is whether it should display
|
||||
// the author of message or not visually
|
||||
const displayAuthorMessages = {};
|
||||
|
||||
// Hide avatar and info of a message if that message and the previous
|
||||
// one are both comments wrote by the same author at the same minute
|
||||
// and in the same document (users can now post message in documents
|
||||
// directly from a channel that follows it)
|
||||
let prevMessage;
|
||||
for (let message of messages) {
|
||||
if (
|
||||
// is first message of thread
|
||||
!prevMessage ||
|
||||
// more than 1 min. elasped
|
||||
(Math.abs(message.getDate().diff(prevMessage.getDate())) > 60000) ||
|
||||
prevMessage.getType() !== 'comment' ||
|
||||
message.getType() !== 'comment' ||
|
||||
// from a different author
|
||||
prevMessage.getAuthorID() !== message.getAuthorID()
|
||||
) {
|
||||
displayAuthorMessages[message.getID()] = true;
|
||||
} else {
|
||||
displayAuthorMessages[message.getID()] = !options.squashCloseMessages;
|
||||
}
|
||||
prevMessage = message;
|
||||
}
|
||||
|
||||
if (modeOptions.displayOrder === ORDER.DESC) {
|
||||
messages.reverse();
|
||||
}
|
||||
|
||||
this.$el.html(QWeb.render('im_livechat.legacy.mail.widget.Thread', {
|
||||
displayAuthorMessages,
|
||||
options,
|
||||
ORDER,
|
||||
dateFormat: time.getLangDatetimeFormat(),
|
||||
widget: this,
|
||||
}));
|
||||
|
||||
for (let message of messages) {
|
||||
const $message = this.$('.o_thread_message[data-message-id="' + message.getID() + '"]');
|
||||
$message.find('.o_mail_timestamp').data('date', message.getDate());
|
||||
|
||||
this._insertReadMore($message);
|
||||
}
|
||||
|
||||
if (shouldScrollToBottomAfterRendering) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
if (!this._updateTimestampsInterval) {
|
||||
this.updateTimestampsInterval = setInterval(() => {
|
||||
this._updateTimestamps();
|
||||
}, 1000 * 60);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render thread widget when loading, i.e. when messaging is not yet ready.
|
||||
* @see /mail/init_messaging
|
||||
*/
|
||||
renderLoading() {
|
||||
this.$el.html(QWeb.render('im_livechat.legacy.mail.widget.ThreadLoading'));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
getScrolltop() {
|
||||
return this.$el.scrollTop();
|
||||
},
|
||||
/**
|
||||
* State whether the bottom of the thread is visible or not,
|
||||
* with a tolerance of 5 pixels
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isAtBottom() {
|
||||
const fullHeight = this.el.scrollHeight;
|
||||
const topHiddenHeight = this.$el.scrollTop();
|
||||
const visibleHeight = this.$el.outerHeight();
|
||||
const bottomHiddenHeight = fullHeight - topHiddenHeight - visibleHeight;
|
||||
return bottomHiddenHeight < 5;
|
||||
},
|
||||
/**
|
||||
* Scroll to the bottom of the thread
|
||||
*/
|
||||
scrollToBottom() {
|
||||
this.$el.scrollTop(this.el.scrollHeight);
|
||||
},
|
||||
/**
|
||||
* Scrolls the thread to a given message
|
||||
*
|
||||
* @param {integer} options.msgID the ID of the message to scroll to
|
||||
* @param {integer} [options.duration]
|
||||
* @param {boolean} [options.onlyIfNecessary]
|
||||
*/
|
||||
scrollToMessage(options) {
|
||||
const $target = this.$('.o_thread_message[data-message-id="' + options.messageID + '"]');
|
||||
if (options.onlyIfNecessary) {
|
||||
const delta = $target.parent().height() - $target.height();
|
||||
let offset = delta < 0 ?
|
||||
0 :
|
||||
delta - ($target.offset().top - $target.offsetParent().offset().top);
|
||||
offset = - Math.min(offset, 0);
|
||||
this.$el.scrollTo("+=" + offset + "px", options.duration);
|
||||
} else if ($target.length) {
|
||||
this.$el.scrollTo($target);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Scroll to the specific position in pixel
|
||||
*
|
||||
* If no position is provided, scroll to the bottom of the thread
|
||||
*
|
||||
* @param {integer} [position] distance from top to position in pixels.
|
||||
* If not provided, scroll to the bottom.
|
||||
*/
|
||||
scrollToPosition(position) {
|
||||
if (position) {
|
||||
this.$el.scrollTop(position);
|
||||
} else {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Unselect the selected message
|
||||
*/
|
||||
unselectMessage() {
|
||||
this.$('.o_thread_message').removeClass('o_thread_selected_message');
|
||||
this._selectedMessageID = null;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Modifies $element to add the 'read more/read less' functionality
|
||||
* All element nodes with 'data-o-mail-quote' attribute are concerned.
|
||||
* All text nodes after a ``#stopSpelling`` element are concerned.
|
||||
* Those text nodes need to be wrapped in a span (toggle functionality).
|
||||
* All consecutive elements are joined in one 'read more/read less'.
|
||||
*
|
||||
* @private
|
||||
* @param {jQuery} $element
|
||||
*/
|
||||
_insertReadMore($element) {
|
||||
|
||||
const groups = [];
|
||||
let readMoreNodes;
|
||||
|
||||
// nodeType 1: element_node
|
||||
// nodeType 3: text_node
|
||||
const $children = $element.contents()
|
||||
.filter(function () {
|
||||
return this.nodeType === 1 ||
|
||||
this.nodeType === 3 &&
|
||||
this.nodeValue.trim();
|
||||
});
|
||||
|
||||
for (let child of $children) {
|
||||
let $child = $(child);
|
||||
|
||||
// Hide Text nodes if "stopSpelling"
|
||||
if (
|
||||
child.nodeType === 3 &&
|
||||
$child.prevAll('[id*="stopSpelling"]').length > 0
|
||||
) {
|
||||
// Convert Text nodes to Element nodes
|
||||
$child = $('<span>', {
|
||||
text: child.textContent,
|
||||
'data-o-mail-quote': '1',
|
||||
});
|
||||
child.parentNode.replaceChild($child[0], child);
|
||||
}
|
||||
|
||||
// Create array for each 'read more' with nodes to toggle
|
||||
if (
|
||||
$child.attr('data-o-mail-quote') ||
|
||||
(
|
||||
$child.get(0).nodeName === 'BR' &&
|
||||
$child.prev('[data-o-mail-quote="1"]').length > 0
|
||||
)
|
||||
) {
|
||||
if (!readMoreNodes) {
|
||||
readMoreNodes = [];
|
||||
groups.push(readMoreNodes);
|
||||
}
|
||||
$child.hide();
|
||||
readMoreNodes.push($child);
|
||||
} else {
|
||||
readMoreNodes = undefined;
|
||||
this._insertReadMore($child);
|
||||
}
|
||||
}
|
||||
|
||||
for (let group of groups) {
|
||||
// Insert link just before the first node
|
||||
const $readMore = $('<a>', {
|
||||
class: 'o_mail_read_more',
|
||||
href: '#',
|
||||
text: READ_MORE,
|
||||
}).insertBefore(group[0]);
|
||||
|
||||
// Toggle All next nodes
|
||||
let isReadMore = true;
|
||||
$readMore.click(function (e) {
|
||||
e.preventDefault();
|
||||
isReadMore = !isReadMore;
|
||||
for (let $child of group) {
|
||||
$child.hide();
|
||||
$child.toggle(!isReadMore);
|
||||
}
|
||||
$readMore.text(isReadMore ? READ_MORE : READ_LESS);
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} options
|
||||
* @param {integer} [options.channelID]
|
||||
* @param {string} options.model
|
||||
* @param {integer} options.id
|
||||
*/
|
||||
_redirect: _.debounce(function (options) {
|
||||
if ('channelID' in options) {
|
||||
this.trigger('redirect_to_channel', options.channelID);
|
||||
} else {
|
||||
this.trigger('redirect', options.model, options.id);
|
||||
}
|
||||
}, 500, true),
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_updateTimestamps() {
|
||||
const isAtBottom = this.isAtBottom();
|
||||
this.$('.o_mail_timestamp').each(function () {
|
||||
const date = $(this).data('date');
|
||||
$(this).text(mailUtils.timeFromNow(date));
|
||||
});
|
||||
if (isAtBottom && !this.isAtBottom()) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClick() {
|
||||
if (this._selectedMessageID) {
|
||||
this.unselectMessage();
|
||||
this.trigger('unselect_message');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMailExpand(ev) {
|
||||
ev.preventDefault();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessage(ev) {
|
||||
$(ev.currentTarget).toggleClass('o_thread_selected_message');
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessageNeedaction(ev) {
|
||||
const messageID = $(ev.currentTarget).data('message-id');
|
||||
this.trigger('mark_as_read', messageID);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessageNotificationError(ev) {
|
||||
const messageID = $(ev.currentTarget).data('message-id');
|
||||
this.do_action('mail.mail_resend_message_action', {
|
||||
additional_context: {
|
||||
mail_message_to_resend: messageID,
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessageReply(ev) {
|
||||
this._selectedMessageID = $(ev.currentTarget).data('message-id');
|
||||
this.$('.o_thread_message').removeClass('o_thread_selected_message');
|
||||
this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]')
|
||||
.addClass('o_thread_selected_message');
|
||||
this.trigger('select_message', this._selectedMessageID);
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickMessageStar(ev) {
|
||||
const messageID = $(ev.currentTarget).data('message-id');
|
||||
this.trigger('toggle_star_status', messageID);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onClickRedirect(ev) {
|
||||
// ignore inherited branding
|
||||
if ($(ev.target).data('oe-field') !== undefined) {
|
||||
return;
|
||||
}
|
||||
const id = $(ev.target).data('oe-id');
|
||||
if (id) {
|
||||
ev.preventDefault();
|
||||
const model = $(ev.target).data('oe-model');
|
||||
let options;
|
||||
if (model && (model !== 'mail.channel')) {
|
||||
options = {
|
||||
model,
|
||||
id
|
||||
};
|
||||
} else {
|
||||
options = { channelID: id };
|
||||
}
|
||||
this._redirect(options);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickShowMore() {
|
||||
this.trigger('load_more_messages');
|
||||
},
|
||||
});
|
||||
|
||||
PublicLivechatView.ORDER = ORDER;
|
||||
|
||||
export default PublicLivechatView;
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {Object} options
|
||||
@param {boolean} [options.displayEmptyThread]
|
||||
@param {boolean} [options.displayNoMatchFound]
|
||||
@param {Array} [options.domain=[]] the domain to restrict messages on the thread.
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread">
|
||||
<t t-if="widget.messaging.publicLivechatGlobal.publicLivechat.widget.hasMessages()">
|
||||
<t t-call="im_livechat.legacy.mail.widget.Thread.Content"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Rendering of thread when messaging not yet ready -->
|
||||
<div t-name="im_livechat.legacy.mail.widget.ThreadLoading" class="o_mail_thread_loading">
|
||||
<i class="o_mail_thread_loading_icon fa fa-circle-o-notch fa-spin"/>
|
||||
<span>Please wait...</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {Object} options
|
||||
@param {integer} [options.displayOrder] 1 or -1 ascending (respectively, descending) order for
|
||||
the thread messages (from top to bottom)
|
||||
@param {Array} [options.domain=[]] the domain to restrict messages on the thread.
|
||||
@param {Object} ORDER
|
||||
@param {integer} ORDER.ASC=1 messages are ordered by ascending order of IDs, (from top to bottom)
|
||||
@param {integer} ORDER.DESC=-1 messages are ordered by descending IDs, (from top to bottom)
|
||||
|
||||
_____________ _____________
|
||||
| | | |
|
||||
| message 1 | | message n |
|
||||
| message 2 | | ... |
|
||||
| ... | | message 2 |
|
||||
| message n | | message 1 |
|
||||
|_____________| |_____________|
|
||||
|
||||
ORDER: ASC DESC
|
||||
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Content">
|
||||
<t t-set="messages" t-value="widget.messaging.publicLivechatGlobal.publicLivechat.widget.getMessages({ 'domain': options.domain || [] })"/>
|
||||
<t t-if="options.displayOrder === ORDER.ASC" t-call="im_livechat.legacy.mail.widget.Thread.Content.ASC"/>
|
||||
<t t-else="" t-call="im_livechat.legacy.mail.widget.Thread.Content.DESC"/>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {Object} options
|
||||
@param {boolean} [options.displayBottomThreadFreeSpace=false]
|
||||
@param {boolean} [options.displayLoadMore=false]
|
||||
|
||||
_____________
|
||||
| |
|
||||
| message 1 |
|
||||
| message 2 |
|
||||
| ... |
|
||||
| message n |
|
||||
|_____________|
|
||||
|
||||
ASC Order
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Content.ASC">
|
||||
<div class="o_mail_thread_content">
|
||||
<t t-if="options.displayLoadMore" t-call="im_livechat.legacy.mail.widget.Thread.LoadMore"/>
|
||||
<t t-call="im_livechat.legacy.mail.widget.Thread.Messages"/>
|
||||
<t t-if="options.displayBottomThreadFreeSpace">
|
||||
<div class="o_thread_bottom_free_space"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {Object} options
|
||||
@param {boolean} [options.displayLoadMore=false]
|
||||
@param {string|integer} [options.messagesSeparatorPosition] 'top' or
|
||||
message ID, the separator is placed just after this message.
|
||||
|
||||
_____________
|
||||
| |
|
||||
| message n |
|
||||
| ... |
|
||||
| message 2 |
|
||||
| message 1 |
|
||||
|_____________|
|
||||
|
||||
DESC Order
|
||||
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Content.DESC">
|
||||
<div class="o_mail_thread_content">
|
||||
<t t-if="options.messagesSeparatorPosition == 'top'" t-call="im_livechat.legacy.mail.MessagesSeparator"/>
|
||||
<t t-set="messages" t-value="messages.slice().reverse()"/>
|
||||
<t t-call="im_livechat.legacy.mail.widget.Thread.Messages"/>
|
||||
<t t-if="options.displayLoadMore" t-call="im_livechat.legacy.mail.widget.Thread.LoadMore"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractMessage[]} messages messages are ordered based
|
||||
on desired display order
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Messages">
|
||||
<t t-set="current_day" t-value="0"/>
|
||||
<t t-foreach="messages" t-as="message">
|
||||
<div t-if="current_day !== message.getDateDay()" class="o_thread_date_separator">
|
||||
<span class="o_thread_date">
|
||||
<t t-esc="message.getDateDay()"/>
|
||||
</span>
|
||||
<t t-set="current_day" t-value="message.getDateDay()"/>
|
||||
</div>
|
||||
|
||||
<t t-call="im_livechat.legacy.mail.widget.Thread.Message"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.AbstractThread} thread
|
||||
@param {string} dateFormat
|
||||
@param {Object} options
|
||||
@param {mail.model.AbstractMessage} message
|
||||
@param {Object} options
|
||||
@param {boolean} [options.displayAvatars]
|
||||
@param {boolean} [options.displayDocumentLinks]
|
||||
@param {boolean} [options.displayMarkAsRead]
|
||||
@param {boolean} [options.displaySubjectsOnMessages]
|
||||
@param {string|integer} [options.messagesSeparatorPosition] 'top' or
|
||||
message ID, the separator is placed just after this message.
|
||||
@param {integer} [options.selectedMessageID]
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.Message">
|
||||
<div t-if="!message.isEmpty()" t-att-class="'o_thread_message o_PublicLivechatMessage ' + (message.getID() === options.selectedMessageID ? 'o_thread_selected_message ' : ' ') + (message.isDiscussion() or message.isNotification() ? ' o_mail_discussion ' : ' o_mail_not_discussion ') + (message.isVisitorTheAuthor() ? 'o-isVisitorTheAuthor' : '')"
|
||||
t-att-data-message-id="message.getID()">
|
||||
<div t-if="options.displayAvatars" class="o_thread_message_sidebar">
|
||||
<t t-if="message.hasAuthor()">
|
||||
<div t-if="displayAuthorMessages[message.getID()]" class="o_thread_message_sidebar_image">
|
||||
<img
|
||||
alt=""
|
||||
t-att-src="message.getAvatarSource()"
|
||||
data-oe-model="res.partner"
|
||||
t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
|
||||
t-attf-class="o_thread_message_avatar rounded-circle #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}"/>
|
||||
<t t-call="im_livechat.legacy.mail.UserStatus">
|
||||
<t t-set="partnerID" t-value="message.getAuthorID()"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<img t-if="displayAuthorMessages[message.getID()]"
|
||||
alt=""
|
||||
t-att-src="message.getAvatarSource()"
|
||||
class="o_thread_message_avatar rounded-circle"/>
|
||||
</t>
|
||||
<span t-if="!displayAuthorMessages[message.getID()]" t-att-title="message.getDate().format(dateFormat)" class="o_thread_message_side_date">
|
||||
<t t-esc="message.getDate().format('hh:mm')"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_thread_message_core">
|
||||
<p t-if="displayAuthorMessages[message.getID()]" t-attf-class="o_mail_info text-muted o_PublicLivechatMessage_header {{ message.isVisitorTheAuthor() ? 'o-isVisitorTheAuthor' : '' }}">
|
||||
<t t-if="message.isNote()">
|
||||
Note by
|
||||
</t>
|
||||
<t t-if="!message.isVisitorTheAuthor()">
|
||||
<strong t-if="message.hasAuthor()"
|
||||
data-oe-model="res.partner" t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
|
||||
t-attf-class="o_thread_author o_PublicLivechatMessage_headerAuthor #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}">
|
||||
<t t-esc="message.getDisplayedAuthor()"/>
|
||||
</strong>
|
||||
<strong t-else="" class="o_thread_author o_PublicLivechatMessage_headerAuthor">
|
||||
<t t-esc="message.getDisplayedAuthor()"/>
|
||||
</strong>
|
||||
</t>
|
||||
|
||||
<span t-if="!message.isVisitorTheAuthor()" class="o_PublicLivechatMessage_headerDatePrefix">-</span> <small class="o_mail_timestamp o_PublicLivechatMessage_headerDate" t-att-title="message.getDate().format(dateFormat)"><t t-esc="message.getTimeElapsed()"/></small>
|
||||
<span t-attf-class="o_thread_icons">
|
||||
</span>
|
||||
</p>
|
||||
<div t-att-data-message-id="message.getID()" t-attf-class="o_PublicLivechatMessage_bubbleWrap {{ message.isVisitorTheAuthor() ? 'o-isVisitorTheAuthor' : '' }} {{ message.isOperatorTheAuthor() ? 'o-isOperatorTheAuthor' : '' }}">
|
||||
<div t-attf-class="o_PublicLivechatMessage_bubble {{ message.isVisitorTheAuthor() ? 'o-isVisitorTheAuthor' : '' }} {{ message.isOperatorTheAuthor() ? 'o-isOperatorTheAuthor' : '' }} {{ !message.isEmpty() ? 'o-isContentNonEmpty' : '' }}">
|
||||
<div t-attf-class="o_PublicLivechatMessage_background {{ message.isVisitorTheAuthor() ? 'o-isVisitorTheAuthor' : '' }} {{ message.isOperatorTheAuthor() ? 'o-isOperatorTheAuthor' : '' }}"/>
|
||||
<div class="o_thread_message_content o_PublicLivechatMessage_content">
|
||||
<t t-out="message.getBody()"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="options.messagesSeparatorPosition == message.getID()">
|
||||
<t t-call="im_livechat.legacy.mail.MessagesSeparator"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {Object} options
|
||||
@param {boolean} [options.loadMoreOnScroll]
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.widget.Thread.LoadMore">
|
||||
<div class="o_thread_show_more">
|
||||
<t t-if="options.loadMoreOnScroll">
|
||||
<span><i class="fa fa-circle-o-notch fa-spin" role="img" aria-label="Please wait" title="Please wait"/> Loading older messages... </span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<button class="btn btn-link">-------- Show older messages --------</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {string} status
|
||||
@param {integer|undefined} [partnerID]
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.UserStatus">
|
||||
<span t-att-class="partnerID ? 'o_updatable_im_status' : ''" t-att-data-partner-id="partnerID"/>
|
||||
</t>
|
||||
|
||||
<t t-name="im_livechat.legacy.mail.MessagesSeparator">
|
||||
<div class="o_thread_new_messages_separator">
|
||||
<span class="o_thread_separator_label">New messages</span>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import config from 'web.config';
|
||||
import { _t, qweb } from 'web.core';
|
||||
import Widget from 'web.Widget';
|
||||
|
||||
import {unaccent} from 'web.utils';
|
||||
import {setCookie} from 'web.utils.cookies';
|
||||
|
||||
/**
|
||||
* This is the widget that represent windows of livechat in the frontend.
|
||||
*
|
||||
* @see @im_livechat/legacy/widgets/public_livechat_window/public_livechat_window for more information
|
||||
*/
|
||||
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_header': '_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',
|
||||
},
|
||||
/**
|
||||
* @param {Widget} parent
|
||||
* @param {Messaging} messaging
|
||||
* @param {@im_livechat/legacy/models/public_livechat} thread
|
||||
*/
|
||||
init(parent, messaging, thread) {
|
||||
this._super(parent);
|
||||
this.messaging = messaging;
|
||||
|
||||
this._debouncedOnScroll = _.debounce(this._onScroll.bind(this), 100);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @return {Promise}
|
||||
*/
|
||||
async start() {
|
||||
this.$input = this.$('.o_composer_text_field');
|
||||
this.$header = this.$('.o_thread_window_header');
|
||||
|
||||
// animate the (un)folding of thread windows
|
||||
this.$el.css({ transition: 'height ' + this.FOLD_ANIMATION_DURATION + 'ms linear' });
|
||||
if (this.messaging.publicLivechatGlobal.publicLivechat.isFolded) {
|
||||
this.$el.css('height', this.HEIGHT_FOLDED);
|
||||
} else {
|
||||
this._focusInput();
|
||||
}
|
||||
const def = this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.replace(this.$('.o_thread_window_content')).then(() => {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.$el.on('scroll', this, this._debouncedOnScroll);
|
||||
});
|
||||
await Promise.all([this._super(), def]);
|
||||
if (this.messaging.publicLivechatGlobal.livechatButtonView.headerBackgroundColor) {
|
||||
this.$('.o_thread_window_header').css('background-color', this.messaging.publicLivechatGlobal.livechatButtonView.headerBackgroundColor);
|
||||
}
|
||||
if (this.messaging.publicLivechatGlobal.livechatButtonView.titleColor) {
|
||||
this.$('.o_thread_window_header').css('color', this.messaging.publicLivechatGlobal.livechatButtonView.titleColor);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
close() {
|
||||
const isComposerDisabled = this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_thread_composer input').prop('disabled');
|
||||
const shouldAskFeedback = !isComposerDisabled && this.messaging.publicLivechatGlobal.messages.find(function (message) {
|
||||
return message.id !== '_welcome';
|
||||
});
|
||||
if (shouldAskFeedback) {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.toggleFold(false);
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.askFeedback();
|
||||
} else {
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.closeChat();
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.leaveSession();
|
||||
},
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
/**
|
||||
* Render the thread window
|
||||
*/
|
||||
render() {
|
||||
this.renderHeader();
|
||||
this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.render({ 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() {
|
||||
this.$header.html(qweb.render('im_livechat.legacy.PublicLivechatWindow.HeaderContent', { widget: this }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the chat window itself.
|
||||
*/
|
||||
renderChatWindow() {
|
||||
this.renderElement();
|
||||
this.adjustPosition();
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute position of this chat window and apply corresponding styles to
|
||||
* the underlying widget.
|
||||
*/
|
||||
adjustPosition() {
|
||||
const cssProps = { bottom: 0 };
|
||||
cssProps[this.messaging.locale.textDirection === 'rtl' ? 'left' : 'right'] = 0;
|
||||
if (!config.device.isMobile) {
|
||||
const margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right";
|
||||
cssProps[margin_dir] = $.position.scrollbarWidth();
|
||||
}
|
||||
this.$el.css(cssProps);
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the thread content with provided new content
|
||||
*
|
||||
* @param {$.Element} $element
|
||||
*/
|
||||
replaceContentWith($element) {
|
||||
$element.replace(this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.$el);
|
||||
},
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* @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.messaging.publicLivechatGlobal.publicLivechat.isFolded;
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.update({ isFolded: folded });
|
||||
if (this.messaging.publicLivechatGlobal.publicLivechat.operator) {
|
||||
setCookie('im_livechat_session', unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.widget.toData()), true), 60 * 60, 'required');
|
||||
}
|
||||
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.messaging.publicLivechatGlobal.publicLivechat.isFolded) {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.scrollToBottom();
|
||||
this._focusInput();
|
||||
}
|
||||
const height = this.messaging.publicLivechatGlobal.publicLivechat.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();
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async _postMessage(messageData) {
|
||||
try {
|
||||
await this.messaging.publicLivechatGlobal.livechatButtonView.sendMessage(messageData);
|
||||
} catch (_err) {
|
||||
await this.messaging.publicLivechatGlobal.livechatButtonView.sendMessage(messageData); // try again just in case
|
||||
}
|
||||
if (!this.messaging.publicLivechatGlobal.publicLivechat.operator) {
|
||||
return;
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget.postMessage(messageData)
|
||||
.then(() => {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.scrollToBottom();
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// 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.messaging.publicLivechatGlobal.publicLivechat.unreadCounter > 0 &&
|
||||
!this.messaging.publicLivechatGlobal.publicLivechat.isFolded
|
||||
) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget.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();
|
||||
},
|
||||
/**
|
||||
* Called when the input in the composer changes
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onInput() {
|
||||
const isTyping = this.$input.val().length > 0;
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget.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.messaging.exists() ||
|
||||
!this.messaging.publicLivechatGlobal ||
|
||||
!this.messaging.publicLivechatGlobal.chatWindow
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.isAtBottom()) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget.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 PublicLivechatWindow;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<!--
|
||||
@param {im_livechat.legacy.PublicLivechatWindow} widget
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.PublicLivechatWindow">
|
||||
<div class="o_thread_window o_in_home_menu"
|
||||
t-att-data-thread-id="widget.messaging.publicLivechatGlobal.publicLivechat.id"
|
||||
t-att-data-thread-model='widget.messaging.publicLivechatGlobal.publicLivechat.model'
|
||||
>
|
||||
<div class="o_thread_window_header">
|
||||
<t t-call="im_livechat.legacy.PublicLivechatWindow.HeaderContent">
|
||||
<t t-set="status" t-value="widget.messaging.publicLivechatGlobal.publicLivechat.status"/>
|
||||
<t t-set="title" t-value="widget.messaging.publicLivechatGlobal.publicLivechat.name"/>
|
||||
<t t-set="unreadCounter" t-value="widget.messaging.publicLivechatGlobal.publicLivechat.unreadCounter"/>
|
||||
<t t-set="thread" t-value="widget.messaging.publicLivechatGlobal.publicLivechat.widget"/>
|
||||
</t>
|
||||
</div>
|
||||
<t t-if="widget.messaging.publicLivechatGlobal.publicLivechat.operator">
|
||||
<div class="o_thread_window_content">
|
||||
</div>
|
||||
<div class="o_thread_composer o_chat_mini_composer">
|
||||
<input class="o_composer_text_field o_PublicLivechatWindow_composer" t-att-placeholder="widget.messaging.publicLivechatGlobal.chatWindow.inputPlaceholder"/>
|
||||
</div>
|
||||
</t>
|
||||
<div t-else="" class="d-flex justify-content-center align-items-center flex-grow-1">
|
||||
<p class="text-500">No operator available</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {im_livechat/legacy/widgets/public_livechat_window/public_livechat_window} widget
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.PublicLivechatWindow.HeaderContent">
|
||||
<span class="o_thread_window_title">
|
||||
<t t-esc="widget.messaging.publicLivechatGlobal.publicLivechat.name"/>
|
||||
<span t-if="widget.messaging.publicLivechatGlobal.publicLivechat.unreadCounter"> (<t t-esc="widget.messaging.publicLivechatGlobal.publicLivechat.unreadCounter"/>)</span>
|
||||
<t t-if="widget.messaging.publicLivechatGlobal.publicLivechat.widget.isSomeoneTyping()" t-call="im_livechat.legacy.mail.ThreadTypingIcon"/>
|
||||
</span>
|
||||
<span class="o_thread_window_buttons">
|
||||
<a href="#" class="o_thread_window_close fa fa-close"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<!--
|
||||
@param {mail.model.Thread} thread with typing feature
|
||||
-->
|
||||
<t t-name="im_livechat.legacy.mail.ThreadTypingIcon">
|
||||
<span class="o_mail_thread_typing_icon" t-att-title="widget.messaging.publicLivechatGlobal.publicLivechat.widget.getTypingMembersToText()">
|
||||
<span class="o_mail_thread_typing_icon_dot"/>
|
||||
<span class="o_mail_thread_typing_icon_dot"/>
|
||||
<span class="o_mail_thread_typing_icon_dot"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerPatch({
|
||||
name: 'Channel',
|
||||
fields: {
|
||||
anonymous_country: one('Country'),
|
||||
anonymous_name: attr(),
|
||||
discussSidebarCategory: {
|
||||
compute() {
|
||||
if (this.channel_type === 'livechat') {
|
||||
return this.messaging.discuss.categoryLivechat;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
displayName: {
|
||||
compute() {
|
||||
if (!this.thread) {
|
||||
return;
|
||||
}
|
||||
if (this.channel_type === 'livechat' && this.correspondent) {
|
||||
if (!this.correspondent.is_public && this.correspondent.country) {
|
||||
return `${this.thread.getMemberName(this.correspondent.persona)} (${this.correspondent.country.name})`;
|
||||
}
|
||||
if (this.anonymous_country) {
|
||||
return `${this.thread.getMemberName(this.correspondent.persona)} (${this.anonymous_country.name})`;
|
||||
}
|
||||
return this.thread.getMemberName(this.correspondent.persona);
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
|
||||
registerPatch({
|
||||
name: 'ChannelPreviewView',
|
||||
fields: {
|
||||
imageUrl: {
|
||||
compute() {
|
||||
if (this.channel.channel_type === 'livechat') {
|
||||
return '/mail/static/src/img/smiley/avatar.jpg';
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
|
||||
registerPatch({
|
||||
name: 'ChatWindow',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
close({ notifyServer } = {}) {
|
||||
if (
|
||||
this.thread &&
|
||||
this.thread.channel &&
|
||||
this.thread.channel.channel_type === 'livechat' &&
|
||||
this.thread.cache.isLoaded &&
|
||||
this.thread.messages.length === 0
|
||||
) {
|
||||
notifyServer = true;
|
||||
this.thread.unpin();
|
||||
}
|
||||
this._super({ notifyServer });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
import '@mail/models/composer_view';
|
||||
|
||||
registerPatch({
|
||||
name: 'ComposerView',
|
||||
fields: {
|
||||
dropZoneView: {
|
||||
compute() {
|
||||
if (this.composer.thread && this.composer.thread.channel && this.composer.thread.channel.channel_type === 'livechat') {
|
||||
return clear();
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerPatch({
|
||||
name: 'Discuss',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
onInputQuickSearch(value) {
|
||||
if (!this.sidebarQuickSearchValue) {
|
||||
this.categoryLivechat.open();
|
||||
}
|
||||
return this._super(value);
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
* Discuss sidebar category for `livechat` channel threads.
|
||||
*/
|
||||
categoryLivechat: one('DiscussSidebarCategory', {
|
||||
default: {},
|
||||
inverse: 'discussAsLivechat',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerPatch({
|
||||
name: 'DiscussSidebarCategory',
|
||||
fields: {
|
||||
categoryItemsOrderedByLastAction: {
|
||||
compute() {
|
||||
if (this.discussAsLivechat) {
|
||||
return this.categoryItems;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
discussAsLivechat: one('Discuss', {
|
||||
identifying: true,
|
||||
inverse: 'categoryLivechat',
|
||||
}),
|
||||
isServerOpen: {
|
||||
compute() {
|
||||
// there is no server state for non-users (guests)
|
||||
if (!this.messaging.currentUser) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.messaging.currentUser.res_users_settings_id) {
|
||||
return clear();
|
||||
}
|
||||
if (this.discussAsLivechat) {
|
||||
return this.messaging.currentUser.res_users_settings_id.is_discuss_sidebar_category_livechat_open;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
name: {
|
||||
compute() {
|
||||
if (this.discussAsLivechat) {
|
||||
return this.env._t("Livechat");
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
orderedCategoryItems: {
|
||||
compute() {
|
||||
if (this.discussAsLivechat) {
|
||||
return this.categoryItemsOrderedByLastAction;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
serverStateKey: {
|
||||
compute() {
|
||||
if (this.discussAsLivechat) {
|
||||
return 'is_discuss_sidebar_category_livechat_open';
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
supportedChannelTypes: {
|
||||
compute() {
|
||||
if (this.discussAsLivechat) {
|
||||
return ['livechat'];
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerPatch({
|
||||
name: 'DiscussSidebarCategoryItem',
|
||||
fields: {
|
||||
avatarUrl: {
|
||||
compute() {
|
||||
if (this.channel.channel_type === 'livechat') {
|
||||
if (this.channel.correspondent && !this.channel.correspondent.is_public) {
|
||||
return this.channel.correspondent.avatarUrl;
|
||||
}
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
categoryCounterContribution: {
|
||||
compute() {
|
||||
if (this.channel.channel_type === 'livechat') {
|
||||
return this.channel.localMessageUnreadCounter > 0 ? 1 : 0;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
counter: {
|
||||
compute() {
|
||||
if (this.channel.channel_type === 'livechat') {
|
||||
return this.channel.localMessageUnreadCounter;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
hasThreadIcon: {
|
||||
compute() {
|
||||
if (this.channel.channel_type === 'livechat') {
|
||||
return clear();
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
hasUnpinCommand: {
|
||||
compute() {
|
||||
if (this.channel.channel_type === 'livechat') {
|
||||
return !this.channel.localMessageUnreadCounter;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
|
||||
registerPatch({
|
||||
name: 'Message',
|
||||
fields: {
|
||||
hasReactionIcon: {
|
||||
compute() {
|
||||
if (this.originThread && this.originThread.channel && this.originThread.channel.channel_type === 'livechat') {
|
||||
return false;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerPatch({
|
||||
name: 'MessageActionList',
|
||||
fields: {
|
||||
actionReplyTo: {
|
||||
compute() {
|
||||
if (
|
||||
this.message &&
|
||||
this.message.originThread &&
|
||||
this.message.originThread.channel &&
|
||||
this.message.originThread.channel.channel_type === 'livechat'
|
||||
) {
|
||||
return clear();
|
||||
}
|
||||
return this._super();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from "@mail/model/model_core";
|
||||
|
||||
registerPatch({
|
||||
name: "MessageView",
|
||||
fields: {
|
||||
hasAuthorClickable: {
|
||||
compute() {
|
||||
if (
|
||||
this.message &&
|
||||
this.message.originThread &&
|
||||
this.message.originThread.channel &&
|
||||
this.message.originThread.channel.channel_type === "livechat"
|
||||
) {
|
||||
return this.message.author === this.message.originThread.channel.correspondent;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { many } from '@mail/model/model_field';
|
||||
|
||||
registerPatch({
|
||||
name: 'Messaging',
|
||||
fields: {
|
||||
/**
|
||||
* All pinned livechats that are known.
|
||||
*/
|
||||
pinnedLivechats: many('Thread', {
|
||||
inverse: 'messagingAsPinnedLivechat',
|
||||
readonly: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { insert } from '@mail/model/model_field_command';
|
||||
|
||||
registerPatch({
|
||||
name: 'MessagingInitializer',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @override
|
||||
* @param {Object[]} [param0.channel_livechat=[]]
|
||||
*/
|
||||
_initCommands() {
|
||||
this._super();
|
||||
this.messaging.update({
|
||||
commands: insert({
|
||||
channel_types: ['livechat'],
|
||||
help: this.env._t("See 15 last visited pages"),
|
||||
methodName: 'execute_command_history',
|
||||
name: "history",
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
|
||||
registerPatch({
|
||||
name: 'MobileMessagingNavbarView',
|
||||
fields: {
|
||||
tabs: {
|
||||
compute() {
|
||||
const res = this._super();
|
||||
if (this.messaging.pinnedLivechats.length > 0) {
|
||||
return [...res, {
|
||||
icon: 'fa fa-comments',
|
||||
id: 'livechat',
|
||||
label: this.env._t("Livechat"),
|
||||
}];
|
||||
}
|
||||
return res;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
|
||||
registerPatch({
|
||||
name: 'NotificationListView',
|
||||
fields: {
|
||||
filteredChannels: {
|
||||
compute() {
|
||||
if (this.filter === 'livechat') {
|
||||
return this.messaging.models['Channel'].all(channel =>
|
||||
channel.channel_type === 'livechat' &&
|
||||
channel.thread.isPinned
|
||||
);
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
|
||||
registerPatch({
|
||||
name: 'Partner',
|
||||
fields: {
|
||||
/**
|
||||
* States the specific name of this partner in the context of livechat.
|
||||
* Either a string or undefined.
|
||||
*/
|
||||
user_livechat_username: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
|
||||
registerPatch({
|
||||
name: 'res.users.settings',
|
||||
fields: {
|
||||
is_discuss_sidebar_category_livechat_open: attr({
|
||||
default: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerPatch({
|
||||
name: 'Thread',
|
||||
recordMethods: {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getMemberName(persona) {
|
||||
if (this.channel && this.channel.channel_type === 'livechat' && persona.partner && persona.partner.user_livechat_username) {
|
||||
return persona.partner.user_livechat_username;
|
||||
}
|
||||
if (this.channel && this.channel.channel_type === 'livechat' && persona.partner && persona.partner.is_public && this.channel.anonymous_name) {
|
||||
return this.channel.anonymous_name;
|
||||
}
|
||||
return this._super(persona);
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
hasInviteFeature: {
|
||||
compute() {
|
||||
if (this.channel && this.channel.channel_type === 'livechat') {
|
||||
return true;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
hasMemberListFeature: {
|
||||
compute() {
|
||||
if (this.channel && this.channel.channel_type === 'livechat') {
|
||||
return true;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
isChatChannel: {
|
||||
compute() {
|
||||
if (this.channel && this.channel.channel_type === 'livechat') {
|
||||
return true;
|
||||
}
|
||||
return this._super();
|
||||
},
|
||||
},
|
||||
/**
|
||||
* If set, current thread is a livechat.
|
||||
*/
|
||||
messagingAsPinnedLivechat: one('Messaging', {
|
||||
compute() {
|
||||
if (!this.messaging || !this.channel || this.channel.channel_type !== 'livechat' || !this.isPinned) {
|
||||
return clear();
|
||||
}
|
||||
return this.messaging;
|
||||
},
|
||||
inverse: 'pinnedLivechats',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { publicLivechatService } from '@im_livechat/services/public_livechat_service';
|
||||
import { isAvailable, options, serverUrl } from 'im_livechat.loaderData';
|
||||
|
||||
import { messagingService } from '@mail/services/messaging_service';
|
||||
import { makeMessagingToLegacyEnv } from '@mail/utils/make_messaging_to_legacy_env';
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
|
||||
const messagingValuesService = {
|
||||
start() {
|
||||
return {
|
||||
publicLivechatGlobal: { isAvailable, options, serverUrl },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const serviceRegistry = registry.category('services');
|
||||
serviceRegistry.add('messaging', messagingService);
|
||||
serviceRegistry.add('messagingValues', messagingValuesService);
|
||||
serviceRegistry.add('public_livechat_service', publicLivechatService);
|
||||
|
||||
registry.category('wowlToLegacyServiceMappers').add('make_messaging_to_legacy_env', makeMessagingToLegacyEnv);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
odoo.define('web.session', function (require) {
|
||||
|
||||
const Session = require('web.Session');
|
||||
const { serverUrl } = require('im_livechat.loaderData');
|
||||
|
||||
return new Session(undefined, serverUrl, { use_cors: true });
|
||||
});
|
||||
|
|
@ -0,0 +1,605 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear, increment } from '@mail/model/model_field_command';
|
||||
|
||||
import { qweb } from 'web.core';
|
||||
import { Markup } from 'web.utils';
|
||||
|
||||
registerModel({
|
||||
name: 'Chatbot',
|
||||
recordMethods: {
|
||||
/**
|
||||
* Add message posted by the bot into the conversation.
|
||||
* This allows not having to wait for the bus (since we run checks based on messages in the
|
||||
* conversation, having the result be there immediately eases the process).
|
||||
*
|
||||
* It also helps while running test tours since those don't have the bus enabled.
|
||||
*/
|
||||
addMessage(message, options) {
|
||||
message.body = Markup(message.body);
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.addMessage(message, options);
|
||||
if (this.messaging.publicLivechatGlobal.publicLivechat.isFolded || !this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.isAtBottom()) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.update({ unreadCounter: increment() });
|
||||
}
|
||||
|
||||
if (!options || !options.skipRenderMessages) {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.renderMessages();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Once the script ends, adds a visual element at the end of the chat window allowing to restart
|
||||
* the whole script.
|
||||
*/
|
||||
endScript() {
|
||||
if (
|
||||
this.currentStep &&
|
||||
this.currentStep.data &&
|
||||
this.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.chatWindow.widget.$('.o_composer_text_field').addClass('d-none');
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_livechat_chatbot_main_restart').show();
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_livechat_chatbot_end').show();
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_livechat_chatbot_restart').one('click', this.messaging.publicLivechatGlobal.livechatButtonView.onChatbotRestartScript);
|
||||
},
|
||||
onKeydownInput() {
|
||||
if (
|
||||
this.currentStep &&
|
||||
this.currentStep.data &&
|
||||
this.currentStep.data.chatbot_step_type === 'free_input_multi'
|
||||
) {
|
||||
this.debouncedAwaitUserInput();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When the user first interacts with the bot, we want to make sure to actually post the welcome
|
||||
* messages into the conversation.
|
||||
*
|
||||
* Indeed, before that, they are 'virtual' messages that are not tied to mail.messages, see
|
||||
* #_sendWelcomeChatbotMessage() for more information.
|
||||
*
|
||||
* Posting them as real messages allows to have a cleaner model and conversation, that will be
|
||||
* kept intact when changing page on the website.
|
||||
*
|
||||
* It also allows tying any first response / question_selection choice to a chatbot.message
|
||||
* that has a linked mail.message.
|
||||
*/
|
||||
async postWelcomeMessages() {
|
||||
const welcomeMessages = this.messaging.publicLivechatGlobal.welcomeMessages;
|
||||
|
||||
if (welcomeMessages.length === 0) {
|
||||
// we already posted the welcome messages, nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
const postedWelcomeMessages = await this.messaging.rpc({
|
||||
route: '/chatbot/post_welcome_steps',
|
||||
params: {
|
||||
channel_uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
|
||||
chatbot_script_id: this.scriptId,
|
||||
},
|
||||
});
|
||||
|
||||
const welcomeMessagesIds = welcomeMessages.map(welcomeMessage => welcomeMessage.id);
|
||||
this.messaging.publicLivechatGlobal.update({
|
||||
messages: this.messaging.publicLivechatGlobal.messages.filter((message) => {
|
||||
!welcomeMessagesIds.includes(message.id);
|
||||
}),
|
||||
});
|
||||
|
||||
postedWelcomeMessages.reverse();
|
||||
postedWelcomeMessages.forEach((message) => {
|
||||
this.addMessage(message, {
|
||||
prepend: true,
|
||||
skipRenderMessages: true,
|
||||
});
|
||||
});
|
||||
|
||||
this.messaging.publicLivechatGlobal.chatWindow.renderMessages();
|
||||
},
|
||||
/**
|
||||
* Processes the step, depending on the current state of the script and the author of the last
|
||||
* message that was typed into the conversation.
|
||||
*
|
||||
* This is a rather complicated process since we have many potential states to handle.
|
||||
* Here are the detailed possible outcomes:
|
||||
*
|
||||
* - Check if the script is finished, and if so end it.
|
||||
*
|
||||
* - If a human operator has taken over the conversation
|
||||
* -> enable the input and let the operator handle the visitor.
|
||||
*
|
||||
* - If the received step is of type expecting an input from the user
|
||||
* - the last message if from the user (he has already answered)
|
||||
* -> trigger the next step
|
||||
* - otherwise
|
||||
* -> enable the input and let the user type
|
||||
*
|
||||
* - Otherwise
|
||||
* - if the step is of type 'question_selection' and we are still waiting for the user to
|
||||
* select one of the options
|
||||
* -> don't do anything, wait for the user to click one of the options
|
||||
* - otherwise
|
||||
* -> trigger the next step
|
||||
*/
|
||||
processStep() {
|
||||
if (this.shouldEndScript) {
|
||||
this.endScript();
|
||||
} else if (
|
||||
this.currentStep.data.chatbot_step_type === 'forward_operator' &&
|
||||
this.currentStep.data.chatbot_operator_found
|
||||
) {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.enableInput();
|
||||
} else if (this.isExpectingUserInput) {
|
||||
if (this.messaging.publicLivechatGlobal.isLastMessageFromCustomer) {
|
||||
// user has already typed a message in -> trigger next step
|
||||
this.setIsTyping();
|
||||
this.update({
|
||||
nextStepTimeout: setTimeout(
|
||||
this.triggerNextStep,
|
||||
this.messageDelay,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.enableInput();
|
||||
}
|
||||
} else {
|
||||
let triggerNextStep = true;
|
||||
if (this.currentStep.data.chatbot_step_type === 'question_selection') {
|
||||
if (!this.messaging.publicLivechatGlobal.isLastMessageFromCustomer) {
|
||||
// if there is no last message or if the last message is from the bot
|
||||
// -> don't trigger the next step, we are waiting for the user to pick an option
|
||||
triggerNextStep = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerNextStep) {
|
||||
let nextStepDelay = this.messageDelay;
|
||||
if (this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_livechat_chatbot_typing').length !== 0) {
|
||||
// special case where we already have a "is typing" message displayed
|
||||
// can happen when the previous step did not trigger any message posted from the bot
|
||||
// e.g: previous step was "forward_operator" and no-one is available
|
||||
// -> in that case, don't wait and trigger the next step immediately
|
||||
nextStepDelay = 0;
|
||||
} else {
|
||||
this.setIsTyping();
|
||||
}
|
||||
|
||||
this.update({
|
||||
nextStepTimeout: setTimeout(
|
||||
this.triggerNextStep,
|
||||
nextStepDelay,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.hasRestartButton) {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_livechat_chatbot_main_restart').hide();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* See 'Chatbot/saveSession'.
|
||||
*
|
||||
* We retrieve the livechat uuid from the session cookie since the livechat Widget is not yet
|
||||
* initialized when we restore the chatbot state.
|
||||
*
|
||||
* We also clear any older keys that store a previously saved chatbot session.
|
||||
* (In that case we clear the actual browser's local storage, we don't use the localStorage
|
||||
* object as it does not allow browsing existing keys, see 'local_storage.js'.)
|
||||
*/
|
||||
restoreSession() {
|
||||
const browserLocalStorage = window.localStorage;
|
||||
if (browserLocalStorage && browserLocalStorage.length) {
|
||||
for (let i = 0; i < browserLocalStorage.length; i++) {
|
||||
const key = browserLocalStorage.key(i);
|
||||
if (key.startsWith('im_livechat.chatbot.state.uuid_') && key !== this.sessionCookieKey) {
|
||||
browserLocalStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
const chatbotState = localStorage.getItem(this.sessionCookieKey);
|
||||
if (chatbotState) {
|
||||
this.update({ currentStep: { data: this.localStorageState._chatbotCurrentStep } });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Register current chatbot step state into localStorage to be able to resume if the visitor
|
||||
* goes to another website page or if he refreshes his page.
|
||||
*
|
||||
* (Will not work if the visitor switches browser but his livechat session will not be restored
|
||||
* anyway in that case, since it's stored into a cookie).
|
||||
*/
|
||||
saveSession() {
|
||||
localStorage.setItem('im_livechat.chatbot.state.uuid_' + this.messaging.publicLivechatGlobal.publicLivechat.uuid, JSON.stringify({
|
||||
'_chatbot': this.data,
|
||||
'_chatbotCurrentStep': this.currentStep.data,
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* Adds a small "is typing" animation into the chat window.
|
||||
*
|
||||
* @param {boolean} [isWelcomeMessage=false]
|
||||
*/
|
||||
setIsTyping(isWelcomeMessage = false) {
|
||||
if (this.messaging.publicLivechatGlobal.livechatButtonView.isTypingTimeout) {
|
||||
clearTimeout(this.messaging.publicLivechatGlobal.livechatButtonView.isTypingTimeout);
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.chatWindow.disableInput('');
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.update({
|
||||
isTypingTimeout: setTimeout(
|
||||
() => {
|
||||
if (!this.messaging.publicLivechatGlobal.chatWindow || !this.messaging.publicLivechatGlobal.chatWindow.exists()) {
|
||||
return;
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_mail_thread_content').append(
|
||||
$(qweb.render('im_livechat.legacy.chatbot.is_typing_message', {
|
||||
'chatbotImageSrc': this.messaging.publicLivechatGlobal.serverUrl + `/im_livechat/operator/${
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.operator.id
|
||||
}/avatar`,
|
||||
'chatbotIsTypingImageSrc': this.messaging.publicLivechatGlobal.serverUrl + '/im_livechat/static/src/img/chatbot_is_typing.gif',
|
||||
'chatbotName': this.name,
|
||||
'isWelcomeMessage': isWelcomeMessage,
|
||||
}))
|
||||
);
|
||||
this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.scrollToBottom();
|
||||
},
|
||||
this.messageDelay / 3,
|
||||
),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Triggers the next step of the script by calling the associated route.
|
||||
* This will receive the next step and call step processing.
|
||||
*/
|
||||
async triggerNextStep() {
|
||||
if (!this.messaging.publicLivechatGlobal.chatWindow || !this.messaging.publicLivechatGlobal.chatWindow.exists()) {
|
||||
return;
|
||||
}
|
||||
let triggerNextStep = true;
|
||||
if (
|
||||
this.currentStep &&
|
||||
this.currentStep.data &&
|
||||
this.currentStep.data.chatbot_step_type === 'question_email'
|
||||
) {
|
||||
triggerNextStep = await this.validateEmail();
|
||||
}
|
||||
|
||||
if (!triggerNextStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStep = await this.messaging.rpc({
|
||||
route: '/chatbot/step/trigger',
|
||||
params: {
|
||||
channel_uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
|
||||
chatbot_script_id: this.scriptId,
|
||||
},
|
||||
});
|
||||
|
||||
if (nextStep) {
|
||||
if (nextStep.chatbot_posted_message) {
|
||||
this.addMessage(nextStep.chatbot_posted_message);
|
||||
}
|
||||
|
||||
this.update({ currentStep: { data: nextStep.chatbot_step } });
|
||||
|
||||
this.processStep();
|
||||
} else {
|
||||
// did not find next step -> end the script
|
||||
this.currentStep.data.chatbot_step_is_last = true;
|
||||
this.messaging.publicLivechatGlobal.chatWindow.renderMessages();
|
||||
this.endScript();
|
||||
}
|
||||
|
||||
this.saveSession();
|
||||
|
||||
return nextStep;
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The validation is made server-side to have the same test when we validate here and when we
|
||||
* register the answer, but also to easily post a message as the bot ("Sorry, try again...").
|
||||
*
|
||||
* Returns a boolean stating whether the email was valid or not.
|
||||
*/
|
||||
async validateEmail() {
|
||||
let emailValidResult = await this.messaging.rpc({
|
||||
route: '/chatbot/step/validate_email',
|
||||
params: { channel_uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid },
|
||||
});
|
||||
|
||||
if (emailValidResult.success) {
|
||||
this.currentStep.data.is_email_valid = true;
|
||||
this.saveSession();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
// email is not valid, let the user try again
|
||||
this.messaging.publicLivechatGlobal.chatWindow.enableInput();
|
||||
if (emailValidResult.posted_message) {
|
||||
this.addMessage(emailValidResult.posted_message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This method will be transformed into a 'debounced' version (see init).
|
||||
*
|
||||
* The purpose is to handle steps of type 'free_input_multi', that will let the user type in
|
||||
* multiple lines of text before the bot goes to the next step.
|
||||
*
|
||||
* Every time a 'keydown' is detected into the input, or every time a message is sent, we call
|
||||
* this debounced method, which will give the user about 10 seconds to type more text before
|
||||
* the next step is triggered.
|
||||
*
|
||||
* First we check if the last message was sent by the user, to make sure we always let him type
|
||||
* at least one message before moving on.
|
||||
*/
|
||||
awaitUserInput() {
|
||||
if (this.messaging.publicLivechatGlobal.isLastMessageFromCustomer) {
|
||||
if (this.shouldEndScript) {
|
||||
this.endScript();
|
||||
} else {
|
||||
this.setIsTyping();
|
||||
this.update({
|
||||
nextStepTimeout: setTimeout(
|
||||
this.triggerNextStep,
|
||||
this.messageDelay,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
awaitUserInputDebounceTime: attr({
|
||||
compute() {
|
||||
return 10000;
|
||||
},
|
||||
}),
|
||||
data: attr({
|
||||
compute() {
|
||||
if (this.messaging.publicLivechatGlobal.isTestChatbot) {
|
||||
return this.messaging.publicLivechatGlobal.testChatbotData;
|
||||
}
|
||||
if (this.state === 'init') {
|
||||
return this.messaging.publicLivechatGlobal.rule.chatbot;
|
||||
}
|
||||
if (this.state === 'welcome') {
|
||||
return this.messaging.publicLivechatGlobal.livechatInit.rule.chatbot;
|
||||
}
|
||||
if (
|
||||
this.state === 'restore_session' &&
|
||||
this.localStorageState
|
||||
) {
|
||||
return this.localStorageState._chatbot;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
currentStep: one('ChatbotStep', {
|
||||
inverse: 'chabotOwner',
|
||||
}),
|
||||
debouncedAwaitUserInput: attr({
|
||||
compute() {
|
||||
// debounced to let the user type several sentences, see 'Chatbot/awaitUserInput' for details
|
||||
return _.debounce(
|
||||
this.awaitUserInput,
|
||||
this.awaitUserInputDebounceTime,
|
||||
);
|
||||
},
|
||||
}),
|
||||
hasRestartButton: attr({
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
compute() {
|
||||
const { publicLivechat } = this.messaging.publicLivechatGlobal;
|
||||
if (publicLivechat && !publicLivechat.operator) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this.messaging.publicLivechatGlobal.publicLivechat ||
|
||||
!this.messaging.publicLivechatGlobal.publicLivechat.uuid
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (publicLivechat && !publicLivechat.data.chatbot_script_id) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
!this.currentStep ||
|
||||
(
|
||||
this.currentStep.data.chatbot_step_type !== 'forward_operator' ||
|
||||
!this.currentStep.data.chatbot_operator_found
|
||||
)
|
||||
);
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
isActive: attr({
|
||||
compute() {
|
||||
if (this.messaging.publicLivechatGlobal.isTestChatbot) {
|
||||
return true;
|
||||
}
|
||||
if (this.messaging.publicLivechatGlobal.rule && this.messaging.publicLivechatGlobal.rule.chatbot) {
|
||||
return true;
|
||||
}
|
||||
if (this.messaging.publicLivechatGlobal.livechatInit && this.messaging.publicLivechatGlobal.livechatInit.rule.chatbot) {
|
||||
return true;
|
||||
}
|
||||
if (this.state === 'welcome') {
|
||||
return true;
|
||||
}
|
||||
if (this.localStorageState) {
|
||||
return true;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
isExpectingUserInput: attr({
|
||||
compute() {
|
||||
if (!this.currentStep) {
|
||||
return clear();
|
||||
}
|
||||
return [
|
||||
'question_phone',
|
||||
'question_email',
|
||||
'free_input_single',
|
||||
'free_input_multi',
|
||||
].includes(this.currentStep.data.chatbot_step_type);
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
isRedirecting: attr({
|
||||
default: false,
|
||||
}),
|
||||
lastWelcomeStep: attr({
|
||||
compute() {
|
||||
if (!this.welcomeSteps) {
|
||||
return clear();
|
||||
}
|
||||
return this.welcomeSteps[this.welcomeSteps.length - 1];
|
||||
},
|
||||
}),
|
||||
localStorageState: attr({
|
||||
compute() {
|
||||
if (!this.messaging.publicLivechatGlobal.sessionCookie) {
|
||||
return clear();
|
||||
}
|
||||
const data = localStorage.getItem(this.sessionCookieKey);
|
||||
if (!data) {
|
||||
return clear();
|
||||
}
|
||||
return JSON.parse(data);
|
||||
},
|
||||
}),
|
||||
name: attr({
|
||||
compute() {
|
||||
if (!this.data) {
|
||||
return clear();
|
||||
}
|
||||
return this.data.name;
|
||||
},
|
||||
}),
|
||||
nextStepTimeout: attr(),
|
||||
messageDelay: attr({
|
||||
compute() {
|
||||
return clear();
|
||||
},
|
||||
default: 3500, // in milliseconds
|
||||
}),
|
||||
publicLivechatGlobalOwner: one('PublicLivechatGlobal', {
|
||||
identifying: true,
|
||||
inverse: 'chatbot',
|
||||
}),
|
||||
scriptId: attr({
|
||||
compute() {
|
||||
if (!this.data) {
|
||||
return clear();
|
||||
}
|
||||
return this.data.chatbot_script_id;
|
||||
},
|
||||
}),
|
||||
serverUrl: attr(),
|
||||
sessionCookieKey: attr({
|
||||
compute() {
|
||||
if (!this.messaging.publicLivechatGlobal.sessionCookie) {
|
||||
return clear();
|
||||
}
|
||||
return 'im_livechat.chatbot.state.uuid_' + JSON.parse(this.messaging.publicLivechatGlobal.sessionCookie).uuid;
|
||||
},
|
||||
}),
|
||||
shouldEndScript: attr({
|
||||
/**
|
||||
* Compute 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
|
||||
*/
|
||||
compute() {
|
||||
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.messages.length !== 0
|
||||
) {
|
||||
if (this.messaging.publicLivechatGlobal.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;
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
state: attr({
|
||||
compute() {
|
||||
if (this.messaging.publicLivechatGlobal.rule && !!this.messaging.publicLivechatGlobal.rule.chatbot) {
|
||||
return 'init';
|
||||
}
|
||||
if (this.messaging.publicLivechatGlobal.livechatInit && this.messaging.publicLivechatGlobal.livechatInit.rule.chatbot) {
|
||||
return 'welcome';
|
||||
}
|
||||
if (
|
||||
!this.messaging.publicLivechatGlobal.rule &&
|
||||
this.messaging.publicLivechatGlobal.history !== null &&
|
||||
this.messaging.publicLivechatGlobal.history.length !== 0 &&
|
||||
this.sessionCookieKey &&
|
||||
localStorage.getItem(this.sessionCookieKey)
|
||||
) {
|
||||
return 'restore_session';
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
welcomeMessageTimeout: attr(),
|
||||
welcomeSteps: attr({
|
||||
compute() {
|
||||
if (!this.data) {
|
||||
return clear();
|
||||
}
|
||||
return this.data.chatbot_welcome_steps;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'ChatbotStep',
|
||||
fields: {
|
||||
chabotOwner: one('Chatbot', {
|
||||
identifying: true,
|
||||
inverse: 'currentStep',
|
||||
}),
|
||||
data: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import {getCookie, deleteCookie} from 'web.utils.cookies';
|
||||
|
||||
registerModel({
|
||||
name: 'LivechatButtonView',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.update({ widget: this.env.services.public_livechat_service.mountLivechatButton() });
|
||||
},
|
||||
_willDelete() {
|
||||
this.widget.destroy();
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* @param {Object} data
|
||||
* @param {Object} [options={}]
|
||||
*/
|
||||
addMessage(data, options) {
|
||||
const hasAlreadyMessage = _.some(this.messaging.publicLivechatGlobal.messages, function (msg) {
|
||||
return data.id === msg.id;
|
||||
});
|
||||
if (hasAlreadyMessage) {
|
||||
return;
|
||||
}
|
||||
const message = this.messaging.models['PublicLivechatMessage'].insert({
|
||||
data,
|
||||
id: data.id,
|
||||
});
|
||||
|
||||
if (this.messaging.publicLivechatGlobal.publicLivechat && this.messaging.publicLivechatGlobal.publicLivechat.widget) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget.addMessage(message.widget);
|
||||
}
|
||||
|
||||
if (options && options.prepend) {
|
||||
this.messaging.publicLivechatGlobal.update({
|
||||
messages: [message, ...this.messaging.publicLivechatGlobal.messages],
|
||||
});
|
||||
} else {
|
||||
this.messaging.publicLivechatGlobal.update({
|
||||
messages: [...this.messaging.publicLivechatGlobal.messages, message],
|
||||
});
|
||||
}
|
||||
},
|
||||
askFeedback() {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_thread_composer input').prop('disabled', true);
|
||||
this.messaging.publicLivechatGlobal.update({ feedbackView: {} });
|
||||
/**
|
||||
* 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.messaging.publicLivechatGlobal.chatbot.currentStep &&
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep.data
|
||||
) {
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep.data.conversation_closed = true;
|
||||
this.messaging.publicLivechatGlobal.chatbot.saveSession();
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_livechat_chatbot_main_restart').hide();
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_livechat_chatbot_end').hide();
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_composer_text_field')
|
||||
.removeClass('d-none')
|
||||
.val('');
|
||||
},
|
||||
/**
|
||||
* Restart the script and then trigger the "next step" (which will be the first of the script
|
||||
* in this case).
|
||||
*/
|
||||
async onChatbotRestartScript(ev) {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_composer_text_field').removeClass('d-none');
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.$('.o_livechat_chatbot_end').hide();
|
||||
|
||||
if (this.messaging.publicLivechatGlobal.chatbot.nextStepTimeout) {
|
||||
clearTimeout(this.messaging.publicLivechatGlobal.chatbot.nextStepTimeout);
|
||||
}
|
||||
|
||||
if (this.messaging.publicLivechatGlobal.chatbot.welcomeMessageTimeout) {
|
||||
clearTimeout(this.messaging.publicLivechatGlobal.chatbot.welcomeMessageTimeout);
|
||||
}
|
||||
if (this.messaging.publicLivechatGlobal.publicLivechat.uuid) {
|
||||
const postedMessage = await this.messaging.rpc({
|
||||
route: '/chatbot/restart',
|
||||
params: {
|
||||
channel_uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
|
||||
chatbot_script_id: this.messaging.publicLivechatGlobal.chatbot.scriptId,
|
||||
},
|
||||
});
|
||||
|
||||
if (postedMessage) {
|
||||
this.messaging.publicLivechatGlobal.chatbot.addMessage(postedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
this.messaging.publicLivechatGlobal.chatbot.update({ currentStep: clear() });
|
||||
this.messaging.publicLivechatGlobal.chatbot.setIsTyping();
|
||||
this.messaging.publicLivechatGlobal.chatbot.update({
|
||||
nextStepTimeout: setTimeout(
|
||||
this.messaging.publicLivechatGlobal.chatbot.triggerNextStep,
|
||||
this.messaging.publicLivechatGlobal.chatbot.messageDelay,
|
||||
),
|
||||
});
|
||||
},
|
||||
closeChat() {
|
||||
this.messaging.publicLivechatGlobal.update({ chatWindow: clear() });
|
||||
deleteCookie('im_livechat_session');
|
||||
},
|
||||
openChat() {
|
||||
if (this.isOpenChatDebounced) {
|
||||
this.openChatDebounced();
|
||||
} else {
|
||||
this._openChat();
|
||||
}
|
||||
},
|
||||
async openChatWindow() {
|
||||
this.messaging.publicLivechatGlobal.update({ chatWindow: {} });
|
||||
await this.messaging.publicLivechatGlobal.chatWindow.widget.appendTo($('body'));
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.adjustPosition();
|
||||
this.widget.$el.hide();
|
||||
this._openChatWindowChatbot();
|
||||
},
|
||||
/**
|
||||
* @param {Object} message
|
||||
*/
|
||||
async sendMessage(message) {
|
||||
if (this.messaging.publicLivechatGlobal.publicLivechat.isTemporary) {
|
||||
await this.messaging.publicLivechatGlobal.publicLivechat.createLivechatChannel();
|
||||
if (!this.messaging.publicLivechatGlobal.publicLivechat.operator) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this._sendMessageChatbotBefore();
|
||||
await this._sendMessage(message);
|
||||
this._sendMessageChatbotAfter();
|
||||
},
|
||||
async start() {
|
||||
if (!this.messaging.publicLivechatGlobal.hasWebsiteLivechatFeature) {
|
||||
this.widget.$el.text(this.buttonText);
|
||||
}
|
||||
this.update({ isWidgetMounted: true });
|
||||
if (this.messaging.publicLivechatGlobal.history) {
|
||||
for (const m of this.messaging.publicLivechatGlobal.history) {
|
||||
this.addMessage(m);
|
||||
}
|
||||
await this.openChat();
|
||||
} else if (!this.messaging.device.isSmall && this.messaging.publicLivechatGlobal.rule.action === 'auto_popup') {
|
||||
const autoPopupCookie = getCookie('im_livechat_auto_popup');
|
||||
if (!autoPopupCookie || JSON.parse(autoPopupCookie)) {
|
||||
this.update({
|
||||
autoOpenChatTimeout: setTimeout(
|
||||
this.openChat,
|
||||
this.messaging.publicLivechatGlobal.rule.auto_popup_timer * 1000,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (this.buttonBackgroundColor) {
|
||||
this.widget.$el.css('background-color', this.buttonBackgroundColor);
|
||||
}
|
||||
if (this.buttonTextColor) {
|
||||
this.widget.$el.css('color', this.buttonTextColor);
|
||||
}
|
||||
// If website_event_track installed, put the livechat banner above the PWA banner.
|
||||
const pwaBannerHeight = $('.o_pwa_install_banner').outerHeight(true);
|
||||
if (pwaBannerHeight) {
|
||||
this.widget.$el.css('bottom', pwaBannerHeight + 'px');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _openChat() {
|
||||
if (this.isOpeningChat) {
|
||||
return;
|
||||
}
|
||||
const cookie = decodeURIComponent(getCookie('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.messaging.publicLivechatGlobal.update({ messages: clear() });
|
||||
def = this.messaging.rpc({
|
||||
route: '/im_livechat/get_session',
|
||||
params: {
|
||||
...this.widget._prepareGetSessionParameters(),
|
||||
persisted: false,
|
||||
},
|
||||
}, { 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: { data: livechatData },
|
||||
});
|
||||
return this.openChatWindow().then(() => {
|
||||
if (!this.messaging.publicLivechatGlobal.history) {
|
||||
this.widget._sendWelcomeMessage();
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.chatWindow.renderMessages();
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.updateSessionCookie();
|
||||
});
|
||||
}
|
||||
}).then(() => {
|
||||
this.update({ isOpeningChat: false });
|
||||
}).guardedCatch(() => {
|
||||
this.update({ isOpeningChat: false });
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Resuming the chatbot script if we are currently running one.
|
||||
*
|
||||
* In addition, we register a resize event on the window object to scroll messages to bottom.
|
||||
* This is done especially for mobile (Android) where the keyboard opens upon focusing the input
|
||||
* field and shrinks the whole window size.
|
||||
* Scrolling to the bottom allows the user to see the last messages properly when that happens.
|
||||
*
|
||||
* @private
|
||||
* @override
|
||||
*/
|
||||
_openChatWindowChatbot() {
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.messaging.publicLivechatGlobal.chatWindow) {
|
||||
this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep &&
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep.data &&
|
||||
this.messaging.publicLivechatGlobal.messages &&
|
||||
this.messaging.publicLivechatGlobal.messages.length !== 0
|
||||
) {
|
||||
this.messaging.publicLivechatGlobal.chatbot.processStep();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} message
|
||||
*/
|
||||
async _sendMessage(message) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget._notifyMyselfTyping({ typing: false });
|
||||
const messageId = await this.messaging.rpc({
|
||||
route: '/mail/chat_post',
|
||||
params: { uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid, message_content: message.content },
|
||||
});
|
||||
if (!messageId) {
|
||||
try {
|
||||
this.widget.displayNotification({
|
||||
message: this.env._t("Session expired... Please refresh and try again."),
|
||||
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("Session expired... Please refresh and try again."));
|
||||
}
|
||||
this.closeChat();
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.scrollToBottom();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_sendMessageChatbotAfter() {
|
||||
if (this.messaging.publicLivechatGlobal.chatbot.isRedirecting) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.messaging.publicLivechatGlobal.chatbot.isActive &&
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep &&
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep.data
|
||||
) {
|
||||
if (
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep.data.chatbot_step_type === 'forward_operator' &&
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep.data.chatbot_operator_found
|
||||
) {
|
||||
return; // operator has taken over the conversation, let them speak
|
||||
} else if (this.messaging.publicLivechatGlobal.chatbot.currentStep.data.chatbot_step_type === 'free_input_multi') {
|
||||
this.messaging.publicLivechatGlobal.chatbot.debouncedAwaitUserInput();
|
||||
} else if (!this.messaging.publicLivechatGlobal.chatbot.shouldEndScript) {
|
||||
this.messaging.publicLivechatGlobal.chatbot.setIsTyping();
|
||||
this.messaging.publicLivechatGlobal.chatbot.update({
|
||||
nextStepTimeout: setTimeout(
|
||||
this.messaging.publicLivechatGlobal.chatbot.triggerNextStep,
|
||||
this.messaging.publicLivechatGlobal.chatbot.messageDelay,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.messaging.publicLivechatGlobal.chatbot.endScript();
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.chatbot.saveSession();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When the Customer sends a message, we need to act depending on our current state:
|
||||
* - If the conversation has been forwarded to an operator
|
||||
* Then there is nothing to do, we let them speak
|
||||
* - If we are currently on a 'free_input_multi' step
|
||||
* Await more user input (see #Chatbot/awaitUserInput for details)
|
||||
* - Otherwise we continue the script or end it if it's the last step
|
||||
*
|
||||
* We also save the current session state.
|
||||
* Important as this may be the very first interaction with the bot, we need to save right away
|
||||
* to correctly handle any page redirection / page refresh.
|
||||
*
|
||||
* Special side case: if we are currently redirecting to another page (see '_onChatbotOptionClicked')
|
||||
* we shortcut the process as we are currently moving to a different URL.
|
||||
* The script will be resumed on the new page (if in the same website domain).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async _sendMessageChatbotBefore() {
|
||||
if (
|
||||
this.messaging.publicLivechatGlobal.chatbot.isActive &&
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep &&
|
||||
this.messaging.publicLivechatGlobal.chatbot.currentStep.data
|
||||
) {
|
||||
await this.messaging.publicLivechatGlobal.chatbot.postWelcomeMessages();
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
autoOpenChatTimeout: attr(),
|
||||
buttonBackgroundColor: attr({
|
||||
compute() {
|
||||
return this.messaging.publicLivechatGlobal.options.button_background_color;
|
||||
},
|
||||
}),
|
||||
buttonText: attr({
|
||||
compute() {
|
||||
if (this.messaging.publicLivechatGlobal.options.button_text) {
|
||||
return this.messaging.publicLivechatGlobal.options.button_text;
|
||||
}
|
||||
return this.env._t("Chat with one of our collaborators");
|
||||
},
|
||||
}),
|
||||
buttonTextColor: attr({
|
||||
compute() {
|
||||
return this.messaging.publicLivechatGlobal.options.button_text_color;
|
||||
},
|
||||
}),
|
||||
chatbotNextStepTimeout: attr(),
|
||||
chatbotWelcomeMessageTimeout: attr(),
|
||||
currentPartnerId: attr({
|
||||
compute() {
|
||||
if (!this.messaging.publicLivechatGlobal.isAvailable) {
|
||||
return clear();
|
||||
}
|
||||
return this.messaging.publicLivechatGlobal.options.current_partner_id;
|
||||
},
|
||||
}),
|
||||
defaultMessage: attr({
|
||||
compute() {
|
||||
if (this.messaging.publicLivechatGlobal.options.default_message) {
|
||||
return this.messaging.publicLivechatGlobal.options.default_message;
|
||||
}
|
||||
return this.env._t("How may I help you?");
|
||||
},
|
||||
}),
|
||||
defaultUsername: attr({
|
||||
compute() {
|
||||
if (this.messaging.publicLivechatGlobal.options.default_username) {
|
||||
return this.messaging.publicLivechatGlobal.options.default_username;
|
||||
}
|
||||
return this.env._t("Visitor");
|
||||
},
|
||||
}),
|
||||
headerBackgroundColor: attr({
|
||||
compute() {
|
||||
return this.messaging.publicLivechatGlobal.options.header_background_color;
|
||||
},
|
||||
}),
|
||||
inputPlaceholder: attr({
|
||||
compute() {
|
||||
if (this.messaging.publicLivechatGlobal.chatbot.isActive) {
|
||||
// void the default livechat placeholder in the user input
|
||||
// as we use it for specific things (e.g: showing "please select an option above")
|
||||
return clear();
|
||||
}
|
||||
if (this.messaging.publicLivechatGlobal.options.input_placeholder) {
|
||||
return this.messaging.publicLivechatGlobal.options.input_placeholder;
|
||||
}
|
||||
return this.env._t("Ask something ...");
|
||||
},
|
||||
default: '',
|
||||
}),
|
||||
isOpenChatDebounced: attr({
|
||||
compute() {
|
||||
return clear();
|
||||
},
|
||||
default: true,
|
||||
}),
|
||||
isOpeningChat: attr({
|
||||
default: false,
|
||||
}),
|
||||
isTypingTimeout: attr(),
|
||||
isWidgetMounted: attr({
|
||||
default: false,
|
||||
}),
|
||||
openChatDebounced: attr({
|
||||
compute() {
|
||||
return _.debounce(this._openChat, 200, true);
|
||||
},
|
||||
}),
|
||||
publicLivechatGlobalOwner: one('PublicLivechatGlobal', {
|
||||
identifying: true,
|
||||
inverse: 'livechatButtonView',
|
||||
}),
|
||||
serverUrl: attr({
|
||||
compute() {
|
||||
return this.messaging.publicLivechatGlobal.serverUrl;
|
||||
},
|
||||
}),
|
||||
titleColor: attr({
|
||||
compute() {
|
||||
return this.messaging.publicLivechatGlobal.options.title_color;
|
||||
},
|
||||
}),
|
||||
widget: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'LivechatOperator',
|
||||
fields: {
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
name: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerPatch({
|
||||
name: 'Messaging',
|
||||
fields: {
|
||||
publicLivechatGlobal: one('PublicLivechatGlobal', {
|
||||
isCausal: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import PublicLivechat from '@im_livechat/legacy/models/public_livechat';
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import { deleteCookie, setCookie } from 'web.utils.cookies';
|
||||
|
||||
registerModel({
|
||||
name: 'PublicLivechat',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.update({
|
||||
widget: new PublicLivechat(this.messaging, {
|
||||
parent: this.publicLivechatGlobalOwner.livechatButtonView.widget,
|
||||
data: this.data,
|
||||
}),
|
||||
});
|
||||
},
|
||||
_willDelete() {
|
||||
this.widget.destroy();
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
async createLivechatChannel() {
|
||||
const livechatData = await this.messaging.rpc({
|
||||
route: "/im_livechat/get_session",
|
||||
params: this.messaging.publicLivechatGlobal.livechatButtonView.widget._prepareGetSessionParameters(),
|
||||
});
|
||||
if (!livechatData || !livechatData.operator_pid) {
|
||||
this.update({ data: clear() });
|
||||
deleteCookie("im_livechat_session");
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.renderChatWindow();
|
||||
} else {
|
||||
this.update({ data: livechatData });
|
||||
this.widget.data = livechatData;
|
||||
this.updateSessionCookie();
|
||||
}
|
||||
},
|
||||
updateSessionCookie() {
|
||||
deleteCookie("im_livechat_session");
|
||||
setCookie(
|
||||
"im_livechat_session",
|
||||
encodeURIComponent(JSON.stringify(this.widget.toData()), true),
|
||||
60 * 60,
|
||||
"required"
|
||||
);
|
||||
setCookie("im_livechat_auto_popup", JSON.stringify(false), 60 * 60, "optional");
|
||||
if (this.operator) {
|
||||
const operatorPidId = this.operator.id;
|
||||
const oneWeek = 7 * 24 * 60 * 60;
|
||||
setCookie("im_livechat_previous_operator_pid", operatorPidId, oneWeek, "optional");
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
data: attr(),
|
||||
id: attr({
|
||||
compute() {
|
||||
if (!this.data) {
|
||||
return clear();
|
||||
}
|
||||
return this.data.id;
|
||||
},
|
||||
}),
|
||||
isFolded: attr({
|
||||
default: false,
|
||||
}),
|
||||
isTemporary: attr({
|
||||
compute() {
|
||||
if (!this.data || !this.data.id) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
publicLivechatGlobalOwner: one('PublicLivechatGlobal', {
|
||||
identifying: true,
|
||||
inverse: 'publicLivechat',
|
||||
}),
|
||||
name: attr({
|
||||
compute() {
|
||||
if (!this.data || !this.operator) {
|
||||
return clear();
|
||||
}
|
||||
return this.data.name;
|
||||
},
|
||||
}),
|
||||
operator: one('LivechatOperator', {
|
||||
compute() {
|
||||
if (!this.data) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.data.operator_pid) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.data.operator_pid[0]) {
|
||||
return clear();
|
||||
}
|
||||
return {
|
||||
id: this.data.operator_pid[0],
|
||||
name: this.data.operator_pid[1],
|
||||
};
|
||||
},
|
||||
}),
|
||||
status: attr({
|
||||
compute() {
|
||||
if (!this.data) {
|
||||
return clear();
|
||||
}
|
||||
return this.data.status || '';
|
||||
},
|
||||
}),
|
||||
// amount of messages that have not yet been read on this chat
|
||||
unreadCounter: attr({
|
||||
default: 0,
|
||||
}),
|
||||
uuid: attr({
|
||||
compute() {
|
||||
if (!this.data) {
|
||||
return clear();
|
||||
}
|
||||
return this.data.uuid;
|
||||
},
|
||||
}),
|
||||
widget: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import Feedback from '@im_livechat/legacy/widgets/feedback/feedback';
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'PublicLivechatFeedbackView',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.update({
|
||||
widget: new Feedback(
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.widget,
|
||||
this.messaging,
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget,
|
||||
),
|
||||
});
|
||||
this.messaging.publicLivechatGlobal.chatWindow.widget.replaceContentWith(this.widget);
|
||||
this.widget.on('feedback_sent', null, this._onFeedbackSent);
|
||||
this.widget.on('send_message', null, this._onSendMessage);
|
||||
},
|
||||
_willDelete() {
|
||||
this.widget.destroy();
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
_onFeedbackSent() {
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.closeChat();
|
||||
},
|
||||
_onSendMessage(...args) {
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.sendMessage(...args);
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
publicLivechatGlobalOwner: one('PublicLivechatGlobal', {
|
||||
identifying: true,
|
||||
inverse: 'feedbackView',
|
||||
}),
|
||||
widget: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, many, one } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import { session } from "@web/session";
|
||||
import legacySession from "web.session";
|
||||
|
||||
import { qweb } from 'web.core';
|
||||
import { Markup } from 'web.utils';
|
||||
import {getCookie, setCookie, deleteCookie} from 'web.utils.cookies';
|
||||
|
||||
registerModel({
|
||||
name: 'PublicLivechatGlobal',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
// History tracking
|
||||
const page = window.location.href.replace(/^.*\/\/[^/]+/, '');
|
||||
const pageHistory = getCookie(this.LIVECHAT_COOKIE_HISTORY);
|
||||
let urlHistory = [];
|
||||
if (pageHistory) {
|
||||
urlHistory = JSON.parse(pageHistory) || [];
|
||||
}
|
||||
if (!_.contains(urlHistory, page)) {
|
||||
urlHistory.push(page);
|
||||
while (urlHistory.length > this.HISTORY_LIMIT) {
|
||||
urlHistory.shift();
|
||||
}
|
||||
setCookie(this.LIVECHAT_COOKIE_HISTORY, JSON.stringify(urlHistory), 60 * 60 * 24, 'optional'); // 1 day cookie
|
||||
}
|
||||
if (this.isAvailable) {
|
||||
this.willStart();
|
||||
}
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
async loadQWebTemplate() {
|
||||
const templates = await this.messaging.rpc({ route: '/im_livechat/load_templates' });
|
||||
for (const template of templates) {
|
||||
qweb.add_template(template);
|
||||
}
|
||||
this.update({ hasLoadedQWebTemplate: true });
|
||||
},
|
||||
async willStart() {
|
||||
await this._willStart();
|
||||
await this._willStartChatbot();
|
||||
},
|
||||
async _willStart() {
|
||||
const strCookie = decodeURIComponent(getCookie('im_livechat_session'));
|
||||
let isSessionCookieAvailable = Boolean(strCookie);
|
||||
let cookie = JSON.parse(strCookie || '{}');
|
||||
if (isSessionCookieAvailable && (cookie.visitor_uid !== session.user_id || !cookie.id)) {
|
||||
this.leaveSession();
|
||||
isSessionCookieAvailable = false;
|
||||
cookie = {};
|
||||
}
|
||||
if (cookie.id) {
|
||||
const history = await this.messaging.rpc({
|
||||
route: '/mail/chat_history',
|
||||
params: { uuid: cookie.uuid, limit: 100 },
|
||||
});
|
||||
history.reverse();
|
||||
this.update({ history });
|
||||
for (const message of this.history) {
|
||||
message.body = Markup(message.body);
|
||||
}
|
||||
this.update({ isAvailableForMe: true });
|
||||
} else {
|
||||
const result = await this.messaging.rpc({
|
||||
route: '/im_livechat/init',
|
||||
params: { channel_id: this.channelId },
|
||||
});
|
||||
if (result.available_for_me) {
|
||||
this.update({ isAvailableForMe: true });
|
||||
}
|
||||
this.update({ rule: result.rule });
|
||||
}
|
||||
const proms = [this.loadQWebTemplate()];
|
||||
if (!session.is_frontend) {
|
||||
proms.push(legacySession.load_translations(["im_livechat"]));
|
||||
}
|
||||
return Promise.all(proms);
|
||||
},
|
||||
/**
|
||||
* This override handles the following use cases:
|
||||
*
|
||||
* - If the chat is started for the first time (first visit of a visitor)
|
||||
* We register the chatbot configuration and the rest of the behavior is triggered by various
|
||||
* method overrides ('sendWelcomeMessage', 'sendMessage', ...)
|
||||
*
|
||||
* - If the chat has been started before, but the user did not interact with the bot
|
||||
* In addition, we fetch the configuration (with a '/init' call), to see if we have a bot
|
||||
* configured.
|
||||
* Indeed we want to trigger the bot script on every page where the associated rule is matched.
|
||||
*
|
||||
* - If we have a non-empty chat history, resume the chat script where the end-user left it by
|
||||
* fetching the necessary information from the local storage.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async _willStartChatbot() {
|
||||
if (this.rule) {
|
||||
// noop
|
||||
} else if (this.history !== null && this.history.length === 0) {
|
||||
this.update({
|
||||
livechatInit: await this.messaging.rpc({
|
||||
route: '/im_livechat/init',
|
||||
params: { channel_id: this.channelId },
|
||||
}),
|
||||
});
|
||||
} else if (this.history !== null && this.history.length !== 0) {
|
||||
const sessionCookie = decodeURIComponent(getCookie('im_livechat_session'));
|
||||
if (sessionCookie) {
|
||||
this.update({ sessionCookie });
|
||||
}
|
||||
}
|
||||
|
||||
if (this.chatbot.state === 'init') {
|
||||
// we landed on a website page where a channel rule is configured to run a chatbot.script
|
||||
// -> initialize necessary state
|
||||
if (this.rule.chatbot_welcome_steps && this.rule.chatbot_welcome_steps.length !== 0) {
|
||||
this.chatbot.update({
|
||||
currentStep: {
|
||||
data: this.chatbot.lastWelcomeStep,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (this.chatbot.state === 'welcome') {
|
||||
// we landed on a website page and a chatbot script was initialized on a previous one
|
||||
// however the end-user did not interact with the bot ( :( )
|
||||
// -> remove cookie to force opening the popup again
|
||||
// -> initialize necessary state
|
||||
// -> batch welcome message (see '_sendWelcomeChatbotMessage')
|
||||
deleteCookie('im_livechat_auto_popup');
|
||||
this.update({ history: clear() });
|
||||
this.update({ rule: this.livechatInit.rule });
|
||||
} else if (this.chatbot.state === 'restore_session') {
|
||||
// we landed on a website page and a chatbot script is currently running
|
||||
// -> restore the user's session (see 'Chatbot/restoreSession')
|
||||
this.chatbot.restoreSession();
|
||||
}
|
||||
},
|
||||
|
||||
getVisitorUserId() {
|
||||
const cookie = JSON.parse(decodeURIComponent(getCookie("im_livechat_session")) || "{}");
|
||||
if ("visitor_uid" in cookie) {
|
||||
return cookie.visitor_uid;
|
||||
}
|
||||
return session.user_id;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 = decodeURIComponent(getCookie('im_livechat_session'));
|
||||
if (cookie) {
|
||||
const channel = JSON.parse(cookie);
|
||||
if (channel.uuid) {
|
||||
this.messaging.rpc({ route: '/im_livechat/visitor_leave_session', params: { uuid: channel.uuid } });
|
||||
}
|
||||
deleteCookie('im_livechat_session');
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
HISTORY_LIMIT: attr({
|
||||
default: 15,
|
||||
}),
|
||||
LIVECHAT_COOKIE_HISTORY: attr({
|
||||
default: 'im_livechat_history',
|
||||
}),
|
||||
RATING_TO_EMOJI: attr({
|
||||
default: {
|
||||
5: "😊",
|
||||
3: "😐",
|
||||
1: "😞",
|
||||
},
|
||||
}),
|
||||
channelId: attr({
|
||||
compute() {
|
||||
return this.options.channel_id;
|
||||
},
|
||||
}),
|
||||
chatbot: one('Chatbot', {
|
||||
default: {},
|
||||
inverse: 'publicLivechatGlobalOwner',
|
||||
}),
|
||||
chatWindow: one('PublicLivechatWindow', {
|
||||
inverse: 'publicLivechatGlobalOwner',
|
||||
}),
|
||||
feedbackView: one('PublicLivechatFeedbackView', {
|
||||
inverse: 'publicLivechatGlobalOwner',
|
||||
}),
|
||||
hasLoadedQWebTemplate: attr({
|
||||
default: false,
|
||||
}),
|
||||
hasWebsiteLivechatFeature: attr({
|
||||
compute() {
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
history: attr({
|
||||
default: null,
|
||||
}),
|
||||
isAvailable: attr({
|
||||
default: false,
|
||||
}),
|
||||
isAvailableForMe: attr({
|
||||
default: false,
|
||||
}),
|
||||
isLastMessageFromCustomer: attr({
|
||||
/**
|
||||
* Compares the last message of the conversation to this livechat's operator id.
|
||||
*/
|
||||
compute() {
|
||||
if (!this.lastMessage) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.publicLivechat) {
|
||||
return clear();
|
||||
}
|
||||
if (!this.publicLivechat.operator) {
|
||||
return clear();
|
||||
}
|
||||
return this.lastMessage.authorId !== this.publicLivechat.operator.id;
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
isTestChatbot: attr({
|
||||
compute() {
|
||||
if (!this.options) {
|
||||
return clear();
|
||||
}
|
||||
return Boolean(this.options.isTestChatbot);
|
||||
},
|
||||
default: false,
|
||||
}),
|
||||
lastMessage: one('PublicLivechatMessage', {
|
||||
compute() {
|
||||
if (this.messages.length === 0) {
|
||||
return clear();
|
||||
}
|
||||
return this.messages[this.messages.length - 1];
|
||||
},
|
||||
}),
|
||||
livechatButtonView: one('LivechatButtonView', {
|
||||
compute() {
|
||||
if (this.isAvailable && (this.isAvailableForMe || this.isTestChatbot) && this.hasLoadedQWebTemplate && this.env.services.public_livechat_service) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
inverse: 'publicLivechatGlobalOwner',
|
||||
}),
|
||||
livechatInit: attr(),
|
||||
messages: many('PublicLivechatMessage'),
|
||||
notificationHandler: one('PublicLivechatGlobalNotificationHandler', {
|
||||
inverse: 'publicLivechatGlobalOwner',
|
||||
compute() {
|
||||
if (this.publicLivechat && !this.publicLivechat.isTemporary) {
|
||||
return {};
|
||||
}
|
||||
return clear();
|
||||
}
|
||||
}),
|
||||
options: attr({
|
||||
default: {},
|
||||
}),
|
||||
publicLivechat: one('PublicLivechat', {
|
||||
inverse: 'publicLivechatGlobalOwner',
|
||||
}),
|
||||
rule: attr(),
|
||||
serverUrl: attr({
|
||||
default: '',
|
||||
}),
|
||||
sessionCookie: attr(),
|
||||
testChatbotData: attr({
|
||||
compute() {
|
||||
if (!this.options) {
|
||||
return clear();
|
||||
}
|
||||
return this.options.testChatbotData;
|
||||
},
|
||||
}),
|
||||
welcomeMessages: many('PublicLivechatMessage', {
|
||||
compute() {
|
||||
return this.messages.filter((message) => {
|
||||
return message.id && typeof message.id === 'string' && message.id.startsWith('_welcome_');
|
||||
});
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
import { increment } from '@mail/model/model_field_command';
|
||||
|
||||
import session from 'web.session';
|
||||
import utils from 'web.utils';
|
||||
import {getCookie} from 'web.utils.cookies';
|
||||
|
||||
registerModel({
|
||||
name: 'PublicLivechatGlobalNotificationHandler',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.env.services['bus_service'].addChannel(this.messaging.publicLivechatGlobal.publicLivechat.uuid);
|
||||
this.env.services['bus_service'].addEventListener('notification', this._onNotification);
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} notification
|
||||
* @param {Object} notification.payload
|
||||
* @param {string} notification.type
|
||||
*/
|
||||
_handleNotification({ payload, type }) {
|
||||
switch (type) {
|
||||
case 'im_livechat.history_command': {
|
||||
if (payload.id !== this.messaging.publicLivechatGlobal.publicLivechat.id) {
|
||||
return;
|
||||
}
|
||||
const cookie = getCookie(this.messaging.publicLivechatGlobal.LIVECHAT_COOKIE_HISTORY);
|
||||
const history = cookie ? JSON.parse(cookie) : [];
|
||||
session.rpc('/im_livechat/history', {
|
||||
pid: this.messaging.publicLivechatGlobal.publicLivechat.operator.id,
|
||||
channel_uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
|
||||
page_history: history,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'mail.channel.member/typing_status': {
|
||||
if (!this.messaging.publicLivechatGlobal.chatWindow || !this.messaging.publicLivechatGlobal.chatWindow.exists()) {
|
||||
return;
|
||||
}
|
||||
const channelMemberData = payload;
|
||||
if (channelMemberData.channel.id !== this.messaging.publicLivechatGlobal.publicLivechat.id) {
|
||||
return;
|
||||
}
|
||||
if (!channelMemberData.persona.partner) {
|
||||
return;
|
||||
}
|
||||
if (channelMemberData.persona.partner.id === this.messaging.publicLivechatGlobal.livechatButtonView.currentPartnerId) {
|
||||
// ignore typing display of current partner.
|
||||
return;
|
||||
}
|
||||
if (channelMemberData.isTyping) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget.registerTyping({ partnerID: channelMemberData.persona.partner.id });
|
||||
} else {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget.unregisterTyping({ partnerID: channelMemberData.persona.partner.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'mail.channel/new_message': {
|
||||
if (!this.messaging.publicLivechatGlobal.chatWindow || !this.messaging.publicLivechatGlobal.chatWindow.exists()) {
|
||||
return;
|
||||
}
|
||||
if (payload.id !== this.messaging.publicLivechatGlobal.publicLivechat.id) {
|
||||
return;
|
||||
}
|
||||
const notificationData = payload.message;
|
||||
// If message from notif is already in chatter messages, stop handling
|
||||
if (this.messaging.publicLivechatGlobal.messages.some(message => message.id === notificationData.id)) {
|
||||
return;
|
||||
}
|
||||
notificationData.body = utils.Markup(notificationData.body);
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.addMessage(notificationData);
|
||||
if (this.messaging.publicLivechatGlobal.publicLivechat.isFolded || !this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.isAtBottom()) {
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.update({ unreadCounter: increment() });
|
||||
}
|
||||
this.messaging.publicLivechatGlobal.chatWindow.renderMessages();
|
||||
return;
|
||||
}
|
||||
case 'mail.message/insert': {
|
||||
if (!this.messaging.publicLivechatGlobal.chatWindow || !this.messaging.publicLivechatGlobal.chatWindow.exists()) {
|
||||
return;
|
||||
}
|
||||
const message = this.messaging.publicLivechatGlobal.messages.find(message => message.id === payload.id);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
message.widget._body = utils.Markup(payload.body);
|
||||
this.messaging.publicLivechatGlobal.chatWindow.renderMessages();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {CustomEvent} ev
|
||||
* @param {Array[]} [ev.detail] Notifications coming from the bus.
|
||||
*/
|
||||
_onNotification({ detail: notifications }) {
|
||||
for (const notification of notifications) {
|
||||
this._handleNotification(notification);
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
publicLivechatGlobalOwner: one('PublicLivechatGlobal', {
|
||||
identifying: true,
|
||||
inverse: 'notificationHandler',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
import PublicLivechatMessage from '@im_livechat/legacy/models/public_livechat_message';
|
||||
|
||||
registerModel({
|
||||
name: 'PublicLivechatMessage',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.update({ widget: new PublicLivechatMessage(this.messaging.publicLivechatGlobal.livechatButtonView.widget, this.messaging, this.data) });
|
||||
},
|
||||
_willDelete() {
|
||||
this.widget.destroy();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
authorId: attr({
|
||||
compute() {
|
||||
if (this.data.author && this.data.author.id) {
|
||||
return this.data.author.id;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
}),
|
||||
data: attr(),
|
||||
id: attr({
|
||||
identifying: true,
|
||||
}),
|
||||
widget: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/** @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',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.update({
|
||||
widget: new PublicLivechatView(this, this.messaging, { displayMarkAsRead: false }),
|
||||
});
|
||||
},
|
||||
_willDelete() {
|
||||
this.widget.destroy();
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
publicLivechatWindowOwner: one('PublicLivechatWindow', {
|
||||
identifying: true,
|
||||
inverse: 'publicLivechatView',
|
||||
}),
|
||||
widget: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import PublicLivechatWindow from '@im_livechat/legacy/widgets/public_livechat_window/public_livechat_window';
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { attr, one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'PublicLivechatWindow',
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
this.update({
|
||||
widget: new PublicLivechatWindow(
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.widget,
|
||||
this.messaging,
|
||||
this.messaging.publicLivechatGlobal.publicLivechat.widget,
|
||||
),
|
||||
});
|
||||
},
|
||||
_willDelete() {
|
||||
this.widget.destroy();
|
||||
},
|
||||
},
|
||||
recordMethods: {
|
||||
enableInput() {
|
||||
const $composerTextField = this.widget.$('.o_composer_text_field');
|
||||
$composerTextField
|
||||
.prop('disabled', false)
|
||||
.removeClass('text-center fst-italic bg-200')
|
||||
.val('')
|
||||
.focus();
|
||||
|
||||
$composerTextField.off('keydown', this.messaging.publicLivechatGlobal.chatbot.onKeydownInput);
|
||||
if (this.messaging.publicLivechatGlobal.chatbot.currentStep.data.chatbot_step_type === 'free_input_multi') {
|
||||
$composerTextField.on('keydown', this.messaging.publicLivechatGlobal.chatbot.onKeydownInput);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Disable the input allowing the user to type.
|
||||
* This is typically used when we want to force him to click on one of the chatbot options.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
disableInput(disableText) {
|
||||
this.widget.$('.o_composer_text_field')
|
||||
.prop('disabled', true)
|
||||
.addClass('text-center fst-italic bg-200')
|
||||
.val(disableText);
|
||||
},
|
||||
renderMessages() {
|
||||
const shouldScroll = !this.isFolded && this.publicLivechatView.widget.isAtBottom();
|
||||
this.widget.render();
|
||||
if (shouldScroll) {
|
||||
this.publicLivechatView.widget.scrollToBottom();
|
||||
}
|
||||
const self = this;
|
||||
|
||||
this.widget.$('.o_thread_message:last .o_livechat_chatbot_options li').each(function () {
|
||||
$(this).on('click', self.messaging.publicLivechatGlobal.livechatButtonView.widget._onChatbotOptionClicked.bind(self.messaging.publicLivechatGlobal.livechatButtonView.widget));
|
||||
});
|
||||
|
||||
this.widget.$('.o_livechat_chatbot_main_restart').on('click', (ev) => {
|
||||
ev.stopPropagation(); // prevent fold behaviour
|
||||
this.messaging.publicLivechatGlobal.livechatButtonView.onChatbotRestartScript(ev);
|
||||
});
|
||||
|
||||
if (this.messaging.publicLivechatGlobal.messages.length !== 0) {
|
||||
const lastMessage = this.messaging.publicLivechatGlobal.lastMessage;
|
||||
const stepAnswers = lastMessage.widget.getChatbotStepAnswers();
|
||||
if (stepAnswers && stepAnswers.length !== 0 && !lastMessage.widget.getChatbotStepAnswerId()) {
|
||||
this.disableInput(this.env._t("Select an option above"));
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
inputPlaceholder: attr({
|
||||
compute() {
|
||||
if (this.messaging.publicLivechatGlobal.livechatButtonView.inputPlaceholder) {
|
||||
return this.messaging.publicLivechatGlobal.livechatButtonView.inputPlaceholder;
|
||||
}
|
||||
return this.env._t("Say something");
|
||||
},
|
||||
}),
|
||||
publicLivechatGlobalOwner: one('PublicLivechatGlobal', {
|
||||
identifying: true,
|
||||
inverse: 'chatWindow',
|
||||
}),
|
||||
publicLivechatView: one('PublicLivechatView', {
|
||||
default: {},
|
||||
inverse: 'publicLivechatWindowOwner',
|
||||
}),
|
||||
widget: attr(),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
.text-muted {
|
||||
color: map-get($grays, '600');
|
||||
}
|
||||
|
||||
.text-start {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_thread_window {
|
||||
&,* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.o_thread_window_header {
|
||||
height: 35px;
|
||||
.fa-close {
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
&:before {
|
||||
content: "\00d7";
|
||||
font-size: initial;
|
||||
}
|
||||
}
|
||||
> span {
|
||||
margin: auto 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_email_chat_button:after {
|
||||
content:' \27A4';
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap classnames are not available in external lib
|
||||
// These are equivalent scss rules so visually it still works.
|
||||
.o_livechat_chatbot_end {
|
||||
// @extend .bg-200;
|
||||
font-style: italic !important; // @extend .fst-italic;
|
||||
text-align: center !important; // @extend .text-center;
|
||||
border: 1px solid #dee2e6 !important; // @extend .border;
|
||||
}
|
||||
|
||||
.o_livechat_chatbot_stepAnswer {
|
||||
display: inline-block !important; // @extend .d-inline-block;
|
||||
border: 1px solid #dee2e6 !important; // @extend .border;
|
||||
border-color: #017e84 !important; // @extend .border-primary;
|
||||
border-radius: 0.25rem !important; // @extend .rounded;
|
||||
padding: 0.5rem !important; // @extend .p-2;
|
||||
margin-right: 1rem !important; // @extend .me-3;
|
||||
margin-bottom: 0.25rem !important; // @extend .mb-1;
|
||||
font-weight: 700 !important; // @extend .fw-bold;
|
||||
}
|
||||
|
||||
.o_livechat_chatbot_options li:not(.disabled):hover {
|
||||
background-color: #017e84 !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
.o_livechat_rules_form {
|
||||
.o_form_sheet_bg {
|
||||
.o_form_sheet {
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_form_view .o_group {
|
||||
// long selector in order to be more specific...
|
||||
.o_im_livechat_field_widget_color {
|
||||
width: 30px;
|
||||
margin: 0 5px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
.o_history_container{
|
||||
table-layout: fixed;
|
||||
width: 100% !important;
|
||||
> tbody > tr > td {
|
||||
padding: 0px !important;
|
||||
}
|
||||
.o_history_kanban_container {
|
||||
text-align:center;
|
||||
.o_history_kanban_sub_container {
|
||||
.o_kanban_ungrouped {
|
||||
max-height:500px;
|
||||
overflow:auto;
|
||||
padding:2px 0px;
|
||||
.rounded-circle {
|
||||
width:32px;
|
||||
height: 32px;
|
||||
}
|
||||
.o_kanban_record {
|
||||
padding: 0px 8px;
|
||||
margin: -1px 8px;
|
||||
min-width: 300px;
|
||||
width: 98%;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
.oe_module_vignette {
|
||||
text-align:left;
|
||||
}
|
||||
.o_kanban_image {
|
||||
padding-top: 8px;
|
||||
}
|
||||
.oe_module_desc {
|
||||
padding: 8px 8px 0px 64px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import LivechatButton from '@im_livechat/legacy/widgets/livechat_button';
|
||||
|
||||
import rootWidget from 'root.widget';
|
||||
|
||||
import {getCookie, deleteCookie} from 'web.utils.cookies';
|
||||
|
||||
export const publicLivechatService = {
|
||||
dependencies: ['messaging'],
|
||||
async start(env, { messaging: messagingService }) {
|
||||
const messaging = await messagingService.get();
|
||||
try {
|
||||
JSON.parse(decodeURIComponent(getCookie('im_livechat_session')));
|
||||
} catch {
|
||||
// Cookies are not supposed to contain non-ASCII characters.
|
||||
// However, some were set in the past. Let's clean them up.
|
||||
deleteCookie('im_livechat_session');
|
||||
}
|
||||
return {
|
||||
mountLivechatButton() {
|
||||
const livechatButton = new LivechatButton(rootWidget, messaging);
|
||||
livechatButton.appendTo(document.body).catch(error => {
|
||||
console.info("Can't load 'LivechatButton' because:", error);
|
||||
});
|
||||
return livechatButton;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import '@mail/../tests/helpers/mock_server'; // ensure mail overrides are applied first
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { MockServer } from "@web/../tests/helpers/mock_server";
|
||||
|
||||
patch(MockServer.prototype, 'im_livechat', {
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _performRPC(route, args) {
|
||||
if (route === '/im_livechat/get_session') {
|
||||
const channel_id = args.channel_id;
|
||||
const anonymous_name = args.anonymous_name;
|
||||
const previous_operator_id = args.previous_operator_id;
|
||||
const context = args.context;
|
||||
return this._mockRouteImLivechatGetSession(channel_id, anonymous_name, previous_operator_id, context);
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private Mocked Routes
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simulates the `/im_livechat/get_session` route.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} channel_id
|
||||
* @param {string} anonymous_name
|
||||
* @param {integer} [previous_operator_id]
|
||||
* @param {Object} [context={}]
|
||||
* @returns {Object}
|
||||
*/
|
||||
_mockRouteImLivechatGetSession(channel_id, anonymous_name, previous_operator_id, context = {}) {
|
||||
let user_id;
|
||||
let country_id;
|
||||
if ('mockedUserId' in context) {
|
||||
// can be falsy to simulate not being logged in
|
||||
user_id = context.mockedUserId;
|
||||
} else {
|
||||
user_id = this.currentUserId;
|
||||
}
|
||||
// don't use the anonymous name if the user is logged in
|
||||
if (user_id) {
|
||||
const user = this.getRecords('res.users', [['id', '=', user_id]])[0];
|
||||
country_id = user.country_id;
|
||||
} else {
|
||||
// simulate geoip
|
||||
const countryCode = context.mockedCountryCode;
|
||||
const country = this.getRecords('res.country', [['code', '=', countryCode]])[0];
|
||||
if (country) {
|
||||
country_id = country.id;
|
||||
anonymous_name = anonymous_name + ' (' + country.name + ')';
|
||||
}
|
||||
}
|
||||
return this._mockImLivechatChannel_openLivechatMailChannel(channel_id, anonymous_name, previous_operator_id, user_id, country_id);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private Mocked Methods
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_mockMailChannelChannelInfo(ids) {
|
||||
const channelInfos = this._super(...arguments);
|
||||
for (const channelInfo of channelInfos) {
|
||||
const channel = this.getRecords('mail.channel', [['id', '=', channelInfo.id]])[0];
|
||||
channelInfo['channel']['anonymous_name'] = channel.anonymous_name;
|
||||
// add the last message date
|
||||
if (channel.channel_type === 'livechat') {
|
||||
// add the operator id
|
||||
if (channel.livechat_operator_id) {
|
||||
const operator = this.getRecords('res.partner', [['id', '=', channel.livechat_operator_id]])[0];
|
||||
// livechat_username ignored for simplicity
|
||||
channelInfo.operator_pid = [operator.id, operator.display_name.replace(',', '')];
|
||||
}
|
||||
}
|
||||
}
|
||||
return channelInfos;
|
||||
},
|
||||
/**
|
||||
* Simulates `_get_available_users` on `im_livechat.channel`.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} id
|
||||
* @returns {Object}
|
||||
*/
|
||||
_mockImLivechatChannel_getAvailableUsers(id) {
|
||||
const livechatChannel = this.getRecords('im_livechat.channel', [['id', '=', id]])[0];
|
||||
const users = this.getRecords('res.users', [['id', 'in', livechatChannel.user_ids]]);
|
||||
return users.filter(user => user.im_status === 'online');
|
||||
},
|
||||
/**
|
||||
* Simulates `_get_livechat_mail_channel_vals` on `im_livechat.channel`.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} id
|
||||
* @returns {Object}
|
||||
*/
|
||||
_mockImLivechatChannel_getLivechatMailChannelVals(id, anonymous_name, operator, user_id, country_id) {
|
||||
// partner to add to the mail.channel
|
||||
const operator_partner_id = operator.partner_id;
|
||||
const membersToAdd = [[0, 0, {
|
||||
is_pinned: false,
|
||||
partner_id: operator_partner_id,
|
||||
}]];
|
||||
let visitor_user;
|
||||
if (user_id) {
|
||||
const visitor_user = this.getRecords('res.users', [['id', '=', user_id]])[0];
|
||||
if (visitor_user && visitor_user.active && visitor_user !== operator) {
|
||||
// valid session user (not public)
|
||||
membersToAdd.push([0, 0, { partner_id: visitor_user.partner_id.id }]);
|
||||
}
|
||||
} else {
|
||||
membersToAdd.push([0, 0, { partner_id: this.publicPartnerId }]);
|
||||
}
|
||||
const membersName = [
|
||||
visitor_user ? visitor_user.display_name : anonymous_name,
|
||||
operator.livechat_username ? operator.livechat_username : operator.name,
|
||||
];
|
||||
return {
|
||||
'channel_member_ids': membersToAdd,
|
||||
'livechat_active': true,
|
||||
'livechat_operator_id': operator_partner_id,
|
||||
'livechat_channel_id': id,
|
||||
'anonymous_name': user_id ? false : anonymous_name,
|
||||
'country_id': country_id,
|
||||
'channel_type': 'livechat',
|
||||
'name': membersName.join(' '),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Simulates `_get_random_operator` on `im_livechat.channel`.
|
||||
* Simplified mock implementation: returns the first available operator.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} id
|
||||
* @returns {Object}
|
||||
*/
|
||||
_mockImLivechatChannel_getRandomOperator(id) {
|
||||
const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id);
|
||||
return availableUsers[0];
|
||||
},
|
||||
/**
|
||||
* Simulates `_open_livechat_mail_channel` on `im_livechat.channel`.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} id
|
||||
* @param {string} anonymous_name
|
||||
* @param {integer} [previous_operator_id]
|
||||
* @param {integer} [user_id]
|
||||
* @param {integer} [country_id]
|
||||
* @returns {Object}
|
||||
*/
|
||||
_mockImLivechatChannel_openLivechatMailChannel(id, anonymous_name, previous_operator_id, user_id, country_id) {
|
||||
let operator;
|
||||
if (previous_operator_id) {
|
||||
const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id);
|
||||
operator = availableUsers.find(user => user.partner_id === previous_operator_id);
|
||||
}
|
||||
if (!operator) {
|
||||
operator = this._mockImLivechatChannel_getRandomOperator(id);
|
||||
}
|
||||
if (!operator) {
|
||||
// no one available
|
||||
return false;
|
||||
}
|
||||
// create the session, and add the link with the given channel
|
||||
const mailChannelVals = this._mockImLivechatChannel_getLivechatMailChannelVals(id, anonymous_name, operator, user_id, country_id);
|
||||
const mailChannelId = this.pyEnv['mail.channel'].create(mailChannelVals);
|
||||
this._mockMailChannel_broadcast([mailChannelId], [operator.partner_id]);
|
||||
return this._mockMailChannelChannelInfo([mailChannelId])[0];
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_mockResPartner_GetChannelsAsMember(ids) {
|
||||
const partner = this.getRecords('res.partner', [['id', 'in', ids]])[0];
|
||||
const members = this.getRecords('mail.channel.member', [['partner_id', '=', partner.id], ['is_pinned', '=', true]]);
|
||||
const livechats = this.getRecords('mail.channel', [
|
||||
['channel_type', '=', 'livechat'],
|
||||
['channel_member_ids', 'in', members.map(member => member.id)],
|
||||
]);
|
||||
return [
|
||||
...this._super(ids),
|
||||
...livechats,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import '@mail/../tests/helpers/mock_server'; // ensure mail overrides are applied first
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { MockServer } from "@web/../tests/helpers/mock_server";
|
||||
|
||||
patch(MockServer.prototype, 'im_livechat/controllers/main', {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _performRPC(route, args) {
|
||||
if (route === '/im_livechat/notify_typing') {
|
||||
const uuid = args.uuid;
|
||||
const is_typing = args.is_typing;
|
||||
const context = args.context;
|
||||
return this._mockRouteImLivechatNotifyTyping(uuid, is_typing, context);
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
/**
|
||||
* Simulates the `/im_livechat/notify_typing` route.
|
||||
*
|
||||
* @private
|
||||
* @param {string} uuid
|
||||
* @param {boolean} is_typing
|
||||
* @param {Object} [context={}]
|
||||
*/
|
||||
_mockRouteImLivechatNotifyTyping(uuid, is_typing, context = {}) {
|
||||
const [mailChannel] = this.getRecords('mail.channel', [['uuid', '=', uuid]]);
|
||||
const partnerId = context.mockedPartnerId || this.currentPartnerId;
|
||||
const [memberOfCurrentUser] = this.getRecords('mail.channel.member', [['channel_id', '=', mailChannel.id], ['partner_id', '=', partnerId]]);
|
||||
this._mockMailChannelMember_NotifyTyping([memberOfCurrentUser.id], is_typing);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import '@mail/../tests/helpers/mock_server/models/mail_channel_member'; // ensure mail overrides are applied first
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { MockServer } from "@web/../tests/helpers/mock_server";
|
||||
|
||||
patch(MockServer.prototype, 'im_livechat/models/mail_channel_member', {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_mockMailChannelMember_GetPartnerData(ids) {
|
||||
const [member] = this.getRecords('mail.channel.member', [['id', 'in', ids]]);
|
||||
const [channel] = this.getRecords('mail.channel', [['id', '=', member.channel_id]]);
|
||||
const [partner] = this.getRecords('res.partner', [['id', '=', member.partner_id]], { active_test: false });
|
||||
if (channel.channel_type === 'livechat') {
|
||||
const data = {
|
||||
'id': partner.id,
|
||||
'is_public': partner.is_public,
|
||||
};
|
||||
if (partner.user_livechat_username) {
|
||||
data['user_livechat_username'] = partner.user_livechat_username;
|
||||
} else {
|
||||
data['name'] = partner.name;
|
||||
}
|
||||
if (!partner.is_public) {
|
||||
const [country] = this.getRecords('res.country', [['id', '=', partner.country_id]]);
|
||||
data['country'] = country
|
||||
? {
|
||||
'code': country.code,
|
||||
'id': country.id,
|
||||
'name': country.name,
|
||||
}
|
||||
: [['clear']];
|
||||
}
|
||||
return data;
|
||||
}
|
||||
return this._super(ids);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { addModelNamesToFetch, insertModelFields } from '@bus/../tests/helpers/model_definitions_helpers';
|
||||
|
||||
addModelNamesToFetch(['im_livechat.channel']);
|
||||
insertModelFields('res.users.settings', {
|
||||
is_discuss_sidebar_category_livechat_open: { default: true },
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
afterNextRender,
|
||||
start,
|
||||
startServer,
|
||||
} from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('chat_window_manager', {}, function () {
|
||||
QUnit.module('chat_window_manager_tests.js');
|
||||
|
||||
QUnit.test('closing a chat window with no message from admin side unpins it', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const resPartnerId1 = pyEnv['res.partner'].create({ name: "Demo" });
|
||||
pyEnv['res.users'].create({ partner_id: resPartnerId1 });
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create(
|
||||
{
|
||||
channel_member_ids: [
|
||||
[0, 0, {
|
||||
is_pinned: true,
|
||||
partner_id: pyEnv.currentPartnerId,
|
||||
}],
|
||||
[0, 0, { partner_id: resPartnerId1 }],
|
||||
],
|
||||
channel_type: "livechat",
|
||||
uuid: 'channel-10-uuid',
|
||||
},
|
||||
);
|
||||
const { messaging } = await start();
|
||||
|
||||
await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
|
||||
await afterNextRender(() => document.querySelector(`.o_NotificationList_preview`).click());
|
||||
await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click());
|
||||
const channels = await messaging.rpc({
|
||||
model: 'mail.channel',
|
||||
method: 'channel_info',
|
||||
args: [mailChannelId1],
|
||||
}, { shadow: true });
|
||||
assert.strictEqual(
|
||||
channels[0].is_pinned,
|
||||
false,
|
||||
'Livechat channel should not be pinned',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { start, startServer } from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('composer_tests.js');
|
||||
|
||||
QUnit.test('livechat: no add attachment button', async function (assert) {
|
||||
// Attachments are not yet supported in livechat, especially from livechat
|
||||
// visitor PoV. This may likely change in the future with task-2029065.
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const livechatId = pyEnv['mail.channel'].create({ channel_type: 'livechat' });
|
||||
const { openDiscuss } = await start({
|
||||
discuss: {
|
||||
context: { active_id: livechatId },
|
||||
},
|
||||
});
|
||||
await openDiscuss();
|
||||
assert.containsOnce(document.body, '.o_Composer', "should have a composer");
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_Composer_buttonAttachment',
|
||||
"composer linked to livechat should not have a 'Add attachment' button"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat: disable attachment upload via drag and drop', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const livechatId = pyEnv['mail.channel'].create({ channel_type: 'livechat' });
|
||||
const { openDiscuss } = await start({
|
||||
discuss: {
|
||||
context: { active_id: livechatId },
|
||||
},
|
||||
});
|
||||
await openDiscuss();
|
||||
assert.containsOnce(document.body, '.o_Composer', "should have a composer");
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_Composer_dropZone',
|
||||
"composer linked to livechat should not have a dropzone"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
start,
|
||||
startServer,
|
||||
} from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('discuss_sidebar_category_item_tests.js');
|
||||
|
||||
QUnit.test('livechat - avatar: should have a smiley face avatar for an anonymous livechat item', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
const livechatItem = document.querySelector(`
|
||||
.o_DiscussSidebarCategoryItem[data-channel-id="${mailChannelId1}"]
|
||||
`);
|
||||
assert.containsOnce(
|
||||
livechatItem,
|
||||
`.o_DiscussSidebarCategoryItem_image`,
|
||||
"should have an avatar"
|
||||
);
|
||||
assert.strictEqual(
|
||||
livechatItem.querySelector(`:scope .o_DiscussSidebarCategoryItem_image`).dataset.src,
|
||||
'/mail/static/src/img/smiley/avatar.jpg',
|
||||
'should have the smiley face as the avatar for anonymous users'
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - avatar: should have a partner profile picture for a livechat item linked with a partner', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const resPartnerId1 = pyEnv['res.partner'].create({
|
||||
name: "Jean",
|
||||
});
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: resPartnerId1 }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
const livechatItem = document.querySelector(`
|
||||
.o_DiscussSidebarCategoryItem[data-channel-id="${mailChannelId1}"]
|
||||
`);
|
||||
assert.containsOnce(
|
||||
livechatItem,
|
||||
`.o_DiscussSidebarCategoryItem_image`,
|
||||
"should have an avatar"
|
||||
);
|
||||
assert.strictEqual(
|
||||
livechatItem.querySelector(`:scope .o_DiscussSidebarCategoryItem_image`).dataset.src,
|
||||
`/web/image/res.partner/${resPartnerId1}/avatar_128`,
|
||||
'should have the partner profile picture as the avatar for partners'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,449 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
afterNextRender,
|
||||
start,
|
||||
startServer,
|
||||
} from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('discuss_sidebar_category_tests.js');
|
||||
|
||||
QUnit.test('livechat - counter: should not have a counter if the category is unfolded and without unread messages', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
`.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_counter`,
|
||||
"should not have a counter if the category is unfolded and without unread messages",
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - counter: should not have a counter if the category is unfolded and with unread messages', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, {
|
||||
message_unread_counter: 10,
|
||||
partner_id: pyEnv.currentPartnerId,
|
||||
}],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
`.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_counter`,
|
||||
"should not have a counter if the category is unfolded and with unread messages",
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - counter: should not have a counter if category is folded and without unread messages', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
pyEnv['res.users.settings'].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_livechat_open: false,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
`.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_counter`,
|
||||
"should not have a counter if the category is folded and without unread messages"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - counter: should have correct value of unread threads if category is folded and with unread messages', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, {
|
||||
message_unread_counter: 10,
|
||||
partner_id: pyEnv.currentPartnerId,
|
||||
}],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
pyEnv['res.users.settings'].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_livechat_open: false,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelector(`.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_counter`).textContent,
|
||||
"1",
|
||||
"should have correct value of unread threads if category is folded and with unread messages"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - states: close manually by clicking the title', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
pyEnv['res.users.settings'].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_livechat_open: true,
|
||||
});
|
||||
const { messaging, openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`
|
||||
);
|
||||
|
||||
// fold the livechat category
|
||||
await afterNextRender(() =>
|
||||
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
|
||||
messaging.discuss.categoryLivechat.localId}"]
|
||||
.o_DiscussSidebarCategory_title
|
||||
`).click()
|
||||
);
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`,
|
||||
"Category livechat should be closed and the content should be invisible"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - states: open manually by clicking the title', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
pyEnv['res.users.settings'].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_livechat_open: false,
|
||||
});
|
||||
const { messaging, openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`
|
||||
);
|
||||
|
||||
// open the livechat category
|
||||
await afterNextRender(() =>
|
||||
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
|
||||
messaging.discuss.categoryLivechat.localId}"]
|
||||
.o_DiscussSidebarCategory_title
|
||||
`).click()
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`,
|
||||
"Category livechat should be open and the content should be visible"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - states: close should update the value on the server', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
pyEnv['res.users.settings'].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_livechat_open: true,
|
||||
});
|
||||
const currentUserId = pyEnv.currentUserId;
|
||||
const { messaging, openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
const initalSettings = await messaging.rpc({
|
||||
model: 'res.users.settings',
|
||||
method: '_find_or_create_for_user',
|
||||
args: [[currentUserId]],
|
||||
});
|
||||
assert.strictEqual(
|
||||
initalSettings.is_discuss_sidebar_category_livechat_open,
|
||||
true,
|
||||
"the value in server side should be true"
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
|
||||
messaging.discuss.categoryLivechat.localId}"]
|
||||
.o_DiscussSidebarCategory_title
|
||||
`).click()
|
||||
);
|
||||
const newSettings = await messaging.rpc({
|
||||
model: 'res.users.settings',
|
||||
method: '_find_or_create_for_user',
|
||||
args: [[currentUserId]],
|
||||
});
|
||||
assert.strictEqual(
|
||||
newSettings.is_discuss_sidebar_category_livechat_open,
|
||||
false,
|
||||
"the value in server side should be false"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - states: open should update the value on the server', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
pyEnv['res.users.settings'].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_livechat_open: false,
|
||||
});
|
||||
const currentUserId = pyEnv.currentUserId;
|
||||
const { messaging, openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
const initalSettings = await messaging.rpc({
|
||||
model: 'res.users.settings',
|
||||
method: '_find_or_create_for_user',
|
||||
args: [[currentUserId]],
|
||||
});
|
||||
assert.strictEqual(
|
||||
initalSettings.is_discuss_sidebar_category_livechat_open,
|
||||
false,
|
||||
"the value in server side should be false"
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
|
||||
messaging.discuss.categoryLivechat.localId}"]
|
||||
.o_DiscussSidebarCategory_title
|
||||
`).click()
|
||||
);
|
||||
const newSettings = await messaging.rpc({
|
||||
model: 'res.users.settings',
|
||||
method: '_find_or_create_for_user',
|
||||
args: [[currentUserId]],
|
||||
});
|
||||
assert.strictEqual(
|
||||
newSettings.is_discuss_sidebar_category_livechat_open,
|
||||
true,
|
||||
"the value in server side should be true"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - states: close from the bus', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const resUsersSettingsId1 = pyEnv['res.users.settings'].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_livechat_open: true,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`
|
||||
);
|
||||
|
||||
await afterNextRender(() => {
|
||||
pyEnv['bus.bus']._sendone(pyEnv.currentPartner, 'res.users.settings/insert', {
|
||||
id: resUsersSettingsId1,
|
||||
'is_discuss_sidebar_category_livechat_open': false,
|
||||
});
|
||||
});
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`,
|
||||
"Category livechat should be closed and the content should be invisible"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - states: open from the bus', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const resUsersSettingsId1 = pyEnv['res.users.settings'].create({
|
||||
user_id: pyEnv.currentUserId,
|
||||
is_discuss_sidebar_category_livechat_open: false,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`
|
||||
);
|
||||
|
||||
await afterNextRender(() => {
|
||||
pyEnv['bus.bus']._sendone(pyEnv.currentPartner, 'res.users.settings/insert', {
|
||||
id: resUsersSettingsId1,
|
||||
'is_discuss_sidebar_category_livechat_open': true,
|
||||
});
|
||||
});
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`,
|
||||
"Category livechat should be open and the content should be visible"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
QUnit.test('livechat - states: category item should be invisible if the category is closed', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { messaging, openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
|
||||
messaging.discuss.categoryLivechat.localId}"]
|
||||
.o_DiscussSidebarCategory_title
|
||||
`).click()
|
||||
);
|
||||
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`,
|
||||
"inactive item should be invisible if the category is folded"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat - states: the active category item should be visble even if the category is closed', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { messaging, openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`
|
||||
);
|
||||
|
||||
const livechat = document.querySelector(`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`);
|
||||
await afterNextRender(() => {
|
||||
livechat.click();
|
||||
});
|
||||
assert.ok(livechat.classList.contains('o-active'));
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector(`.o_DiscussSidebarCategory[data-category-local-id="${
|
||||
messaging.discuss.categoryLivechat.localId}"]
|
||||
.o_DiscussSidebarCategory_title
|
||||
`).click()
|
||||
);
|
||||
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_DiscussSidebarCategory_item[data-channel-id="${mailChannelId1}"]`,
|
||||
'the active livechat item should remain open even if the category is folded'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
afterNextRender,
|
||||
nextAnimationFrame,
|
||||
start,
|
||||
startServer,
|
||||
} from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
import { datetime_to_str } from 'web.time';
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('discuss_tests.js');
|
||||
|
||||
QUnit.test('livechat in the sidebar: basic rendering', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsOnce(document.body, '.o_Discuss_sidebar',
|
||||
"should have a sidebar section"
|
||||
);
|
||||
const groupLivechat = document.querySelector('.o_DiscussSidebar_categoryLivechat');
|
||||
assert.ok(groupLivechat,
|
||||
"should have a channel group livechat"
|
||||
);
|
||||
const titleText = groupLivechat.querySelector('.o_DiscussSidebarCategory_titleText');
|
||||
assert.strictEqual(
|
||||
titleText.textContent.trim(),
|
||||
"Livechat",
|
||||
"should have a channel group named 'Livechat'"
|
||||
);
|
||||
const livechat = groupLivechat.querySelector(`
|
||||
.o_DiscussSidebarCategoryItem[data-channel-id="${mailChannelId1}"]
|
||||
`);
|
||||
assert.ok(
|
||||
livechat,
|
||||
"should have a livechat in sidebar"
|
||||
);
|
||||
assert.strictEqual(
|
||||
livechat.textContent,
|
||||
"Visitor 11",
|
||||
"should have 'Visitor 11' as livechat name"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechat in the sidebar: existing user with country', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const resCountryId1 = pyEnv['res.country'].create({
|
||||
code: 'be',
|
||||
name: "Belgium",
|
||||
});
|
||||
const resPartnerId1 = pyEnv['res.partner'].create({
|
||||
country_id: resCountryId1,
|
||||
name: "Jean",
|
||||
});
|
||||
pyEnv['mail.channel'].create({
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: resPartnerId1 }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_DiscussSidebar_categoryLivechat',
|
||||
"should have a channel group livechat in the side bar"
|
||||
);
|
||||
const livechat = document.querySelector('.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategoryItem');
|
||||
assert.ok(
|
||||
livechat,
|
||||
"should have a livechat in sidebar"
|
||||
);
|
||||
assert.strictEqual(
|
||||
livechat.textContent,
|
||||
"Jean (Belgium)",
|
||||
"should have user name and country as livechat name"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('do not add livechat in the sidebar on visitor opening his chat', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['res.users'].write([pyEnv.currentUserId], { im_status: 'online' });
|
||||
const imLivechatChannelId1 = pyEnv['im_livechat.channel'].create({
|
||||
user_ids: [pyEnv.currentUserId],
|
||||
});
|
||||
const { messaging, openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_DiscussSidebar_categoryLivechat',
|
||||
"should not have any livechat in the sidebar initially"
|
||||
);
|
||||
|
||||
// simulate livechat visitor opening his chat
|
||||
await messaging.rpc({
|
||||
route: '/im_livechat/get_session',
|
||||
params: {
|
||||
context: {
|
||||
mockedUserId: false,
|
||||
},
|
||||
channel_id: imLivechatChannelId1,
|
||||
},
|
||||
});
|
||||
await nextAnimationFrame();
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_DiscussSidebar_categoryLivechat',
|
||||
"should still not have any livechat in the sidebar after visitor opened his chat"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('do not add livechat in the sidebar on visitor typing', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['res.users'].write([pyEnv.currentUserId], { im_status: 'online' });
|
||||
const imLivechatChannelId1 = pyEnv['im_livechat.channel'].create({
|
||||
user_ids: [pyEnv.currentUserId],
|
||||
});
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
channel_member_ids: [
|
||||
[0, 0, {
|
||||
is_pinned: false,
|
||||
partner_id: pyEnv.currentPartnerId,
|
||||
}],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_channel_id: imLivechatChannelId1,
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { messaging, openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_DiscussSidebar_categoryLivechat',
|
||||
"should not have any livechat in the sidebar initially"
|
||||
);
|
||||
|
||||
// simulate livechat visitor typing
|
||||
const channel = pyEnv['mail.channel'].searchRead([['id', '=', mailChannelId1]])[0];
|
||||
await messaging.rpc({
|
||||
route: '/im_livechat/notify_typing',
|
||||
params: {
|
||||
context: {
|
||||
mockedPartnerId: pyEnv.publicPartnerId,
|
||||
},
|
||||
is_typing: true,
|
||||
uuid: channel.uuid,
|
||||
},
|
||||
});
|
||||
await nextAnimationFrame();
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_DiscussSidebar_categoryLivechat',
|
||||
"should still not have any livechat in the sidebar after visitor started typing"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('add livechat in the sidebar on visitor sending first message', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['res.users'].write([pyEnv.currentUserId], { im_status: 'online' });
|
||||
const resCountryId1 = pyEnv['res.country'].create({
|
||||
code: 'be',
|
||||
name: "Belgium",
|
||||
});
|
||||
const imLivechatChannelId1 = pyEnv['im_livechat.channel'].create({
|
||||
user_ids: [pyEnv.currentUserId],
|
||||
});
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor (Belgium)",
|
||||
channel_member_ids: [
|
||||
[0, 0, {
|
||||
is_pinned: false,
|
||||
partner_id: pyEnv.currentPartnerId,
|
||||
}],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
country_id: resCountryId1,
|
||||
livechat_channel_id: imLivechatChannelId1,
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { messaging, openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_DiscussSidebar_categoryLivechat',
|
||||
"should not have any livechat in the sidebar initially"
|
||||
);
|
||||
|
||||
// simulate livechat visitor sending a message
|
||||
const channel = pyEnv['mail.channel'].searchRead([['id', '=', mailChannelId1]])[0];
|
||||
await afterNextRender(async () => messaging.rpc({
|
||||
route: '/mail/chat_post',
|
||||
params: {
|
||||
context: {
|
||||
mockedUserId: false,
|
||||
},
|
||||
uuid: channel.uuid,
|
||||
message_content: "new message",
|
||||
},
|
||||
}));
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_DiscussSidebar_categoryLivechat',
|
||||
"should have a channel group livechat in the side bar after receiving first message"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategoryItem',
|
||||
"should have a livechat in the sidebar after receiving first message"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategoryItem .o_DiscussSidebarCategoryItem_name').textContent,
|
||||
"Visitor (Belgium)",
|
||||
"should have visitor name and country as livechat name"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('livechats are sorted by last activity time in the sidebar: most recent at the top', async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const [mailChannelId1, mailChannelId2] = pyEnv['mail.channel'].create([
|
||||
{
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, {
|
||||
last_interest_dt: datetime_to_str(new Date(2021, 0, 1)),
|
||||
partner_id: pyEnv.currentPartnerId,
|
||||
}],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
},
|
||||
{
|
||||
anonymous_name: "Visitor 12",
|
||||
channel_member_ids: [
|
||||
[0, 0, {
|
||||
last_interest_dt: datetime_to_str(new Date(2021, 0, 2)),
|
||||
partner_id: pyEnv.currentPartnerId,
|
||||
}],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
},
|
||||
]);
|
||||
const { openDiscuss } = await start();
|
||||
await openDiscuss();
|
||||
const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_item');
|
||||
assert.strictEqual(
|
||||
initialLivechats.length,
|
||||
2,
|
||||
"should have 2 livechat items"
|
||||
);
|
||||
assert.strictEqual(
|
||||
Number(initialLivechats[0].dataset.channelId),
|
||||
mailChannelId2,
|
||||
"first livechat should be the one with the more recent last activity time"
|
||||
);
|
||||
assert.strictEqual(
|
||||
Number(initialLivechats[1].dataset.channelId),
|
||||
mailChannelId1,
|
||||
"second livechat should be the one with the less recent last activity time"
|
||||
);
|
||||
|
||||
// post a new message on the last channel
|
||||
await afterNextRender(() => initialLivechats[1].click());
|
||||
await afterNextRender(() => document.execCommand('insertText', false, "Blabla"));
|
||||
await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
|
||||
|
||||
const newLivechats = document.querySelectorAll('.o_DiscussSidebar_categoryLivechat .o_DiscussSidebarCategory_item');
|
||||
assert.strictEqual(
|
||||
newLivechats.length,
|
||||
2,
|
||||
"should have 2 livechat items"
|
||||
);
|
||||
assert.strictEqual(
|
||||
Number(newLivechats[0].dataset.channelId),
|
||||
mailChannelId1,
|
||||
"first livechat should be the one with the more recent last activity time"
|
||||
);
|
||||
assert.strictEqual(
|
||||
Number(newLivechats[1].dataset.channelId),
|
||||
mailChannelId2,
|
||||
"second livechat should be the one with the less recent last activity time"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('invite button should be present on livechat', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create(
|
||||
{
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
},
|
||||
);
|
||||
const { openDiscuss } = await start({
|
||||
discuss: {
|
||||
params: {
|
||||
default_active_id: `mail.channel_${mailChannelId1}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
await openDiscuss();
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ThreadViewTopbar_inviteButton',
|
||||
"Invite button should be visible in top bar when livechat is active thread"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('call buttons should not be present on livechat', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create(
|
||||
{
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
},
|
||||
);
|
||||
const { openDiscuss } = await start({
|
||||
discuss: {
|
||||
params: {
|
||||
default_active_id: `mail.channel_${mailChannelId1}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
await openDiscuss();
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_ThreadViewTopbar_callButton',
|
||||
"Call buttons should not be visible in top bar when livechat is active thread"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('reaction button should not be present on livechat', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
channel_partner_ids: [pyEnv.currentPartnerId, pyEnv.publicPartnerId],
|
||||
});
|
||||
const { click, insertText, openDiscuss } = await start({
|
||||
discuss: {
|
||||
params: {
|
||||
default_active_id: `mail.channel_${mailChannelId1}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
await openDiscuss();
|
||||
await insertText('.o_ComposerTextInput_textarea', "Test");
|
||||
await click('.o_Composer_buttonSend');
|
||||
await click('.o_Message');
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_MessageActionView_actionReaction',
|
||||
"should not have action to add a reaction"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('reply button should not be present on livechat', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
channel_partner_ids: [pyEnv.currentPartnerId, pyEnv.publicPartnerId],
|
||||
});
|
||||
const { click, insertText, openDiscuss } = await start({
|
||||
discuss: {
|
||||
params: {
|
||||
default_active_id: `mail.channel_${mailChannelId1}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
await openDiscuss();
|
||||
await insertText('.o_ComposerTextInput_textarea', "Test");
|
||||
await click('.o_Composer_buttonSend');
|
||||
await click('.o_Message');
|
||||
assert.containsNone(
|
||||
document.body,
|
||||
'.o_MessageActionView_actionReplyTo',
|
||||
"should not have reply action"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
afterNextRender,
|
||||
start,
|
||||
startServer,
|
||||
} from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('messaging_menu_tests.js');
|
||||
|
||||
QUnit.test('livechats should be in "chat" filter', async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 11",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
await start();
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_MessagingMenu',
|
||||
"should have messaging menu"
|
||||
);
|
||||
|
||||
await afterNextRender(() => document.querySelector('.o_MessagingMenu_toggler').click());
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_MessagingMenuTab[data-tab-id="all"]',
|
||||
"should have a tab/filter 'all' in messaging menu"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_MessagingMenuTab[data-tab-id="chat"]',
|
||||
"should have a tab/filter 'chat' in messaging menu"
|
||||
);
|
||||
assert.hasClass(
|
||||
document.querySelector('.o_MessagingMenuTab[data-tab-id="all"]'),
|
||||
'o-active',
|
||||
"tab/filter 'all' of messaging menu should be active initially"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_ChannelPreviewView[data-channel-id="${mailChannelId1}"]`,
|
||||
"livechat should be listed in 'all' tab/filter of messaging menu"
|
||||
);
|
||||
|
||||
await afterNextRender(() =>
|
||||
document.querySelector('.o_MessagingMenuTab[data-tab-id="chat"]').click()
|
||||
);
|
||||
assert.hasClass(
|
||||
document.querySelector('.o_MessagingMenuTab[data-tab-id="chat"]'),
|
||||
'o-active',
|
||||
"tab/filter 'chat' of messaging menu should become active after click"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_ChannelPreviewView[data-channel-id="${mailChannelId1}"]`,
|
||||
"livechat should be listed in 'chat' tab/filter of messaging menu"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
afterNextRender,
|
||||
start,
|
||||
startServer,
|
||||
} from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('thread_icon_tests.js');
|
||||
|
||||
QUnit.test('livechat: public website visitor is typing', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 20",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { messaging, openDiscuss } = await start({
|
||||
discuss: {
|
||||
context: { active_id: mailChannelId1 },
|
||||
},
|
||||
});
|
||||
await openDiscuss();
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ThreadViewTopbar .o_ThreadIcon',
|
||||
"should have thread icon"
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ThreadIcon .fa.fa-comments',
|
||||
"should have default livechat icon"
|
||||
);
|
||||
|
||||
const mailChannel1 = pyEnv['mail.channel'].searchRead([['id', '=', mailChannelId1]])[0];
|
||||
// simulate receive typing notification from livechat visitor "is typing"
|
||||
await afterNextRender(() => messaging.rpc({
|
||||
route: '/im_livechat/notify_typing',
|
||||
params: {
|
||||
context: {
|
||||
mockedPartnerId: pyEnv.publicPartnerId,
|
||||
},
|
||||
is_typing: true,
|
||||
uuid: mailChannel1.uuid,
|
||||
},
|
||||
}));
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ThreadIcon_typing',
|
||||
"should have thread icon with visitor currently typing"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_ThreadIcon_typing').title,
|
||||
"Visitor 20 is typing...",
|
||||
"title of icon should tell visitor is currently typing"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
afterNextRender,
|
||||
start,
|
||||
startServer,
|
||||
} from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
QUnit.module('im_livechat', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('thread_textual_typing_status_tests.js');
|
||||
|
||||
QUnit.test('receive visitor typing status "is typing"', async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
anonymous_name: "Visitor 20",
|
||||
channel_member_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: pyEnv.publicPartnerId }],
|
||||
],
|
||||
channel_type: 'livechat',
|
||||
livechat_operator_id: pyEnv.currentPartnerId,
|
||||
});
|
||||
const { messaging, openDiscuss } = await start({
|
||||
discuss: {
|
||||
context: { active_id: mailChannelId1 },
|
||||
},
|
||||
});
|
||||
await openDiscuss();
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_ThreadTextualTypingStatus').textContent,
|
||||
"",
|
||||
"Should display no one is currently typing"
|
||||
);
|
||||
|
||||
const mailChannel1 = pyEnv['mail.channel'].searchRead([['id', '=', mailChannelId1]])[0];
|
||||
// simulate receive typing notification from livechat visitor "is typing"
|
||||
await afterNextRender(() => messaging.rpc({
|
||||
route: '/im_livechat/notify_typing',
|
||||
params: {
|
||||
context: {
|
||||
mockedPartnerId: pyEnv.publicPartnerId,
|
||||
},
|
||||
is_typing: true,
|
||||
uuid: mailChannel1.uuid,
|
||||
},
|
||||
}));
|
||||
assert.strictEqual(
|
||||
document.querySelector('.o_ThreadTextualTypingStatus').textContent,
|
||||
"Visitor 20 is typing...",
|
||||
"Should display that visitor is typing"
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import tour from "web_tour.tour";
|
||||
|
||||
const requestChatSteps = [
|
||||
{
|
||||
trigger: ".o_livechat_button",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_thread_window",
|
||||
},
|
||||
];
|
||||
|
||||
tour.register("im_livechat_request_chat", { test: true }, requestChatSteps);
|
||||
|
||||
tour.register("im_livechat_request_chat_and_send_message", { test: true }, [
|
||||
...requestChatSteps,
|
||||
{
|
||||
trigger: ".o_composer_text_field",
|
||||
run: "text Hello, I need help please !",
|
||||
},
|
||||
{
|
||||
trigger: '.o_composer_text_field',
|
||||
run() {
|
||||
$(".o_composer_text_field").trigger($.Event("keydown", { which: 13 }));
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".o_thread_message:contains('Hello, I need help')",
|
||||
},
|
||||
]);
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import tour from "web_tour.tour";
|
||||
|
||||
const commonSteps = [tour.stepUtils.showAppsMenuItem(), {
|
||||
trigger: '.o_app[data-menu-xmlid="im_livechat.menu_livechat_root"]',
|
||||
}, {
|
||||
trigger: 'button[data-menu-xmlid="im_livechat.livechat_config"]',
|
||||
}, {
|
||||
trigger: 'a[data-menu-xmlid="im_livechat.chatbot_config"]',
|
||||
}, {
|
||||
trigger: '.o_list_button_add',
|
||||
}, {
|
||||
trigger: 'input[id="title"]',
|
||||
run: 'text Test Chatbot Sequence'
|
||||
}, {
|
||||
trigger: 'div[name="script_step_ids"] .o_field_x2many_list_row_add a'
|
||||
}, {
|
||||
trigger: 'textarea#message',
|
||||
run: 'text Step 1'
|
||||
}, {
|
||||
trigger: 'button:contains("Save & New")'
|
||||
}, {
|
||||
trigger: 'tr:contains("Step 1")',
|
||||
in_modal: false,
|
||||
run: () => {}
|
||||
}, {
|
||||
trigger: 'textarea#message',
|
||||
run: 'text Step 2'
|
||||
}, {
|
||||
trigger: 'button:contains("Save & New")'
|
||||
}, {
|
||||
trigger: 'tr:contains("Step 2")',
|
||||
in_modal: false,
|
||||
run: () => {}
|
||||
}, {
|
||||
trigger: 'textarea#message',
|
||||
run: 'text Step 3'
|
||||
}];
|
||||
|
||||
|
||||
/**
|
||||
* Simply create a few steps in order to check the sequences.
|
||||
*/
|
||||
tour.register('im_livechat_chatbot_steps_sequence_tour', {
|
||||
test: true,
|
||||
url: '/web',
|
||||
}, [
|
||||
...commonSteps, {
|
||||
trigger: 'button:contains("Save & Close")'
|
||||
}, {
|
||||
trigger: 'body.o_web_client:not(.modal-open)',
|
||||
run() {},
|
||||
}, ...tour.stepUtils.discardForm()
|
||||
]);
|
||||
|
||||
/**
|
||||
* Same as above, with an extra drag&drop at the end.
|
||||
*/
|
||||
tour.register('im_livechat_chatbot_steps_sequence_with_move_tour', {
|
||||
test: true,
|
||||
url: '/web',
|
||||
}, [
|
||||
...commonSteps, {
|
||||
trigger: 'button:contains("Save & New")'
|
||||
}, {
|
||||
trigger: 'tr:contains("Step 3")',
|
||||
in_modal: false,
|
||||
run: () => {}
|
||||
}, {
|
||||
trigger: 'textarea#message',
|
||||
run: 'text Step 4'
|
||||
}, {
|
||||
trigger: 'button:contains("Save & New")'
|
||||
}, {
|
||||
trigger: 'tr:contains("Step 4")',
|
||||
in_modal: false,
|
||||
run: () => {}
|
||||
}, {
|
||||
trigger: 'textarea#message',
|
||||
run: 'text Step 5'
|
||||
}, {
|
||||
trigger: 'button:contains("Save & Close")'
|
||||
}, {
|
||||
trigger: 'body.o_web_client:not(.modal-open)',
|
||||
run: () => {}
|
||||
}, {
|
||||
trigger: 'tr:contains("Step 5") .o_row_handle',
|
||||
run: () => {
|
||||
// move 'step 5' between 'step 1' and 'step 2'
|
||||
const from = document.querySelector('div[name="script_step_ids"] tr:nth-child(5) .o_row_handle');
|
||||
const fromPosition = from.getBoundingClientRect();
|
||||
fromPosition.x += from.offsetWidth / 2;
|
||||
fromPosition.y += from.offsetHeight / 2;
|
||||
|
||||
const to = document.querySelector('div[name="script_step_ids"] tr:nth-child(2) .o_row_handle');
|
||||
from.dispatchEvent(new Event("mouseenter", { bubbles: true }));
|
||||
from.dispatchEvent(new MouseEvent("mousedown", {
|
||||
bubbles: true,
|
||||
which: 1,
|
||||
button: 0,
|
||||
clientX: fromPosition.x,
|
||||
clientY: fromPosition.y}));
|
||||
from.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true,
|
||||
which: 1,
|
||||
button: 0,
|
||||
// dragging is only enabled when the mouse have moved from at least 10 pixels from the original position
|
||||
clientX: fromPosition.x + 20,
|
||||
clientY: fromPosition.y + 20,
|
||||
}));
|
||||
to.dispatchEvent(new Event("mouseenter", { bubbles: true }));
|
||||
from.dispatchEvent(new Event("mouseup", { bubbles: true }));
|
||||
}
|
||||
}, {
|
||||
trigger: 'div[name="script_step_ids"] .o_field_x2many_list_row_add a'
|
||||
}, {
|
||||
trigger: 'textarea#message',
|
||||
run: 'text Step 6'
|
||||
}, {
|
||||
trigger: 'button:contains("Save & Close")'
|
||||
}, {
|
||||
trigger: 'body.o_web_client:not(.modal-open)',
|
||||
run: () => {}
|
||||
}, {
|
||||
trigger: 'tr:contains("Step 6")',
|
||||
in_modal: false,
|
||||
run: () => {}
|
||||
}, ...tour.stepUtils.discardForm(),
|
||||
]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue