mirror of
https://github.com/bringout/oca-ocb-mail.git
synced 2026-04-22 09:42:05 +02:00
19.0 vanilla
This commit is contained in:
parent
5df8c07b59
commit
daa394e8b0
2114 changed files with 564841 additions and 299642 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
|
@ -1,27 +1 @@
|
|||
<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>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M4 26.222C4 27.204 4.796 28 5.778 28H16c6.627 0 12-5.373 12-12S22.627 4 16 4 4 9.373 4 16v10.222Z" fill="#FC868B"/><path d="M46 5.778C46 4.796 45.204 4 44.222 4H34c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12V5.778Z" fill="#985184"/><path d="M25 23.937c1.867-2.115 3-4.894 3-7.937s-1.133-5.822-3-7.938A11.954 11.954 0 0 0 22 16c0 3.043 1.133 5.822 3 7.937Z" fill="#962B48"/><path d="M4 38.5C4 32.701 8.701 28 14.5 28S25 32.701 25 38.5V46H4v-7.5Z" fill="#FC868B"/><path d="M25 38.5C25 32.701 29.701 28 35.5 28S46 32.701 46 38.5V46H25v-7.5Z" fill="#985184"/></svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 664 B |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -1,10 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/** @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);
|
||||
}
|
||||
|
||||
});
|
||||
72
odoo-bringout-oca-ocb-im_livechat/im_livechat/static/src/core/common/@types/models.d.ts
vendored
Normal file
72
odoo-bringout-oca-ocb-im_livechat/im_livechat/static/src/core/common/@types/models.d.ts
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
declare module "models" {
|
||||
import { Chatbot as ChatbotClass } from "@im_livechat/core/common/chatbot_model";
|
||||
import { ChatbotScript as ChatbotScriptClass } from "@im_livechat/core/common/chatbot_script_model";
|
||||
import { ChatbotScriptStep as ChatbotScriptStepClass } from "@im_livechat/core/common/chatbot_script_step_model";
|
||||
import { ChatbotScriptStepAnswer as ChatbotScriptStepAnswerClass } from "@im_livechat/core/common/chatbot_script_step_answer_model";
|
||||
import { ChatbotStep as ChatbotStepClass } from "@im_livechat/core/common/chatbot_step_model";
|
||||
import { LivechatChannel as LivechatChannelClass } from "@im_livechat/core/common/livechat_channel_model";
|
||||
import { LivechatChannelRule as LivechatChannelRuleClass } from "@im_livechat/core/common/livechat_channel_rule_model";
|
||||
import { LivechatConversationTag as LivechatConversationTagClass } from "@im_livechat/core/common/livechat_conversation_tag_model";
|
||||
import { LivechatExpertise as LivechatExpertiseClass } from "@im_livechat/core/common/livechat_expertise_model";
|
||||
|
||||
export interface Chatbot extends ChatbotClass {}
|
||||
export interface ChatbotScript extends ChatbotScriptClass {}
|
||||
export interface ChatbotScriptStep extends ChatbotScriptStepClass {}
|
||||
export interface ChatbotScriptStepAnswer extends ChatbotScriptStepAnswerClass {}
|
||||
export interface ChatbotStep extends ChatbotStepClass {}
|
||||
export interface LivechatChannel extends LivechatChannelClass {}
|
||||
export interface LivechatChannelRule extends LivechatChannelRuleClass {}
|
||||
export interface LivechatConversationTag extends LivechatConversationTagClass {}
|
||||
export interface LivechatExpertise extends LivechatExpertiseClass {}
|
||||
|
||||
export interface ChatWindow {
|
||||
livechatStep: undefined|"CONFIRM_CLOSE"|"FEEDBACK";
|
||||
}
|
||||
export interface DataResponse {
|
||||
chatbot_step: ChatbotStep;
|
||||
}
|
||||
export interface Message {
|
||||
chatbotStep: ChatbotStep;
|
||||
}
|
||||
export interface ResPartner {
|
||||
livechat_expertise: String[];
|
||||
livechat_languages: String[];
|
||||
}
|
||||
export interface ResUsers {
|
||||
is_livechat_manager: boolean;
|
||||
livechat_expertise_ids: LivechatExpertise[];
|
||||
}
|
||||
export interface Store {
|
||||
Chatbot: StaticMailRecord<Chatbot, typeof ChatbotClass>;
|
||||
"chatbot.script": StaticMailRecord<ChatbotScript, typeof ChatbotScriptClass>;
|
||||
"chatbot.script.answer": StaticMailRecord<ChatbotScriptStepAnswer, typeof ChatbotScriptStepAnswerClass>;
|
||||
"chatbot.script.step": StaticMailRecord<ChatbotScriptStep, typeof ChatbotScriptStepClass>;
|
||||
ChatbotStep: StaticMailRecord<ChatbotStep, typeof ChatbotStepClass>;
|
||||
"im_livechat.channel": StaticMailRecord<LivechatChannel, typeof LivechatChannelClass>;
|
||||
"im_livechat.channel.rule": StaticMailRecord<LivechatChannelRule, typeof LivechatChannelRuleClass>;
|
||||
"im_livechat.conversation.tag": StaticMailRecord<LivechatConversationTag, typeof LivechatConversationTagClass>;
|
||||
"im_livechat.expertise": StaticMailRecord<LivechatExpertise, typeof LivechatExpertiseClass>;
|
||||
}
|
||||
export interface Thread {
|
||||
composerDisabled: Readonly<boolean>;
|
||||
composerDisabledText: Readonly<string>;
|
||||
livechat_conversation_tag_ids: LivechatConversationTag[];
|
||||
livechat_end_dt: import("luxon").DateTime;
|
||||
livechat_operator_id: ResPartner;
|
||||
livechatVisitorMember: ChannelMember;
|
||||
open_chat_window: true|undefined;
|
||||
livechat_lang_id: ResLang;
|
||||
}
|
||||
|
||||
export interface Models {
|
||||
Chatbot: Chatbot;
|
||||
"chatbot.script": ChatbotScript;
|
||||
"chatbot.script.answer": ChatbotScriptStepAnswer;
|
||||
"chatbot.script.step": ChatbotScriptStep;
|
||||
ChatbotStep: ChatbotStep;
|
||||
"im_livechat.channel": LivechatChannel;
|
||||
"im_livechat.channel.rule": LivechatChannelRule;
|
||||
"im_livechat.conversation.tag": LivechatConversationTag;
|
||||
"im_livechat.expertise": LivechatExpertise;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="im_livechat.ChannelMemberList" t-inherit="discuss.ChannelMemberList" t-inherit-mode="extension">
|
||||
<xpath expr="//button[@name='inviteButton']" position="attributes">
|
||||
<attribute name="t-if">!props.thread.livechat_end_dt</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { ChatBubble } from "@mail/core/common/chat_bubble";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(ChatBubble.prototype, {
|
||||
get showImStatus() {
|
||||
if (this.thread?.self_member_id?.livechat_member_type === "visitor") {
|
||||
return false;
|
||||
}
|
||||
return super.showImStatus;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { ChatWindow } from "@mail/core/common/chat_window_model";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
export const CW_LIVECHAT_STEP = {
|
||||
NONE: undefined,
|
||||
CONFIRM_CLOSE: "CONFIRM_CLOSE", // currently showing confirm dialog to close/end livechat
|
||||
FEEDBACK: "FEEDBACK", // currently showing feedback panel
|
||||
};
|
||||
|
||||
/** @type {import("models").ChatWindow} */
|
||||
const chatWindowPatch = {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
/** @type {undefined|"CONFIRM_CLOSE"|"FEEDBACK"} */
|
||||
this.livechatStep = CW_LIVECHAT_STEP.NONE;
|
||||
},
|
||||
close(options = {}) {
|
||||
if (this.thread?.channel_type !== "livechat") {
|
||||
return super.close(...arguments);
|
||||
}
|
||||
if (options.force) {
|
||||
this.livechatStep = CW_LIVECHAT_STEP.NONE;
|
||||
return super.close(...arguments);
|
||||
}
|
||||
const isSelfVisitor = this.thread.livechatVisitorMember?.persona?.eq(this.store.self);
|
||||
switch (this.livechatStep) {
|
||||
case CW_LIVECHAT_STEP.NONE: {
|
||||
if (this.thread.isTransient) {
|
||||
this.thread.delete();
|
||||
super.close(...arguments);
|
||||
break;
|
||||
}
|
||||
if (!this.thread.hasSelfAsMember) {
|
||||
super.close(...arguments);
|
||||
break;
|
||||
}
|
||||
if (this.thread.livechat_end_dt) {
|
||||
if (isSelfVisitor) {
|
||||
this.livechatStep = CW_LIVECHAT_STEP.FEEDBACK;
|
||||
this.open({ focus: true, notifyState: this.thread?.state !== "open" });
|
||||
} else {
|
||||
super.close(...arguments);
|
||||
}
|
||||
break;
|
||||
}
|
||||
this.actionsDisabled = true;
|
||||
this.livechatStep = CW_LIVECHAT_STEP.CONFIRM_CLOSE;
|
||||
if (!isSelfVisitor && this.thread.channel_member_ids.length > 2) {
|
||||
super.close(...arguments);
|
||||
break;
|
||||
}
|
||||
if (!this.hubAsOpened) {
|
||||
this.open({ focus: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CW_LIVECHAT_STEP.CONFIRM_CLOSE: {
|
||||
this.actionsDisabled = false;
|
||||
if (isSelfVisitor) {
|
||||
this.open({ focus: true, notifyState: this.thread?.state !== "open" });
|
||||
this.livechatStep = CW_LIVECHAT_STEP.FEEDBACK;
|
||||
} else {
|
||||
this.livechatStep = CW_LIVECHAT_STEP.NONE;
|
||||
super.close(...arguments);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CW_LIVECHAT_STEP.FEEDBACK: {
|
||||
this.livechatStep = CW_LIVECHAT_STEP.NONE;
|
||||
super.close(...arguments);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
patch(ChatWindow.prototype, chatWindowPatch);
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { CloseConfirmation } from "@im_livechat/core/common/close_confirmation";
|
||||
|
||||
import { ChatWindow } from "@mail/core/common/chat_window";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { CW_LIVECHAT_STEP } from "./chat_window_model_patch";
|
||||
|
||||
Object.assign(ChatWindow.components, { CloseConfirmation });
|
||||
|
||||
patch(ChatWindow.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.CW_LIVECHAT_STEP = CW_LIVECHAT_STEP;
|
||||
},
|
||||
onCloseConfirmationDialog() {
|
||||
this.props.chatWindow.autofocus++;
|
||||
this.props.chatWindow.actionsDisabled = false;
|
||||
this.props.chatWindow.livechatStep = CW_LIVECHAT_STEP.NONE;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ChatWindow" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='thread content']" position="replace">
|
||||
<t>$0</t>
|
||||
<t t-if="props.chatWindow.livechatStep === CW_LIVECHAT_STEP.CONFIRM_CLOSE">
|
||||
<CloseConfirmation onCloseConfirmationDialog.bind="onCloseConfirmationDialog" onClickLeaveConversation.bind="close"/>
|
||||
</t>
|
||||
</xpath>
|
||||
<xpath expr="//Composer" position="replace">
|
||||
<div t-if="thread?.composerDisabled" class="bg-200 py-1 text-center d-flex fst-italic fw-bold text-muted" t-ref="composerDisabledContainer">
|
||||
<span t-if="!showGiveFeedbackBtn" class="flex-grow-1"/>
|
||||
<span t-esc="thread.composerDisabledText"/>
|
||||
<span class="flex-grow-1"/>
|
||||
</div>
|
||||
<t t-else="">$0</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
import { AND, fields, Record } from "@mail/core/common/record";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { debounce } from "@web/core/utils/timing";
|
||||
import { expirableStorage } from "@im_livechat/core/common/expirable_storage";
|
||||
|
||||
export class Chatbot extends Record {
|
||||
static id = AND("script", "thread");
|
||||
static MESSAGE_DELAY = 400;
|
||||
static TYPING_DELAY = 500;
|
||||
// Time to wait without user input before considering a multi line step as
|
||||
// completed.
|
||||
static MULTILINE_STEP_DEBOUNCE_DELAY = 10000;
|
||||
|
||||
forwarded;
|
||||
isTyping = false;
|
||||
isProcessingAnswer = false;
|
||||
script = fields.One("chatbot.script");
|
||||
currentStep = fields.One("ChatbotStep", {
|
||||
onUpdate() {
|
||||
if (this.currentStep?.operatorFound) {
|
||||
this.forwarded = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
steps = fields.Many("ChatbotStep");
|
||||
thread = fields.One("Thread", {
|
||||
inverse: "chatbot",
|
||||
onDelete() {
|
||||
this.delete();
|
||||
},
|
||||
});
|
||||
tmpAnswer = "";
|
||||
typingMessage = fields.One("mail.message", {
|
||||
compute() {
|
||||
if (this.isTyping && this.thread) {
|
||||
return {
|
||||
id: -0.1 - this.thread.id,
|
||||
thread: this.thread,
|
||||
author_id: this.script.operator_partner_id,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
/**
|
||||
* @type {(message: import("models").Message) => Promise<void>}
|
||||
*/
|
||||
_processAnswerDebounced = fields.Attr(null, {
|
||||
compute() {
|
||||
return debounce(this._processAnswer, Chatbot.MULTILINE_STEP_DEBOUNCE_DELAY);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Start the chatbot. Either from the beginning if the user just started the
|
||||
* session or from where we left off if the session was restored after a
|
||||
* page load.
|
||||
*/
|
||||
async start() {
|
||||
if (this.completed) {
|
||||
return;
|
||||
}
|
||||
if (this.thread.isLastMessageFromCustomer) {
|
||||
await this.processAnswer(this.thread.newestPersistentOfAllMessage);
|
||||
}
|
||||
if (!this.currentStep?.expectAnswer || this.currentStep?.completed) {
|
||||
this._runUntilUserInputStep();
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearTimeout(this.nextStepTimeout);
|
||||
}
|
||||
|
||||
async restart() {
|
||||
if (!this.completed) {
|
||||
return;
|
||||
}
|
||||
const { store_data, message_id } = await rpc("/chatbot/restart", {
|
||||
channel_id: this.thread.id,
|
||||
chatbot_script_id: this.script.id,
|
||||
});
|
||||
this.store.insert(store_data);
|
||||
this.thread.messages.add(message_id);
|
||||
this.thread.livechat_end_dt = false;
|
||||
if (this.currentStep) {
|
||||
this.currentStep.isLast = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("models").Message} message
|
||||
*/
|
||||
async processAnswer(message) {
|
||||
if (
|
||||
this.forwarded ||
|
||||
this.thread.notEq(message.thread) ||
|
||||
!this.currentStep?.expectAnswer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this.currentStep.step_type === "free_input_multi") {
|
||||
await this._processAnswerDebounced(message);
|
||||
} else {
|
||||
await this._processAnswer(message);
|
||||
}
|
||||
this.isProcessingAnswer = false;
|
||||
}
|
||||
|
||||
async _triggerNextStep() {
|
||||
if (this.currentStep) {
|
||||
await this._simulateTyping();
|
||||
}
|
||||
await this._goToNextStep();
|
||||
if (!this.currentStep || this.currentStep.completed || !this.thread) {
|
||||
return;
|
||||
}
|
||||
if (this.thread.isTransient) {
|
||||
// Thread is not persisted thus messages do not exist on the server,
|
||||
// create them now on the client side.
|
||||
this.currentStep.message = this.store["mail.message"].insert({
|
||||
id: this.store.getNextTemporaryId(),
|
||||
author_id: this.script.operator_partner_id,
|
||||
body: this.currentStep.scriptStep.message,
|
||||
thread: this.thread,
|
||||
});
|
||||
}
|
||||
if (this.currentStep.message) {
|
||||
this.thread.messages.add(this.currentStep.message);
|
||||
}
|
||||
}
|
||||
|
||||
get completed() {
|
||||
return (
|
||||
(this.currentStep?.isLast &&
|
||||
(!this.currentStep.expectAnswer || this.currentStep?.completed)) ||
|
||||
this.currentStep?.operatorFound ||
|
||||
this.thread.livechat_end_dt
|
||||
);
|
||||
}
|
||||
|
||||
get canRestart() {
|
||||
return this.completed && !this.currentStep?.operatorFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the next step of the chatbot, fetch it if needed.
|
||||
*/
|
||||
async _goToNextStep() {
|
||||
if (!this.thread) {
|
||||
return;
|
||||
}
|
||||
if (this.steps.at(-1)?.eq(this.currentStep)) {
|
||||
const dataRequest = this.store.DataResponse.createRequest();
|
||||
await rpc("/chatbot/step/trigger", {
|
||||
channel_id: this.thread.id,
|
||||
chatbot_script_id: this.script.id,
|
||||
data_id: dataRequest.id,
|
||||
});
|
||||
await dataRequest._resultDef;
|
||||
if (!dataRequest.chatbot_step) {
|
||||
this.currentStep.isLast = true;
|
||||
return;
|
||||
}
|
||||
this.steps.push(dataRequest.chatbot_step);
|
||||
} else {
|
||||
const nextStepIndex = this.steps.lastIndexOf(this.currentStep) + 1;
|
||||
this.currentStep = this.steps[nextStepIndex];
|
||||
this.currentStep.selectedAnswer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger chat bot steps recursively until the script is completed or a user
|
||||
* input is required.
|
||||
*/
|
||||
async _runUntilUserInputStep() {
|
||||
await this._triggerNextStep();
|
||||
if (
|
||||
!this.currentStep ||
|
||||
this.completed ||
|
||||
(this.currentStep.expectAnswer && !this.currentStep.completed)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.nextStepTimeout = browser.setTimeout(
|
||||
async () => this._runUntilUserInputStep(),
|
||||
Chatbot.TYPING_DELAY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the typing of the chatbot.
|
||||
*/
|
||||
async _simulateTyping(duration = Chatbot.MESSAGE_DELAY) {
|
||||
this.isTyping = true;
|
||||
await new Promise((res) =>
|
||||
setTimeout(() => {
|
||||
this.isTyping = false;
|
||||
res();
|
||||
}, duration)
|
||||
);
|
||||
}
|
||||
|
||||
async _processAnswer(message) {
|
||||
if (
|
||||
this.currentStep.step_type === "free_input_multi" &&
|
||||
this.thread.composer.composerText &&
|
||||
this.tmpAnswer !== this.thread.composer.composerText
|
||||
) {
|
||||
return await this._delayThenProcessAnswerAgain(message);
|
||||
}
|
||||
this.tmpAnswer = "";
|
||||
let stepCompleted = true;
|
||||
if (this.currentStep.step_type === "question_email") {
|
||||
stepCompleted = await this._processAnswerQuestionEmail();
|
||||
} else if (this.currentStep.step_type === "question_selection") {
|
||||
stepCompleted = await this._processAnswerQuestionSelection(message);
|
||||
}
|
||||
this.currentStep.completed = stepCompleted;
|
||||
if (this.currentStep.completed) {
|
||||
await this._runUntilUserInputStep();
|
||||
}
|
||||
}
|
||||
|
||||
async _delayThenProcessAnswerAgain(message) {
|
||||
this.tmpAnswer = this.thread.composer.composerText;
|
||||
await Promise.resolve(); // Ensure that it's properly debounced when called again
|
||||
return this._processAnswerDebounced(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the user answer for a question selection step.
|
||||
*
|
||||
* @param {import("models").Message} message Answer posted by the user.
|
||||
* @returns {Promise<boolean>} Whether the script is ready to go to the next step.
|
||||
*/
|
||||
async _processAnswerQuestionSelection(message) {
|
||||
const answer = this.currentStep.selectedAnswer;
|
||||
if (!answer?.redirect_link) {
|
||||
return true;
|
||||
}
|
||||
let isRedirecting = false;
|
||||
if (answer.redirect_link && URL.canParse(answer.redirect_link, window.location.href)) {
|
||||
const url = new URL(window.location.href);
|
||||
const nextURL = new URL(answer.redirect_link, window.location.href);
|
||||
isRedirecting = url.pathname !== nextURL.pathname || url.origin !== nextURL.origin;
|
||||
}
|
||||
const redirects = JSON.parse(
|
||||
expirableStorage.getItem("im_livechat.chatbot_redirect") ?? "[]"
|
||||
);
|
||||
const targetURL = new URL(answer.redirect_link, window.location.origin);
|
||||
const redirectionAlreadyDone =
|
||||
targetURL.href === location.href || redirects.includes(message.id);
|
||||
redirects.push(message.id);
|
||||
const ONE_DAY_TTL = 60 * 60 * 24;
|
||||
expirableStorage.setItem(
|
||||
"im_livechat.chatbot_redirect",
|
||||
JSON.stringify([...new Set(redirects)]),
|
||||
ONE_DAY_TTL
|
||||
);
|
||||
if (!redirectionAlreadyDone) {
|
||||
browser.location.assign(answer.redirect_link);
|
||||
} else if (this.store.env.services.ui.isSmall) {
|
||||
await this.store.chatHub.initPromise;
|
||||
this.store.ChatWindow.get({ thread: this.thread })?.fold();
|
||||
}
|
||||
return redirectionAlreadyDone || !isRedirecting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the user answer for a question email step.
|
||||
*
|
||||
* @returns {Promise<boolean>} Whether the script is ready to go to the next step.
|
||||
*/
|
||||
async _processAnswerQuestionEmail() {
|
||||
const { success, data } = await rpc("/chatbot/step/validate_email", {
|
||||
channel_id: this.thread.id,
|
||||
});
|
||||
this.store.insert(data);
|
||||
return success;
|
||||
}
|
||||
}
|
||||
Chatbot.register();
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Record, fields } from "@mail/core/common/record";
|
||||
|
||||
export class ChatbotScript extends Record {
|
||||
static _name = "chatbot.script";
|
||||
static id = "id";
|
||||
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {string} */
|
||||
title;
|
||||
isLivechatTourRunning = false;
|
||||
operator_partner_id = fields.One("res.partner");
|
||||
}
|
||||
ChatbotScript.register();
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Record } from "@mail/core/common/record";
|
||||
|
||||
export class ChatbotScriptStepAnswer extends Record {
|
||||
static id = "id";
|
||||
static _name = "chatbot.script.answer";
|
||||
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {string} */
|
||||
label;
|
||||
/** @type {string|false} */
|
||||
redirect_link;
|
||||
}
|
||||
ChatbotScriptStepAnswer.register();
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { fields, Record } from "@mail/core/common/record";
|
||||
|
||||
export class ChatbotScriptStep extends Record {
|
||||
static id = "id";
|
||||
static _name = "chatbot.script.step";
|
||||
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {string} */
|
||||
message;
|
||||
/** @type {"free_input_multi"|"free_input_single"|"question_email"|"question_phone"|"question_selection"|"text"|"forward_operator"} */
|
||||
step_type;
|
||||
isLast = false;
|
||||
answer_ids = fields.Many("chatbot.script.answer");
|
||||
}
|
||||
ChatbotScriptStep.register();
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { AND, fields, Record } from "@mail/core/common/record";
|
||||
import { createDocumentFragmentFromContent } from "@web/core/utils/html";
|
||||
|
||||
export class ChatbotStep extends Record {
|
||||
static id = AND("scriptStep", "message");
|
||||
|
||||
operatorFound = false;
|
||||
scriptStep = fields.One("chatbot.script.step");
|
||||
message = fields.One("mail.message", { inverse: "chatbotStep" });
|
||||
answer_ids = fields.Many("chatbot.script.answer", {
|
||||
compute() {
|
||||
return this.scriptStep?.answer_ids;
|
||||
},
|
||||
});
|
||||
selectedAnswer = fields.One("chatbot.script.answer");
|
||||
rawAnswer = fields.Html("");
|
||||
step_type = fields.Attr("", {
|
||||
compute() {
|
||||
return this.scriptStep?.step_type;
|
||||
},
|
||||
});
|
||||
isLast = false;
|
||||
|
||||
get expectAnswer() {
|
||||
return [
|
||||
"free_input_multi",
|
||||
"free_input_single",
|
||||
"question_selection",
|
||||
"question_email",
|
||||
"question_phone",
|
||||
].includes(this.step_type);
|
||||
}
|
||||
|
||||
get answer() {
|
||||
switch (this.step_type) {
|
||||
case "free_input_multi":
|
||||
case "free_input_single":
|
||||
case "question_email":
|
||||
case "question_phone":
|
||||
return createDocumentFragmentFromContent(this.rawAnswer).body.textContent;
|
||||
case "question_selection":
|
||||
return this.selectedAnswer?.label;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatbotStep.register();
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
|
||||
export class CloseConfirmation extends Component {
|
||||
static template = "im_livechat.CloseConfirmation";
|
||||
static props = ["onCloseConfirmationDialog", "onClickLeaveConversation"];
|
||||
|
||||
setup() {
|
||||
useAutofocus({ refName: "confirm" });
|
||||
}
|
||||
|
||||
onKeydown(ev) {
|
||||
if (ev.key === "Escape") {
|
||||
this.props.onCloseConfirmationDialog();
|
||||
} else if (ev.key === "Enter") {
|
||||
this.props.onClickLeaveConversation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.o-livechat-CloseConfirmation {
|
||||
z-index: $o-mail-NavigableList-zIndex - 1;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="im_livechat.CloseConfirmation">
|
||||
<div
|
||||
class="o-livechat-CloseConfirmation position-absolute w-100 h-100 d-flex justify-content-center align-items-center" t-on-click.stop="() => this.props.onCloseConfirmationDialog()">
|
||||
<div class="o-livechat-CloseConfirmation-dialog rounded bg-view bg-opacity-100 p-3 m-3 d-flex flex-column position-relative" t-ref="dialog">
|
||||
<div class="position-absolute top-0 end-0 p-1 o-xsmaller">
|
||||
<button class="o-livechat-CloseConfirmation-close btn-close" t-on-click.stop="() => this.props.onCloseConfirmationDialog()"/>
|
||||
</div>
|
||||
<span class="pt-2 pb-3">Leaving will end the live chat. Do you want to proceed?</span>
|
||||
<button class="o-livechat-CloseConfirmation-leave btn btn-danger p-2 gap-1" t-on-keydown.stop.prevent="onKeydown" t-on-click.stop="() => this.props.onClickLeaveConversation()" t-ref="confirm"><i class="fa fa-fw fa-sign-out"/>Yes, leave conversation</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { DataResponse } from "@mail/core/common/data_response_model";
|
||||
import { fields } from "@mail/model/misc";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(DataResponse.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.chatbot_step = fields.One("ChatbotStep");
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { EventBus } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
const BASE_STORAGE_KEY = "EXPIRABLE_STORAGE_";
|
||||
const CLEAR_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
|
||||
function cleanupExpirableStorage() {
|
||||
const now = Date.now();
|
||||
// Next line is for testing compatibility as for..in is not supported by
|
||||
// the `MockStorage` class.
|
||||
const keys = browser.localStorage.items?.keys() ?? Object.keys(browser.localStorage);
|
||||
for (const key of keys) {
|
||||
if (key.startsWith(BASE_STORAGE_KEY)) {
|
||||
const item = JSON.parse(browser.localStorage.getItem(key));
|
||||
if (item.expires && item.expires < now) {
|
||||
browser.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const storageBus = new EventBus();
|
||||
const storageFnToWrapper = new Map();
|
||||
browser.addEventListener("storage", ({ key, newValue }) => {
|
||||
if (key?.startsWith(BASE_STORAGE_KEY)) {
|
||||
const actualKey = key.slice(BASE_STORAGE_KEY.length);
|
||||
storageBus.trigger(actualKey, newValue ? JSON.parse(newValue).value : null);
|
||||
}
|
||||
});
|
||||
|
||||
export const expirableStorage = {
|
||||
/** @param {string} key */
|
||||
getItem(key) {
|
||||
cleanupExpirableStorage();
|
||||
const item = browser.localStorage.getItem(`${BASE_STORAGE_KEY}${key}`);
|
||||
if (item) {
|
||||
return JSON.parse(item).value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} value
|
||||
* @param {number} ttl Number of seconds after which the item should expire.
|
||||
*/
|
||||
setItem(key, value, ttl) {
|
||||
let expires;
|
||||
if (ttl) {
|
||||
expires = Date.now() + ttl * 1000;
|
||||
}
|
||||
browser.localStorage.setItem(
|
||||
`${BASE_STORAGE_KEY}${key}`,
|
||||
JSON.stringify({ value, expires })
|
||||
);
|
||||
},
|
||||
/** @param {string} key */
|
||||
removeItem(key) {
|
||||
browser.localStorage.removeItem(`${BASE_STORAGE_KEY}${key}`);
|
||||
},
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {(value: any) => void} fn
|
||||
*/
|
||||
onChange(key, fn) {
|
||||
storageFnToWrapper.set(fn, ({ detail }) => fn(detail));
|
||||
storageBus.addEventListener(key, storageFnToWrapper.get(fn));
|
||||
},
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {(value: any) => void} fn
|
||||
*/
|
||||
offChange(key, fn) {
|
||||
storageBus.removeEventListener(key, storageFnToWrapper.get(fn));
|
||||
storageFnToWrapper.delete(fn);
|
||||
},
|
||||
};
|
||||
|
||||
cleanupExpirableStorage();
|
||||
setInterval(cleanupExpirableStorage, CLEAR_INTERVAL);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Record } from "@mail/core/common/record";
|
||||
|
||||
export class LivechatChannel extends Record {
|
||||
static _name = "im_livechat.channel";
|
||||
static id = "id";
|
||||
|
||||
/** @type {boolean} */
|
||||
are_you_inside;
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {string} */
|
||||
name;
|
||||
}
|
||||
LivechatChannel.register();
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { fields } from "@mail/model/misc";
|
||||
import { Record } from "@mail/model/record";
|
||||
|
||||
export class LivechatChannelRule extends Record {
|
||||
static id = "id";
|
||||
static _name = "im_livechat.channel.rule";
|
||||
|
||||
/** @type {string} */
|
||||
action;
|
||||
/** @type {number} */
|
||||
autopopup_timer;
|
||||
chatbot_script_id = fields.One("chatbot.script");
|
||||
/** @type {number} */
|
||||
id;
|
||||
}
|
||||
LivechatChannelRule.register();
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { ActionPanel } from "@mail/discuss/core/common/action_panel";
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
import { useAutofocus, useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const commandRegistry = registry.category("discuss.channel_commands");
|
||||
|
||||
export class LivechatCommandDialog extends Component {
|
||||
static template = "im_livechat.LivechatCommandDialog";
|
||||
static components = { ActionPanel };
|
||||
static props = ["thread", "close", "commandName", "placeholderText", "title", "icon"];
|
||||
|
||||
setup() {
|
||||
this.state = useState({ inputText: "" });
|
||||
this.store = useService("mail.store");
|
||||
useAutofocus();
|
||||
}
|
||||
|
||||
onKeydown(ev) {
|
||||
if (ev.key === "Enter" && this.state.inputText.trim().length > 0) {
|
||||
this.executeCommand();
|
||||
}
|
||||
}
|
||||
|
||||
executeCommand() {
|
||||
const command = commandRegistry.get(this.props.commandName, false);
|
||||
if (command) {
|
||||
this.props.thread.executeCommand(
|
||||
command,
|
||||
`/${this.props.commandName} ${this.state.inputText}`
|
||||
);
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.o-livechat-LivechatCommandDialog-form:focus-within {
|
||||
border-color: $input-focus-border-color;
|
||||
color: $input-focus-color;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="im_livechat.LivechatCommandDialog">
|
||||
<ActionPanel title="props.title" icon="props.icon" resizable="false">
|
||||
<div class="input-group my-2 shadow-sm">
|
||||
<div class="o-livechat-LivechatCommandDialog-form form-control bg-view p-0" aria-autocomplete="list">
|
||||
<input type="text" class="border-0 h-100 rounded px-2" accesskey="Q" t-att-placeholder="props.placeholderText" t-on-keydown="onKeydown" t-ref="autofocus" t-model="state.inputText"/>
|
||||
</div>
|
||||
<button class="btn btn-primary" t-on-click="executeCommand" t-att-disabled="!state.inputText" t-out="props.title"/>
|
||||
</div>
|
||||
</ActionPanel>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Record } from "@mail/core/common/record";
|
||||
|
||||
export class LivechatConversationTag extends Record {
|
||||
static _name = "im_livechat.conversation.tag";
|
||||
static id = "id";
|
||||
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {string} */
|
||||
name;
|
||||
/** @type {number} */
|
||||
color;
|
||||
}
|
||||
LivechatConversationTag.register();
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Record } from "@mail/core/common/record";
|
||||
|
||||
export class LivechatExpertise extends Record {
|
||||
static id = "id";
|
||||
static _name = "im_livechat.expertise";
|
||||
|
||||
/** @type {number} */
|
||||
id;
|
||||
/** @type {string} */
|
||||
name;
|
||||
}
|
||||
LivechatExpertise.register();
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Message } from "@mail/core/common/message_model";
|
||||
import { fields } from "@mail/core/common/record";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/** @type {import("models").Message} */
|
||||
const messagePatch = {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.chatbotStep = fields.One("ChatbotStep", { inverse: "message" });
|
||||
},
|
||||
canReplyTo(thread) {
|
||||
return (
|
||||
super.canReplyTo(thread) &&
|
||||
(thread?.channel_type !== "livechat" || !thread.composerDisabled)
|
||||
);
|
||||
},
|
||||
isTranslatable(thread) {
|
||||
return (
|
||||
super.isTranslatable(thread) ||
|
||||
(this.store.hasMessageTranslationFeature &&
|
||||
thread?.channel_type === "livechat" &&
|
||||
this.store.self?.main_user_id?.share === false)
|
||||
);
|
||||
},
|
||||
};
|
||||
patch(Message.prototype, messagePatch);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export function isValidEmail(val) {
|
||||
// http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
|
||||
const re =
|
||||
/^(([^<>()[\].,;:\s@"]+(\.[^<>()[\].,;:\s@"]+)*)|(".+"))@(([^<>()[\].,;:\s@"]+\.)+[^<>()[\].,;:\s@"]{2,})$/i;
|
||||
return re.test(val);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { ResPartner } from "@mail/core/common/res_partner_model";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/** @type {import("models").Persona} */
|
||||
const resPartnerPatch = {
|
||||
setup() {
|
||||
super.setup();
|
||||
/** @type {String[]} */
|
||||
this.livechat_languages = [];
|
||||
/**
|
||||
* @deprecated Use `user.livechat_expertise_ids` instead.
|
||||
* @type {String[]}
|
||||
*/
|
||||
this.livechat_expertise = [];
|
||||
},
|
||||
_computeDisplayName() {
|
||||
return super._computeDisplayName() || this.user_livechat_username;
|
||||
},
|
||||
};
|
||||
patch(ResPartner.prototype, resPartnerPatch);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { ResUsers } from "@mail/core/common/res_users_model";
|
||||
import { fields } from "@mail/model/misc";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/** @type {import("models").ResUsers} */
|
||||
const resUsersPatch = {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.is_livechat_manager = false;
|
||||
this.livechat_expertise_ids = fields.Many("im_livechat.expertise");
|
||||
},
|
||||
};
|
||||
patch(ResUsers.prototype, resUsersPatch);
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { ThreadAction, threadActionsRegistry } from "@mail/core/common/thread_actions";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(ThreadAction.prototype, {
|
||||
_condition({ action, store, thread }) {
|
||||
const visitorActions = [
|
||||
"fold-chat-window",
|
||||
"close",
|
||||
"restart",
|
||||
"call-settings",
|
||||
"meeting-chat",
|
||||
];
|
||||
if (
|
||||
thread?.channel_type === "livechat" &&
|
||||
store.self_partner?.main_user_id?.share !== false &&
|
||||
!visitorActions.includes(action.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return super._condition(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
patch(threadActionsRegistry.get("invite-people"), {
|
||||
condition({ thread }) {
|
||||
if (thread?.channel_type === "livechat") {
|
||||
return super.condition(...arguments) && !thread.livechat_end_dt;
|
||||
}
|
||||
return super.condition(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
patch(threadActionsRegistry.get("notification-settings"), {
|
||||
condition({ thread }) {
|
||||
if (thread?.channel_type === "livechat") {
|
||||
return super.condition(...arguments) && !thread.livechat_end_dt;
|
||||
}
|
||||
return super.condition(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
patch(threadActionsRegistry.get("camera-call"), {
|
||||
condition({ thread }) {
|
||||
if (thread?.channel_type === "livechat") {
|
||||
return super.condition(...arguments) && !thread.livechat_end_dt;
|
||||
}
|
||||
return super.condition(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
patch(threadActionsRegistry.get("call"), {
|
||||
condition({ thread }) {
|
||||
if (thread?.channel_type === "livechat") {
|
||||
return super.condition(...arguments) && !thread.livechat_end_dt;
|
||||
}
|
||||
return super.condition(...arguments);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { fields } from "@mail/core/common/record";
|
||||
import { Thread } from "@mail/core/common/thread_model";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { url } from "@web/core/utils/urls";
|
||||
|
||||
patch(Thread.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.livechat_end_dt = fields.Datetime();
|
||||
this.livechat_lang_id = fields.One("res.lang");
|
||||
this.livechat_operator_id = fields.One("res.partner");
|
||||
this.livechat_conversation_tag_ids = fields.Many("im_livechat.conversation.tag");
|
||||
this.chatbot = fields.One("Chatbot");
|
||||
this.livechatVisitorMember = fields.One("discuss.channel.member", {
|
||||
compute() {
|
||||
if (this.channel_type !== "livechat") {
|
||||
return;
|
||||
}
|
||||
// For livechat threads, the correspondent is the first
|
||||
// channel member that is not the operator.
|
||||
const orderedChannelMembers = [...this.channel_member_ids].sort(
|
||||
(a, b) => a.id - b.id
|
||||
);
|
||||
const isFirstMemberOperator = orderedChannelMembers[0]?.partner_id?.eq(
|
||||
this.livechat_operator_id
|
||||
);
|
||||
const visitor = isFirstMemberOperator
|
||||
? orderedChannelMembers[1]
|
||||
: orderedChannelMembers[0];
|
||||
return visitor;
|
||||
},
|
||||
});
|
||||
/** @type {true|undefined} */
|
||||
this.open_chat_window = fields.Attr(undefined, {
|
||||
/** @this {import("models").Thread} */
|
||||
onUpdate() {
|
||||
if (this.open_chat_window) {
|
||||
this.open_chat_window = undefined;
|
||||
this.openChatWindow({ focus: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
get autoOpenChatWindowOnNewMessage() {
|
||||
return (
|
||||
(this.channel_type === "livechat" &&
|
||||
!this.store.chatHub.compact &&
|
||||
this.self_member_id) ||
|
||||
super.autoOpenChatWindowOnNewMessage
|
||||
);
|
||||
},
|
||||
get showCorrespondentCountry() {
|
||||
if (this.channel_type === "livechat") {
|
||||
return (
|
||||
this.correspondent?.livechat_member_type === "visitor" &&
|
||||
Boolean(this.correspondentCountry)
|
||||
);
|
||||
}
|
||||
return super.showCorrespondentCountry;
|
||||
},
|
||||
get typesAllowingCalls() {
|
||||
return super.typesAllowingCalls.concat(["livechat"]);
|
||||
},
|
||||
|
||||
get isChatChannel() {
|
||||
return this.channel_type === "livechat" || super.isChatChannel;
|
||||
},
|
||||
|
||||
get allowDescription() {
|
||||
return this.channel_type === "livechat" || super.allowDescription;
|
||||
},
|
||||
|
||||
get composerDisabled() {
|
||||
return this.channel_type === "livechat" && this.livechat_end_dt;
|
||||
},
|
||||
|
||||
get composerDisabledText() {
|
||||
return this.channel_type === "livechat" && this.livechat_end_dt
|
||||
? _t("This livechat conversation has ended")
|
||||
: "";
|
||||
},
|
||||
|
||||
get transcriptUrl() {
|
||||
return url(`/im_livechat/download_transcript/${this.id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {import("models").Persona} persona
|
||||
*/
|
||||
getPersonaName(persona) {
|
||||
if (this.channel_type === "livechat" && persona?.user_livechat_username) {
|
||||
return persona.user_livechat_username;
|
||||
}
|
||||
return super.getPersonaName(persona);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { Thread } from "@mail/core/common/thread";
|
||||
import { useEffect } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { user } from "@web/core/user";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
patch(Thread.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.IM_STATUS_DELAY = 1500;
|
||||
Object.assign(this.state, { isVisitorOffline: false }); // starting online avoids flickering
|
||||
useEffect(
|
||||
() => {
|
||||
if (!this.props.thread.livechatVisitorMember?.im_status) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.imStatusTimeoutId);
|
||||
if (this.props.thread.livechatVisitorMember.im_status.includes("offline")) {
|
||||
this.imStatusTimeoutId = setTimeout(
|
||||
() => (this.state.isVisitorOffline = true),
|
||||
this.IM_STATUS_DELAY
|
||||
);
|
||||
} else {
|
||||
this.state.isVisitorOffline = false;
|
||||
}
|
||||
return () => clearTimeout(this.imStatusTimeoutId);
|
||||
},
|
||||
() => [this.props.thread.livechatVisitorMember?.im_status]
|
||||
);
|
||||
},
|
||||
get showVisitorDisconnected() {
|
||||
return (
|
||||
this.store.self.notEq(this.props.thread.livechatVisitorMember?.persona) &&
|
||||
!this.props.thread.livechat_end_dt &&
|
||||
this.props.thread.livechatVisitorMember &&
|
||||
this.state.isVisitorOffline
|
||||
);
|
||||
},
|
||||
get disconnectedText() {
|
||||
const offlineSince = this.props.thread.livechatVisitorMember.persona.offline_since;
|
||||
if (!offlineSince) {
|
||||
return _t("Visitor is disconnected");
|
||||
}
|
||||
const userLocale = { locale: user.lang };
|
||||
if (offlineSince.hasSame(DateTime.now(), "day")) {
|
||||
return _t("Visitor is disconnected since %(time)s", {
|
||||
time: offlineSince.toLocaleString(DateTime.TIME_SIMPLE, userLocale),
|
||||
});
|
||||
}
|
||||
if (offlineSince.hasSame(DateTime.now().minus({ day: 1 }), "day")) {
|
||||
return _t("Visitor is disconnected since yesterday at %(time)s", {
|
||||
time: offlineSince.toLocaleString(DateTime.TIME_SIMPLE, userLocale),
|
||||
});
|
||||
}
|
||||
return _t("Visitor is disconnected");
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.Thread" t-inherit-mode="extension">
|
||||
<xpath expr="//div[hasclass('o-mail-Thread')]" position="before">
|
||||
<div t-if="showVisitorDisconnected" class="o-livechat-VisitorDisconnected bg-secondary py-1 px-3 fw-bold smaller shadow-sm border border-secondary" t-out="disconnectedText"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { isValidEmail } from "@im_livechat/core/common/misc";
|
||||
import { Component, onWillUpdateProps, useEffect, useState } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {import("models").Thread}
|
||||
* @extends {Component<Props, Env>}
|
||||
*/
|
||||
export class TranscriptSender extends Component {
|
||||
static template = "im_livechat.TranscriptSender";
|
||||
static props = ["thread", "disableOnSend?"];
|
||||
|
||||
STATUS = Object.freeze({
|
||||
IDLE: "idle",
|
||||
SENDING: "sending",
|
||||
SENT: "sent",
|
||||
FAILED: "failed",
|
||||
});
|
||||
|
||||
setup() {
|
||||
this.isValidEmail = isValidEmail;
|
||||
this.state = useState({
|
||||
email: this.props.thread.livechatVisitorMember?.persona.email,
|
||||
status: this.STATUS.IDLE,
|
||||
});
|
||||
this.store = useService("mail.store");
|
||||
onWillUpdateProps((newProps) => {
|
||||
if (this.props.thread?.notEq(newProps.thread)) {
|
||||
this.state.email = newProps.thread.livechatVisitorMember?.persona.email;
|
||||
this.state.status = this.STATUS.IDLE;
|
||||
}
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
this.state.status = this.STATUS.IDLE;
|
||||
},
|
||||
() => [this.state.email]
|
||||
);
|
||||
}
|
||||
|
||||
get isButtonDisabled() {
|
||||
return (
|
||||
[this.STATUS.SENDING, this.STATUS.SENT].includes(this.state.status) ||
|
||||
!this.isValidEmail(this.state.email)
|
||||
);
|
||||
}
|
||||
|
||||
get isInputDisabled() {
|
||||
return (
|
||||
!(this.store.self_partner?.main_user_id?.share === false) ||
|
||||
this.state.status === this.STATUS.SENDING ||
|
||||
(this.props.disableOnSend && this.state.status === this.STATUS.SENT)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {KeyboardEvent} ev */
|
||||
onKeydown(ev) {
|
||||
if (ev.key == "Enter" && !this.isButtonDisabled) {
|
||||
this.onClickSend();
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.state.status = this.STATUS.IDLE;
|
||||
this.state.email = "";
|
||||
}
|
||||
|
||||
async onClickSend() {
|
||||
this.state.status = this.STATUS.SENDING;
|
||||
try {
|
||||
await rpc("/im_livechat/email_livechat_transcript", {
|
||||
channel_id: this.props.thread.id,
|
||||
email: this.state.email,
|
||||
});
|
||||
this.state.status = this.STATUS.SENT;
|
||||
} catch {
|
||||
this.state.status = this.STATUS.FAILED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="im_livechat.TranscriptSender">
|
||||
<div class="o-livechat-TranscriptSender">
|
||||
<div class="input-group">
|
||||
<input t-ref="input" t-on-keydown="onKeydown" t-model="state.email" t-att-disabled="isInputDisabled" type="text" class="form-control" t-att-class="{'bg-view': !isInputDisabled}" placeholder="mail@example.com" />
|
||||
<button class="btn btn-primary" type="button" data-action="sendTranscript" t-att-disabled="isButtonDisabled" t-on-click="onClickSend">
|
||||
<i class="fa" t-att-class="{
|
||||
'fa-circle-o-notch fa-spin': state.status === STATUS.SENDING,
|
||||
'fa-check': state.status === STATUS.SENT,
|
||||
'fa-paper-plane': state.status === STATUS.IDLE,
|
||||
'fa-repeat': state.status === STATUS.FAILED,
|
||||
}" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text ms-1">
|
||||
<t t-if="state.status === STATUS.SENT">The conversation was sent.</t>
|
||||
<t t-elif="state.status === STATUS.FAILED">An error occurred. Please try again.</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
24
odoo-bringout-oca-ocb-im_livechat/im_livechat/static/src/core/public_web/@types/models.d.ts
vendored
Normal file
24
odoo-bringout-oca-ocb-im_livechat/im_livechat/static/src/core/public_web/@types/models.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
declare module "models" {
|
||||
export interface DiscussApp {
|
||||
defaultLivechatCategory: DiscussAppCategory;
|
||||
lastThread: Thread;
|
||||
livechatLookingForHelpCategory: DiscussAppCategory;
|
||||
livechats: Thread[];
|
||||
}
|
||||
export interface DiscussAppCategory {
|
||||
livechat_channel_id: LivechatChannel;
|
||||
}
|
||||
export interface LivechatChannel {
|
||||
appCategory: DiscussAppCategory;
|
||||
threads: Thread[];
|
||||
}
|
||||
export interface Thread {
|
||||
appAsLivechats: DiscussApp;
|
||||
country_id: Country;
|
||||
livechat_channel_id: LivechatChannel;
|
||||
livechat_expertise_ids: LivechatExpertise[];
|
||||
livechat_status: "in_progress"|"waiting"|"need_help"|undefined;
|
||||
matchesSelfExpertise: Readonly<boolean>;
|
||||
shadowedBySelf: number;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { ChatWindow } from "@mail/core/common/chat_window_model";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(ChatWindow.prototype, {
|
||||
_onClose(options = {}) {
|
||||
if (
|
||||
this.thread?.channel_type === "livechat" &&
|
||||
this.thread.livechatVisitorMember?.persona?.notEq(this.store.self)
|
||||
) {
|
||||
const thread = this.thread; // save ref before delete
|
||||
super._onClose(...arguments);
|
||||
this.delete();
|
||||
if (options.notifyState) {
|
||||
thread.leaveChannel({ force: true });
|
||||
}
|
||||
} else {
|
||||
super._onClose(...arguments);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { patch } from "@web/core/utils/patch";
|
||||
import { DiscussAppCategory } from "@mail/discuss/core/public_web/discuss_app_category_model";
|
||||
import { fields } from "@mail/core/common/record";
|
||||
import { compareDatetime } from "@mail/utils/common/misc";
|
||||
|
||||
patch(DiscussAppCategory.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.livechat_channel_id = fields.One("im_livechat.channel", {
|
||||
inverse: "appCategory",
|
||||
onDelete() {
|
||||
this.delete();
|
||||
},
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {import("models").Thread} t1
|
||||
* @param {import("models").Thread} t2
|
||||
*/
|
||||
sortThreads(t1, t2) {
|
||||
if (this.eq(this.app?.livechatLookingForHelpCategory)) {
|
||||
return t1.id - t2.id;
|
||||
}
|
||||
if (this.livechat_channel_id || this.eq(this.app?.defaultLivechatCategory)) {
|
||||
return compareDatetime(t2.lastInterestDt, t1.lastInterestDt) || t2.id - t1.id;
|
||||
}
|
||||
return super.sortThreads(t1, t2);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import { fields } from "@mail/core/common/record";
|
||||
import { DiscussApp } from "@mail/core/public_web/discuss_app_model";
|
||||
import { effectWithDebouncedCleanup } from "@mail/utils/common/misc";
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
// Looking for help subscription is triggered when the sidebar category is
|
||||
// opened, and when the discuss app is active. To avoid unsubscribing right away
|
||||
// when the user closes the sidebar or switches to another app, wait for 5
|
||||
// minutes before unsubscribing.
|
||||
export const LFH_UNSUBSCRIBE_DELAY = 5 * 60 * 1000;
|
||||
export const LIVECHAT_INFO_DEFAULT_OPEN_LS = "im_livechat.isInfoPanelOpenByDefault";
|
||||
|
||||
const discussAppStaticPatch = {
|
||||
new() {
|
||||
/** @type {import("models").DiscussApp} */
|
||||
const app = super.new(...arguments);
|
||||
effectWithDebouncedCleanup({
|
||||
delay: LFH_UNSUBSCRIBE_DELAY,
|
||||
dependencies: (app) => ({
|
||||
busService: app.store.env.services.bus_service,
|
||||
category: app.livechatLookingForHelpCategory,
|
||||
store: app.store,
|
||||
}),
|
||||
effect({ busService, category, store }) {
|
||||
busService.addChannel("im_livechat.looking_for_help");
|
||||
store.fetchStoreData("/im_livechat/looking_for_help");
|
||||
return () => {
|
||||
busService.deleteChannel("im_livechat.looking_for_help");
|
||||
if (!category.exists()) {
|
||||
return;
|
||||
}
|
||||
category.threads
|
||||
.filter((thread) => !thread.self_member_id && !thread.isLocallyPinned)
|
||||
.forEach((thread) => thread.delete());
|
||||
};
|
||||
},
|
||||
predicate: (app) =>
|
||||
Boolean(
|
||||
app.exists() &&
|
||||
app.livechatLookingForHelpCategory?.open &&
|
||||
!app.livechatLookingForHelpCategory.hidden &&
|
||||
app.isActive
|
||||
),
|
||||
reactiveTargets: [app],
|
||||
});
|
||||
return app;
|
||||
},
|
||||
};
|
||||
patch(DiscussApp, discussAppStaticPatch);
|
||||
|
||||
patch(DiscussApp.prototype, {
|
||||
setup(env) {
|
||||
super.setup(...arguments);
|
||||
this.defaultLivechatCategory = fields.One("DiscussAppCategory", {
|
||||
compute() {
|
||||
return {
|
||||
extraClass: "o-mail-DiscussSidebarCategory-livechat",
|
||||
hideWhenEmpty: true,
|
||||
icon: "fa fa-commenting-o",
|
||||
id: `im_livechat.category_default`,
|
||||
name: _t("Livechat"),
|
||||
sequence: 21,
|
||||
};
|
||||
},
|
||||
eager: true,
|
||||
});
|
||||
this.livechatLookingForHelpCategory = fields.One("DiscussAppCategory", {
|
||||
compute() {
|
||||
if (!this.store.has_access_livechat) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
extraClass: "o-mail-DiscussSidebarCategory-livechatNeedHelp",
|
||||
icon: "fa fa-exclamation-circle",
|
||||
id: `im_livechat.category_need_help`,
|
||||
name: _t("Looking for help"),
|
||||
sequence: 15,
|
||||
};
|
||||
},
|
||||
eager: true,
|
||||
});
|
||||
this.lastThread = fields.One("Thread");
|
||||
this.livechats = fields.Many("Thread", { inverse: "appAsLivechats" });
|
||||
this._recomputeIsLivechatInfoPanelOpenedByDefault = 0;
|
||||
this.isLivechatInfoPanelOpenByDefault = fields.Attr(true, {
|
||||
compute() {
|
||||
void this._recomputeIsLivechatInfoPanelOpenedByDefault;
|
||||
return browser.localStorage.getItem(LIVECHAT_INFO_DEFAULT_OPEN_LS) !== "false";
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
shouldDisableMemberPanelAutoOpenFromClose(nextActiveAction) {
|
||||
if (nextActiveAction?.id === "livechat-info") {
|
||||
return false;
|
||||
}
|
||||
return super.shouldDisableMemberPanelAutoOpenFromClose(...arguments);
|
||||
},
|
||||
|
||||
_threadOnUpdate() {
|
||||
if (
|
||||
this.lastThread?.notEq(this.thread) &&
|
||||
(this.lastThread.livechat_status === "need_help" || this.lastThread.unpinOnThreadSwitch)
|
||||
) {
|
||||
this.lastThread.isLocallyPinned = false;
|
||||
}
|
||||
if (this.thread?.livechat_status === "need_help" && !this.thread.self_member_id) {
|
||||
this.thread.isLocallyPinned = true;
|
||||
}
|
||||
this.lastThread = this.thread;
|
||||
super._threadOnUpdate();
|
||||
},
|
||||
|
||||
onStorage(ev) {
|
||||
super.onStorage(ev);
|
||||
if (ev.key === LIVECHAT_INFO_DEFAULT_OPEN_LS) {
|
||||
this._recomputeIsLivechatInfoPanelOpenedByDefault++;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.DiscussContent" t-inherit-mode="extension">
|
||||
<xpath expr="//Composer" position="replace">
|
||||
<span t-if="thread?.composerDisabled" class="bg-200 py-1 text-center fst-italic fw-bold text-muted" t-esc="thread.composerDisabledText"/>
|
||||
<t t-else="">$0</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.o-mail-DiscussSidebarCategory-livechatJoinedIndicatorCompact {
|
||||
right: 11px;
|
||||
bottom: 1px;
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.DiscussSidebarCategory.main" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='titleText']" position="before">
|
||||
<i t-if="!store.discuss.isSidebarCompact and category.livechat_channel_id?.are_you_inside" class="fa fa-circle text-success o-xxsmaller me-1" title="You have joined this live chat channel"/>
|
||||
</xpath>
|
||||
<xpath expr="//*[@name='header']" position="inside">
|
||||
<i t-if="store.discuss.isSidebarCompact and category.livechat_channel_id?.are_you_inside" class="fa fa-circle text-success o-xxxs position-absolute o-mail-DiscussSidebarCategory-livechatJoinedIndicatorCompact"/>
|
||||
</xpath>
|
||||
</t>
|
||||
<t t-inherit="mail.DiscussSidebarCategory" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@t-ref='floatingCategoryName']" position="after">
|
||||
<span t-if="category.livechat_channel_id?.are_you_inside" class="d-flex align-items-center">
|
||||
<i class="fa fa-circle text-success o-xxxs me-1"/>
|
||||
<span class="text-muted smaller fst-italic text-success pe-1">joined</span>
|
||||
</span>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-inherit="mail.DiscussSidebarChannel.main" t-inherit-mode="extension">
|
||||
<xpath expr="//*[hasclass('o-mail-DiscussSidebarChannel-itemName')]" position="replace">
|
||||
<div t-if="!store.discuss.isSidebarCompact and thread.discussAppCategory.eq(store.discuss.livechatLookingForHelpCategory) and thread.matchesSelfExpertise" class="d-flex justify-content-between align-items-baseline o-min-width-0">
|
||||
<t>$0</t>
|
||||
<i class="fa fa-star o-mail-starred me-2" role="image" title="Relevant to your expertise"/>
|
||||
</div>
|
||||
<t t-else="">$0</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { LivechatChannel } from "@im_livechat/core/common/livechat_channel_model";
|
||||
import { fields } from "@mail/core/common/record";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
const livechatChannelPatch = {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.appCategory = fields.One("DiscussAppCategory", {
|
||||
compute() {
|
||||
return {
|
||||
extraClass: "o-mail-DiscussSidebarCategory-livechat",
|
||||
hideWhenEmpty: !this.are_you_inside,
|
||||
id: `im_livechat.category_${this.id}`,
|
||||
icon: "fa fa-commenting-o",
|
||||
name: this.name,
|
||||
sequence: 22,
|
||||
};
|
||||
},
|
||||
eager: true,
|
||||
inverse: "livechat_channel_id",
|
||||
});
|
||||
this.threads = fields.Many("Thread", { inverse: "livechat_channel_id" });
|
||||
},
|
||||
};
|
||||
patch(LivechatChannel.prototype, livechatChannelPatch);
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { MessagingMenu } from "@mail/core/public_web/messaging_menu";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(MessagingMenu.prototype, {
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get _tabs() {
|
||||
const items = super._tabs;
|
||||
const hasLivechats = Object.values(this.store.Thread.records).some(
|
||||
({ channel_type }) => channel_type === "livechat"
|
||||
);
|
||||
if (hasLivechats) {
|
||||
items.push({
|
||||
counter: this.store.discuss.livechats.reduce(
|
||||
(acc, channel) =>
|
||||
channel.self_member_id?.message_unread_counter > 0 ? acc + 1 : acc,
|
||||
0
|
||||
),
|
||||
id: "livechat",
|
||||
icon: "fa fa-commenting-o",
|
||||
activeIcon: "fa fa-commenting",
|
||||
label: _t("Live Chats"),
|
||||
sequence: 60,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { fields } from "@mail/core/common/record";
|
||||
import { Thread } from "@mail/core/common/thread_model";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(Thread.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.appAsLivechats = fields.One("DiscussApp", {
|
||||
compute() {
|
||||
return this.channel_type === "livechat" ? this.store.discuss : null;
|
||||
},
|
||||
});
|
||||
this.country_id = fields.One("res.country");
|
||||
this.livechat_channel_id = fields.One("im_livechat.channel", { inverse: "threads" });
|
||||
this.livechat_expertise_ids = fields.Many("im_livechat.expertise");
|
||||
/** @type {"in_progress"|"waiting"|"need_help"|undefined} */
|
||||
this.livechat_status = fields.Attr(undefined, {
|
||||
onUpdate() {
|
||||
if (this.livechat_status === "need_help") {
|
||||
this.wasLookingForHelp = true;
|
||||
this.unpinOnThreadSwitch = false;
|
||||
return;
|
||||
}
|
||||
if (this.wasLookingForHelp) {
|
||||
this.wasLookingForHelp = false;
|
||||
// Still the active thread; keep it pinned after leaving "need help" status.
|
||||
// The agent may interact with the thread, keeping it pinned, or it will be
|
||||
// unpinned on the next thread switch to avoid bloating the sidebar.
|
||||
this.unpinOnThreadSwitch = this.eq(this.store.discuss?.thread);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.shadowedBySelf = 0;
|
||||
},
|
||||
get canLeave() {
|
||||
const lookingForHelpCategory = this.store.discuss.livechatLookingForHelpCategory;
|
||||
return (
|
||||
super.canLeave &&
|
||||
(!lookingForHelpCategory ||
|
||||
lookingForHelpCategory.notEq(this.discussAppCategory) ||
|
||||
this.self_member_id)
|
||||
);
|
||||
},
|
||||
_computeDiscussAppCategory() {
|
||||
if (this.channel_type !== "livechat") {
|
||||
return super._computeDiscussAppCategory();
|
||||
}
|
||||
if (
|
||||
this.livechat_status === "need_help" &&
|
||||
this.store.discuss.livechatLookingForHelpCategory
|
||||
) {
|
||||
return this.store.discuss.livechatLookingForHelpCategory;
|
||||
}
|
||||
return (
|
||||
this.livechat_channel_id?.appCategory ?? this.appAsLivechats?.defaultLivechatCategory
|
||||
);
|
||||
},
|
||||
get hasMemberList() {
|
||||
return this.channel_type === "livechat" || super.hasMemberList;
|
||||
},
|
||||
get allowedToLeaveChannelTypes() {
|
||||
return [...super.allowedToLeaveChannelTypes, "livechat"];
|
||||
},
|
||||
get correspondents() {
|
||||
return super.correspondents.filter(
|
||||
(correspondent) => correspondent.livechat_member_type !== "bot"
|
||||
);
|
||||
},
|
||||
|
||||
computeCorrespondent() {
|
||||
const correspondent = super.computeCorrespondent();
|
||||
if (this.channel_type === "livechat" && !correspondent) {
|
||||
return this.livechatVisitorMember;
|
||||
}
|
||||
return correspondent;
|
||||
},
|
||||
|
||||
_computeDisplayInSidebar() {
|
||||
return this.livechat_status === "need_help" || super._computeDisplayInSidebar();
|
||||
},
|
||||
|
||||
get displayName() {
|
||||
if (
|
||||
this.channel_type !== "livechat" ||
|
||||
!this.correspondent ||
|
||||
this.self_member_id?.custom_channel_name
|
||||
) {
|
||||
return super.displayName;
|
||||
}
|
||||
if (!this.correspondent.persona.is_public && this.correspondent.persona.country) {
|
||||
return `${this.correspondent.name} (${this.correspondent.persona.country.name})`;
|
||||
}
|
||||
if (this.country_id) {
|
||||
return `${this.correspondent.name} (${this.country_id.name})`;
|
||||
}
|
||||
return this.correspondent.name;
|
||||
},
|
||||
|
||||
get avatarUrl() {
|
||||
if (this.channel_type === "livechat" && this.correspondent) {
|
||||
return this.correspondent.avatarUrl;
|
||||
}
|
||||
return super.avatarUrl;
|
||||
},
|
||||
|
||||
get inChathubOnNewMessage() {
|
||||
if (this.channel_type === "livechat") {
|
||||
return Boolean(this.self_member_id);
|
||||
}
|
||||
return super.inChathubOnNewMessage;
|
||||
},
|
||||
get notifyWhenOutOfFocus() {
|
||||
if (this.channel_type === "livechat") {
|
||||
return (
|
||||
this.self_member_id || this.shadowedBySelf || this.eq(this.store.discuss?.thread)
|
||||
);
|
||||
}
|
||||
return super.notifyWhenOutOfFocus;
|
||||
},
|
||||
get matchesSelfExpertise() {
|
||||
return (
|
||||
this.store.self_partner?.main_user_id &&
|
||||
this.livechat_expertise_ids.some((expertise) =>
|
||||
expertise.in(this.store.self_partner.main_user_id.livechat_expertise_ids)
|
||||
)
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {boolean} pushState
|
||||
*/
|
||||
setAsDiscussThread(pushState) {
|
||||
super.setAsDiscussThread(pushState);
|
||||
if (this.store.env.services.ui.isSmall && this.channel_type === "livechat") {
|
||||
this.store.discuss.activeTab = "livechat";
|
||||
}
|
||||
},
|
||||
get shouldSubscribeToBusChannel() {
|
||||
return super.shouldSubscribeToBusChannel || Boolean(this.shadowedBySelf);
|
||||
},
|
||||
async leaveChannel({ force = false } = {}) {
|
||||
if (
|
||||
this.channel_type === "livechat" &&
|
||||
this.channel_member_ids.length <= 2 &&
|
||||
this.self_member_id &&
|
||||
!this.livechat_end_dt &&
|
||||
!force
|
||||
) {
|
||||
await this.askLeaveConfirmation(
|
||||
_t("Leaving will end the live chat. Do you want to proceed?")
|
||||
);
|
||||
}
|
||||
super.leaveChannel(...arguments);
|
||||
},
|
||||
});
|
||||
22
odoo-bringout-oca-ocb-im_livechat/im_livechat/static/src/core/web/@types/models.d.ts
vendored
Normal file
22
odoo-bringout-oca-ocb-im_livechat/im_livechat/static/src/core/web/@types/models.d.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
declare module "models" {
|
||||
export interface LivechatChannel {
|
||||
join: (param0: { notify: boolean }) => Promise<void>;
|
||||
joinTitle: Readonly<string>;
|
||||
leave: (param0: { notify: boolean }) => Promise<void>;
|
||||
leaveTitle: Readonly<string>;
|
||||
}
|
||||
export interface Store {
|
||||
goToOldestUnreadLivechatThread: () => boolean;
|
||||
has_access_livechat: boolean;
|
||||
livechatChannels: ReturnType<Store['makeCachedFetchData']>;
|
||||
livechatStatusButtons: Readonly<object[]>;
|
||||
}
|
||||
export interface Thread {
|
||||
hasFetchedLivechatSessionData: boolean;
|
||||
livechat_note: ReturnType<import("@odoo/owl").markup>|string;
|
||||
livechat_outcome: "no_answer"|"no_agent"|"no_failure"|"escalated"|undefined;
|
||||
livechatNoteText: string|undefined;
|
||||
livechatStatusLabel: Readonly<string>;
|
||||
updateLivechatStatus: (status: "in_progress"|"waiting"|"need_help") => void;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
registry.category("discuss.channel_commands").add("history", {
|
||||
condition: ({ store }) => store.has_access_livechat,
|
||||
channel_types: ["livechat"],
|
||||
help: _t("See 15 last visited pages"),
|
||||
methodName: "execute_command_history",
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o-discuss-ChannelInvitation-inCallTextColor {
|
||||
color: lighten($o-action, 5%);
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="discuss.ChannelInvitation-selectableItem" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='selectablePartnerName']" position="inside">
|
||||
<span t-if="props.thread?.channel_type === 'livechat' and selectablePartner.user_livechat_username" t-esc="selectablePartner.user_livechat_username" class="text-truncate text-muted smaller mx-2"/>
|
||||
<span t-if="props.thread?.channel_type === 'livechat' and selectablePartner.is_in_call" class="flex-shrink-0 opacity-75 smaller mx-2">
|
||||
<i class="fa fa-volume-up o-discuss-inCallIconColor me-1"/>
|
||||
<span class="o-discuss-ChannelInvitation-inCallTextColor">in a call</span>
|
||||
</span>
|
||||
</xpath>
|
||||
<xpath expr="//*[@name='selectablePartnerDetail']" position="inside">
|
||||
<div t-if="props.thread?.channel_type === 'livechat' and selectablePartner.lang_name" class="d-flex flex-wrap align-items-center gap-1">
|
||||
<span class="d-flex text-start fs-6 gap-1">
|
||||
<span class="badge rounded text-bg-primary" t-esc="selectablePartner.lang_name"/>
|
||||
<t t-foreach="selectablePartner.livechat_languages" t-as="language" t-key="language_index">
|
||||
<span class="badge rounded text-bg-primary" t-esc="language"/>
|
||||
</t>
|
||||
</span>
|
||||
<span class="d-flex text-start fs-6 gap-1">
|
||||
<i t-if="selectablePartner.livechat_expertise.length" class="fa fa-fw fa-graduation-cap" title="Expertise"/>
|
||||
<t t-foreach="selectablePartner.livechat_expertise" t-as="expertise" t-key="expertise_index">
|
||||
<span class="badge rounded text-bg-info bg-opacity-75 o-text-white" t-esc="expertise"/>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { ChannelMemberList } from "@mail/discuss/core/common/channel_member_list";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(ChannelMemberList.prototype, {
|
||||
canOpenChatWith(member) {
|
||||
return (
|
||||
super.canOpenChatWith(member) &&
|
||||
!member.partner_id?.is_public &&
|
||||
member.livechat_member_type !== "bot"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="discuss.channel_member" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@t-ref='displayName']" position="inside">
|
||||
<div t-if="member.channel_id.channel_type === 'livechat'" class="ms-2 d-flex flex-wrap">
|
||||
<span t-if="member.getLangName()" class="me-2">
|
||||
<i class="fa fa-comment-o me-1" aria-label="Lang"/>
|
||||
<t t-esc="member.getLangName()"/>
|
||||
</span>
|
||||
<span t-if="member.persona?.country_id or props.thread.country_id">
|
||||
<i class="fa fa-globe me-1" aria-label="country"/>
|
||||
<t t-esc="member.persona?.country_id?.name ?? props.thread.country_id.name"/>
|
||||
</span>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ChatBubble" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@t-ref='root']" position="inside">
|
||||
<t t-if="props.chatWindow.thread" t-call="im_livechat.LivechatStatusLabelOfThread">
|
||||
<t t-set="templateParams" t-value="{ livechatThread: props.chatWindow.thread }"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ChatWindow.headerContent" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='threadAvatar']" position="before">
|
||||
<t t-if="props.chatWindow.thread" t-call="im_livechat.LivechatStatusLabelOfThread">
|
||||
<t t-set="templateParams" t-value="{ livechatThread: props.chatWindow.thread }"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="im_livechat.LivechatChannelCommand">
|
||||
<div class="o_command_default d-flex align-items-center justify-content-between px-4 py-2">
|
||||
<i class="me-2" t-att-class="props.iconClass"/>
|
||||
<span class="flex-grow-1 text-ellipsis">
|
||||
<t t-slot="name"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Composer } from "@mail/core/common/composer";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(Composer.prototype, {
|
||||
onKeydown(ev) {
|
||||
super.onKeydown(ev);
|
||||
if (
|
||||
ev.key === "Tab" &&
|
||||
this.thread?.channel_type === "livechat" &&
|
||||
!this.props.composer.composerText
|
||||
) {
|
||||
const threadChanged = this.store.goToOldestUnreadLivechatThread();
|
||||
if (threadChanged) {
|
||||
// prevent chat window from switching to the next thread: as
|
||||
// we want to go to the oldest unread thread, not the next
|
||||
// one.
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}
|
||||
},
|
||||
get placeholder() {
|
||||
if (this.displayNextLivechatHint() && this.props.composer.isFocused) {
|
||||
return _t("Tab to next livechat");
|
||||
}
|
||||
return super.placeholder;
|
||||
},
|
||||
displayNextLivechatHint() {
|
||||
return (
|
||||
this.thread?.channel_type === "livechat" &&
|
||||
this.store.discuss.livechats.some(
|
||||
(thread) => thread.notEq(this.thread) && thread.isUnread
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { DiscussClientAction } from "@mail/core/public_web/discuss_client_action";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(DiscussClientAction.prototype, {
|
||||
async restoreDiscussThread() {
|
||||
if (this.store.has_access_livechat) {
|
||||
this.store.livechatChannels.fetch();
|
||||
this.store.livechatSelfExpertises.fetch();
|
||||
}
|
||||
return super.restoreDiscussThread(...arguments);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { DiscussContent } from "@mail/core/public_web/discuss_content";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(DiscussContent.prototype, {
|
||||
actionPanelAutoOpenFn() {
|
||||
const livechatInfoAction = this.threadActions.actions.find((a) => a.id === "livechat-info");
|
||||
if (livechatInfoAction && this.store.discuss.isLivechatInfoPanelOpenByDefault) {
|
||||
livechatInfoAction.open();
|
||||
} else {
|
||||
super.actionPanelAutoOpenFn();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
DiscussSidebarCategory,
|
||||
DiscussSidebarChannel,
|
||||
} from "@mail/discuss/core/public_web/discuss_sidebar_categories";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/** @type {import("@mail/discuss/core/public_web/discuss_sidebar_categories").DiscussSidebarCategory} */
|
||||
const DiscussSidebarCategoryPatch = {
|
||||
get actions() {
|
||||
const actions = super.actions;
|
||||
if (
|
||||
this.store.has_access_livechat &&
|
||||
this.category.livechat_channel_id &&
|
||||
this.category.open
|
||||
) {
|
||||
actions.push({
|
||||
onSelect: () => {
|
||||
if (this.category.livechat_channel_id.are_you_inside) {
|
||||
this.category.livechat_channel_id.leave({ notify: false });
|
||||
} else {
|
||||
this.category.livechat_channel_id.join({ notify: false });
|
||||
}
|
||||
},
|
||||
label: this.category.livechat_channel_id.are_you_inside
|
||||
? this.category.livechat_channel_id.leaveTitle
|
||||
: this.category.livechat_channel_id.joinTitle,
|
||||
icon: this.category.livechat_channel_id.are_you_inside
|
||||
? "fa fa-sign-out fa-rotate-180 text-danger"
|
||||
: "fa fa-sign-in text-success",
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {import("@mail/discuss/core/public_web/discuss_sidebar_categories").DiscussSidebarChannel} */
|
||||
const DiscussSidebarChannelPatch = {
|
||||
get attClassContainer() {
|
||||
return {
|
||||
...super.attClassContainer,
|
||||
"bg-100": this.thread.livechat_end_dt,
|
||||
};
|
||||
},
|
||||
get itemNameAttClass() {
|
||||
return {
|
||||
...super.itemNameAttClass,
|
||||
"fst-italic text-muted fw-normal": this.thread.livechat_end_dt,
|
||||
};
|
||||
},
|
||||
get threadAvatarAttClass() {
|
||||
return {
|
||||
...super.threadAvatarAttClass,
|
||||
"o-opacity-65": this.thread.livechat_end_dt,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
patch(DiscussSidebarCategory.prototype, DiscussSidebarCategoryPatch);
|
||||
patch(DiscussSidebarChannel.prototype, DiscussSidebarChannelPatch);
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
.o-livechat-LivechatStatusLabel-Sidebar {
|
||||
--bg-opacity: .1;
|
||||
--border-color: transparent;
|
||||
|
||||
&.bg-info {
|
||||
--background-color: RGBA(#{to-rgb($purple)}, var(--bg-opacity));
|
||||
}
|
||||
|
||||
.o-mail-DiscussSidebarChannel.o-active &,
|
||||
.o-mail-DiscussSidebarChannel &:hover,
|
||||
.o-mail-DiscussSidebarChannel &:focus-visible {
|
||||
--bg-opacity: 1;
|
||||
}
|
||||
|
||||
.o-mail-DiscussSidebarChannel.o-active & {
|
||||
--border-opacity: .75;
|
||||
}
|
||||
|
||||
.o-mail-DiscussSidebarChannel:hover &.bg-warning {
|
||||
--background-color: #{mix($o-webclient-background-color, $warning, 80%)};
|
||||
}
|
||||
.o-mail-DiscussSidebarChannel:hover &.bg-info {
|
||||
--background-color: #{mix($o-webclient-background-color, $purple, 80%)};
|
||||
}
|
||||
|
||||
.o-mail-DiscussSidebarChannel.o-active &.bg-warning {
|
||||
--background-color: #{mix($o-webclient-background-color, $warning, 70%)};
|
||||
--border-color: #{rgba($warning, 0.75)};
|
||||
}
|
||||
.o-mail-DiscussSidebarChannel.o-active &.bg-info {
|
||||
--background-color: #{mix($o-webclient-background-color, $purple, 90%)};
|
||||
--border-color: #{rgba($indigo, 0.75)};
|
||||
}
|
||||
}
|
||||
|
||||
.o-livechat-LivechatStatusLabel-icon.o-inDiscussSidebar {
|
||||
background-color: $white;
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.DiscussSidebarChannel.main" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='threadAvatar']" position="before">
|
||||
<i t-if="store.discuss.isSidebarCompact and thread.discussAppCategory.eq(store.discuss.livechatLookingForHelpCategory) and thread.matchesSelfExpertise"
|
||||
class="fa fa-star o-mail-starred position-absolute top-0 start-0 z-1"
|
||||
role="image"
|
||||
title="Relevant to your expertise"
|
||||
/>
|
||||
<t t-else="" t-call="im_livechat.LivechatStatusLabelOfThread">
|
||||
<t t-set="templateParams" t-value="{ livechatThread: props.thread }"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="im_livechat.LivechatStatusLabel">
|
||||
<t t-set="btn" t-value="templateParams.btn"/>
|
||||
<t t-set="inThreadActions" t-value="templateParams.inThreadActions"/>
|
||||
<span class="o-livechat-LivechatStatusLabel fa" t-att-title="btn.label" t-att-class="{
|
||||
'text-success': btn.status === 'in_progress',
|
||||
'o-waiting text-warning': btn.status === 'waiting',
|
||||
'o-help': btn.status === 'need_help',
|
||||
}">
|
||||
<i
|
||||
t-if="btn.icon"
|
||||
class="o-livechat-LivechatStatusLabel-icon"
|
||||
t-attf-class="{{ btn.icon }}"
|
||||
/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<t t-name="im_livechat.LivechatStatusLabelOfThread">
|
||||
<t t-set="livechatThread" t-value="templateParams.livechatThread"/>
|
||||
<span t-if="livechatThread.channel_type === 'livechat' and (['waiting', 'need_help'].includes(livechatThread.livechat_status) or livechatThread.livechat_end_dt)" class="o-livechat-LivechatStatusLabel" t-att-title="livechatThread.livechatStatusLabel" t-att-class="{
|
||||
'position-absolute top-0 start-0': env.inDiscussSidebar or env.inChatBubble or env.inNotificationItem,
|
||||
'o-livechat-LivechatStatusLabel-Sidebar end-0 bottom-0 rounded-2 border': env.inDiscussSidebar,
|
||||
'm-n2 z-1 bg-opacity-100': env.inChatBubble or env.inNotificationItem,
|
||||
'text-warning': livechatThread.livechat_status === 'waiting',
|
||||
'o-help': livechatThread.livechat_status === 'need_help',
|
||||
'bg-warning': livechatThread.livechat_status === 'waiting' and env.inDiscussSidebar,
|
||||
'bg-info': livechatThread.livechat_status === 'need_help' and env.inDiscussSidebar,
|
||||
}">
|
||||
<i class="o-livechat-LivechatStatusLabel-icon" t-attf-class="{{ livechatThread.livechat_end_dt ? 'fa fa-flag-checkered' : store.livechatStatusButtons.find(btn => btn.status === livechatThread.livechat_status)?.icon }}" t-att-class="{
|
||||
'o-white': livechatThread.livechat_end_dt,
|
||||
'position-absolute start-0 top-0 z-1 o-inDiscussSidebar rounded-circle o-px-0_5 m-n1': env.inDiscussSidebar,
|
||||
'fa-fw': !env.inDiscussSidebar,
|
||||
}"/>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<t t-name="im_livechat.LivechatStatusSelection">
|
||||
<t t-set="livechatThread" t-value="templateParams.livechatThread"/>
|
||||
<div class="o-livechat-LivechatStatusSelection list-group" t-ref="livechat-status-selection">
|
||||
<t t-foreach="store.livechatStatusButtons" t-as="btn" t-key="btn.status">
|
||||
<t t-set="isBtnActive" t-value="livechatThread.livechat_status === btn.status"/>
|
||||
<button
|
||||
class="list-group-item list-group-item-action d-flex align-items-center gap-2"
|
||||
t-att-class="{
|
||||
'active': isBtnActive,
|
||||
'o-inProgress': btn.status === 'in_progress',
|
||||
'bg-view': isBtnActive and btn.status === 'in_progress',
|
||||
'o-need-help': isBtnActive and btn.status === 'need_help',
|
||||
'o-waiting bg-warning-subtle text-warning': isBtnActive and btn.status === 'waiting',
|
||||
}"
|
||||
t-att-disabled="!store.has_access_livechat"
|
||||
t-on-click="() => livechatThread.updateLivechatStatus(btn.status)"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
t-att-checked="isBtnActive"
|
||||
class="form-check-input m-0"
|
||||
/>
|
||||
<span class="o-livechat-LivechatStatusSelection-Label text-truncate cursor-pointer flex-grow-1 " t-esc="btn.label"/>
|
||||
<t t-call="im_livechat.LivechatStatusLabel">
|
||||
<t t-set="templateParams" t-value="{ btn }"/>
|
||||
</t>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.DiscussSidebar" t-inherit-mode="extension">
|
||||
<xpath expr="//div" position="attributes">
|
||||
<attribute name="style" add="--mail-DiscussSidebar-itemActiveBgColor: var(--100)" separator=";"/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { x2ManyCommands } from "@web/core/orm_service";
|
||||
import { useTagNavigation } from "@web/core/record_selectors/tag_navigation_hook";
|
||||
import { TagsList } from "@web/core/tags_list/tags_list";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {import("models").Thread} channel
|
||||
* @extends {Component<Props, Env>}
|
||||
*/
|
||||
export class ExpertiseTagsAutocomplete extends Component {
|
||||
static template = "im_livechat.ExpertiseTagsAutocomplete";
|
||||
static props = ["channel", "disabled?"];
|
||||
static components = { TagsList, Many2XAutocomplete };
|
||||
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.orm = useService("orm");
|
||||
this.store = useService("mail.store");
|
||||
useTagNavigation("root", {
|
||||
delete: (index) => {
|
||||
const expertise = this.props.channel.livechat_expertise_ids[index];
|
||||
if (expertise) {
|
||||
this.writeExpertises([x2ManyCommands.unlink(expertise.id)]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {(ReturnType<typeof x2ManyCommands.link>|ReturnType<typeof x2ManyCommands.unlink>)[]} ormCommands */
|
||||
writeExpertises(ormCommands) {
|
||||
rpc("/im_livechat/conversation/write_expertises", {
|
||||
channel_id: this.props.channel.id,
|
||||
orm_commands: ormCommands,
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
createAndLinkExpertise(name) {
|
||||
if (
|
||||
this.props.channel.livechat_expertise_ids.some(
|
||||
(expertise) => expertise.name === name.trim()
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
rpc("/im_livechat/conversation/create_and_link_expertise", {
|
||||
channel_id: this.props.channel.id,
|
||||
expertise_name: name,
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {{id: number, display_name: string}} expertises */
|
||||
addExpertises(expertises) {
|
||||
const toAdd = expertises.filter((expertise) => !this.isSelected(expertise.id));
|
||||
if (!toAdd.length) {
|
||||
return;
|
||||
}
|
||||
this.writeExpertises(toAdd.map((expertise) => x2ManyCommands.link(expertise.id)));
|
||||
}
|
||||
|
||||
get placeholder() {
|
||||
if (this.props.channel.livechat_expertise_ids.length === 0) {
|
||||
return _t("Add expertise");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
get tags() {
|
||||
return this.props.channel.livechat_expertise_ids.map((expertise) => ({
|
||||
id: expertise.id,
|
||||
onDelete: () => this.writeExpertises([x2ManyCommands.unlink(expertise.id)]),
|
||||
text: expertise.name,
|
||||
}));
|
||||
}
|
||||
|
||||
isSelected(expertiseId) {
|
||||
return this.props.channel.livechat_expertise_ids.some((e) => e.id === expertiseId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="im_livechat.ExpertiseTagsAutocomplete">
|
||||
<div
|
||||
class="o-livechat-ExpertiseTagsAutocomplete d-inline-flex o_tags_input flex-wrap gap-1 mb-3"
|
||||
t-att-class="{'o_input': !props.disabled, 'o-disabled': props.disabled}"
|
||||
t-ref="root"
|
||||
>
|
||||
<TagsList displayText="true" tags="tags"/>
|
||||
<div t-if="!props.disabled" class="flex-grow-1 d-inline-flex w-100" style="flex-basis: 0;" >
|
||||
<Many2XAutocomplete
|
||||
placeholder="placeholder"
|
||||
resModel="'im_livechat.expertise'"
|
||||
activeActions="{'link': true}"
|
||||
isToMany="true"
|
||||
fieldString.translate="Expertise"
|
||||
update.bind="addExpertises"
|
||||
quickCreate="store.self_partner?.main_user_id?.is_livechat_manager ? (name) => this.createAndLinkExpertise(name) : null"
|
||||
getDomain="() => []"
|
||||
>
|
||||
<t t-set-slot="autoCompleteItem" t-slot-scope="autoCompleteItemScope">
|
||||
<span t-att-class="{ 'fw-bold': isSelected(autoCompleteItemScope.record.id) }">
|
||||
<span t-out="autoCompleteItemScope.label"/>
|
||||
</span>
|
||||
</t>
|
||||
</Many2XAutocomplete>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
class LivechatChannelCommand extends Component {
|
||||
static template = "im_livechat.LivechatChannelCommand";
|
||||
static props = {
|
||||
executeCommand: Function,
|
||||
iconClass: String,
|
||||
name: String,
|
||||
searchValue: String,
|
||||
slots: Object,
|
||||
};
|
||||
}
|
||||
|
||||
registry.category("command_provider").add("im_livechat.channel_join_leave", {
|
||||
/**
|
||||
* @param {import("@web/env").OdooEnv} env
|
||||
*/
|
||||
async provide(env) {
|
||||
const store = env.services["mail.store"];
|
||||
if (!store?.has_access_livechat) {
|
||||
return [];
|
||||
}
|
||||
await store.livechatChannels.fetch();
|
||||
const activeChannels = new Set(
|
||||
Object.values(store["im_livechat.channel"].records)
|
||||
.filter((c) => c.threads.length > 0)
|
||||
.map((c) => c.id)
|
||||
);
|
||||
// Show live chat channels with ongoing conversations first
|
||||
return Object.values(store["im_livechat.channel"].records)
|
||||
.sort((c) => (activeChannels.has(c.id) ? -1 : 1))
|
||||
.map((c) => ({
|
||||
action: c.are_you_inside ? c.leave.bind(c) : c.join.bind(c),
|
||||
Component: LivechatChannelCommand,
|
||||
name: c.are_you_inside ? c.leaveTitle : c.joinTitle,
|
||||
props: {
|
||||
iconClass: c.are_you_inside
|
||||
? "fa fa-sign-out text-danger"
|
||||
: "fa fa-sign-in text-success",
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.o-livechat-LivechatStatusLabel.o-help {
|
||||
--livechat-LivechatStatusLabel-helpColor: #{lighten($purple, 15%)};
|
||||
text-shadow: 0px 0px 3px black
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { TranscriptSender } from "@im_livechat/core/common/transcript_sender";
|
||||
import { ExpertiseTagsAutocomplete } from "@im_livechat/core/web/expertise_tags_autocomplete";
|
||||
import { ConversationTagEdit } from "@im_livechat/core/web/livechat_conversation_tag_edit";
|
||||
|
||||
import { ActionPanel } from "@mail/discuss/core/common/action_panel";
|
||||
import { prettifyMessageContent } from "@mail/utils/common/format";
|
||||
|
||||
import { Component, useEffect, useRef, useSubEnv } from "@odoo/owl";
|
||||
|
||||
import { startUrl } from "@web/core/browser/router";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { url } from "@web/core/utils/urls";
|
||||
import { TagsList } from "@web/core/tags_list/tags_list";
|
||||
|
||||
export class LivechatChannelInfoList extends Component {
|
||||
static components = { ActionPanel, TagsList, ExpertiseTagsAutocomplete, TranscriptSender };
|
||||
static template = "im_livechat.LivechatChannelInfoList";
|
||||
static props = ["thread"];
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
this.store = useService("mail.store");
|
||||
this.ui = useService("ui");
|
||||
this.tagEditPopover = usePopover(ConversationTagEdit, {
|
||||
closeOnClickAway: true,
|
||||
position: "left",
|
||||
useBottomSheet: this.ui.isSmall,
|
||||
});
|
||||
this.tagsContainer = useRef("tagsContainer");
|
||||
useSubEnv({ inLivechatInfoPanel: true });
|
||||
useEffect(
|
||||
() => {
|
||||
if (this.props.thread.hasFetchedLivechatSessionData) {
|
||||
return;
|
||||
}
|
||||
this.store.fetchStoreData("/im_livechat/session/data", {
|
||||
channel_id: this.props.thread.id,
|
||||
});
|
||||
this.props.thread.hasFetchedLivechatSessionData = true;
|
||||
},
|
||||
() => [this.props.thread.id, this.props.thread.hasFetchedLivechatSessionData]
|
||||
);
|
||||
}
|
||||
|
||||
get conversationTags() {
|
||||
return this.props.thread.livechat_conversation_tag_ids.map((tag) => ({
|
||||
id: tag.id,
|
||||
text: tag.name,
|
||||
colorIndex: tag.color,
|
||||
className: "me-1 mb-1",
|
||||
}));
|
||||
}
|
||||
|
||||
get expectAnswerSteps() {
|
||||
return this.props.thread.messages
|
||||
.filter((m) => m.chatbotStep?.expectAnswer && m.chatbotStep.answer)
|
||||
.map((m) => m.chatbotStep);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
get expertiseTags() {
|
||||
return this.props.thread.livechat_expertise_ids.map((expertise) => ({
|
||||
id: expertise.id,
|
||||
text: expertise.name,
|
||||
colorIndex: 0,
|
||||
className: "me-1 mb-1",
|
||||
}));
|
||||
}
|
||||
|
||||
onBlurNote() {
|
||||
prettifyMessageContent(this.props.thread.livechatNoteText).then((note) => {
|
||||
rpc("/im_livechat/session/update_note", { channel_id: this.props.thread.id, note });
|
||||
});
|
||||
}
|
||||
|
||||
onClickEditTags(ev) {
|
||||
this.tagEditPopover.open(this.tagsContainer.el, {
|
||||
thread: this.props.thread,
|
||||
});
|
||||
}
|
||||
|
||||
openVisitorProfile() {
|
||||
if (this.ui.isSmall) {
|
||||
this.store.ChatWindow.get({ thread: this.props.thread })?.fold();
|
||||
} else {
|
||||
this.props.thread.openChatWindow({ focus: true });
|
||||
}
|
||||
this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "res.partner",
|
||||
res_id: this.props.thread.livechatVisitorMember.partner_id.id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
get visitorProfileURL() {
|
||||
const visitorMember = this.props.thread?.livechatVisitorMember;
|
||||
if (visitorMember?.partner_id) {
|
||||
return url(`/${startUrl()}/res.partner/${visitorMember.partner_id.id}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
.o-livechat-LivechatStatusLabel.o-help {
|
||||
color: var(--livechat-LivechatStatusLabel-helpColor, #{$purple});
|
||||
}
|
||||
|
||||
.o-livechat-LivechatStatusSelection {
|
||||
--list-group-border-radius: var(--border-radius);
|
||||
|
||||
@keyframes shakeX {
|
||||
from, to {transform: translate3d(0, 0, 0);}
|
||||
20%, 60% {transform: translate3d(-.15em, 0, 0);}
|
||||
40%, 80% {transform: translate3d(.15em, 0, 0);}
|
||||
}
|
||||
|
||||
button:where(:not(.active)) .o-livechat-LivechatStatusLabel {
|
||||
visibility: hidden;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.o-livechat-LivechatStatusLabel {
|
||||
&.o-waiting {
|
||||
transform: rotate(180deg);
|
||||
transition: transform 1s;
|
||||
}
|
||||
&.o-help {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
button.active .o-livechat-LivechatStatusLabel {
|
||||
&.o-waiting {
|
||||
transform: none;
|
||||
}
|
||||
&.o-help {
|
||||
animation: shakeX .5s;
|
||||
}
|
||||
}
|
||||
|
||||
button.active.o-inProgress {
|
||||
--list-group-active-bg: #{rgba($success, .2)};
|
||||
--list-group-active-color: #{$success};
|
||||
--list-group-active-border-color: #{$success};
|
||||
|
||||
.o-livechat-LivechatStatusSelection-Label {
|
||||
color: var(--list-group-color);
|
||||
}
|
||||
&.o-livechat-LivechatStatusLabel-icon {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
.form-check-input {
|
||||
background-color: $success;
|
||||
border-color: $success;
|
||||
}
|
||||
}
|
||||
|
||||
button.active.o-need-help {
|
||||
--list-group-active-bg: #{rgba($purple, .1)};
|
||||
--list-group-active-color: var(--body-color);
|
||||
--list-group-active-border-color: var(--purple);
|
||||
|
||||
|
||||
&.o-livechat-LivechatStatusLabel-icon {
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
.form-check-input {
|
||||
background-color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
}
|
||||
}
|
||||
|
||||
button.active.o-waiting {
|
||||
--list-group-active-color: var(--body-color);
|
||||
--list-group-active-border-color: map-get($o-theme-text-colors, 'warning');
|
||||
|
||||
.form-check-input {
|
||||
background-color: map-get($o-theme-text-colors, 'warning');
|
||||
border-color: map-get($o-theme-text-colors, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown
|
||||
.o_popover & {
|
||||
--list-group-border-radius: 0;
|
||||
|
||||
.list-group-item {
|
||||
border-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o-livechat-LivechatStatusLabel.position-absolute {
|
||||
background-color: rgba($white, var(--bg-opacity, 1));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.o_country_flag {
|
||||
width:24px;
|
||||
height: 16px;
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="im_livechat.LivechatChannelInfoList">
|
||||
<ActionPanel title.translate="Information" minWidth="200" initialWidth="env.inMeetingView ? 400 : 300" icon="'fa fa-info'">
|
||||
<a t-if="visitorProfileURL" class="btn btn-primary mt-1" t-on-click.prevent="openVisitorProfile" t-att-href="visitorProfileURL" title="View Contact">
|
||||
View Contact
|
||||
</a>
|
||||
<div t-if="!props.thread.livechat_end_dt" class="o-livechat-LivechatChannelInfoList d-flex flex-column bg-inherit gap-1">
|
||||
<h6 class="pt-3 mb-1">Status</h6>
|
||||
<t t-call="im_livechat.LivechatStatusSelection">
|
||||
<t t-set="templateParams" t-value="{ livechatThread: props.thread }"/>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="props.thread.livechat_end_dt" class="o-livechat-LivechatChannelInfoList d-flex flex-column bg-inherit gap-1">
|
||||
<h6 class="pt-3 mb-1">Outcome</h6>
|
||||
<div class="d-flex text-truncate">
|
||||
<t t-if="props.thread.livechat_outcome === 'no_answer'">Never Answered</t>
|
||||
<t t-elif="props.thread.livechat_outcome === 'no_agent'">No one Available</t>
|
||||
<t t-elif="props.thread.livechat_outcome === 'no_failure'">Success</t>
|
||||
<t t-elif="props.thread.livechat_outcome === 'escalated'">Escalated</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-column bg-inherit gap-1">
|
||||
<h6 class="pt-3 mb-0">Notes</h6>
|
||||
<textarea class="form-control" t-att-disabled="!store.has_access_livechat" rows="3" placeholder="Add your notes here..." t-model="props.thread.livechatNoteText" t-on-blur="onBlurNote"></textarea>
|
||||
</div>
|
||||
<div class="d-flex flex-column bg-inherit gap-1" t-ref="tagsContainer">
|
||||
<h6 class="align-items-baseline pt-3 d-flex">
|
||||
<span class="pt-3 mb-1">Tags</span>
|
||||
<button t-if="store.has_access_livechat" class="btn btn-link text-dark opacity-75 opacity-100-hover p-0 ms-1" t-on-click="onClickEditTags"><i class="oi oi-plus"/></button>
|
||||
</h6>
|
||||
<div t-if="conversationTags.length" class="d-flex flex-wrap flex-grow-1 gap-1">
|
||||
<TagsList displayText="true" tags="conversationTags"/>
|
||||
<button t-if="this.store.has_access_livechat" class="btn btn-sm btn-link text-dark opacity-75 opacity-100 lh-1" t-on-click="onClickEditTags"><i class="fa fa-pencil"/></button>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="expectAnswerSteps.length" class="d-flex flex-column bg-inherit gap-1">
|
||||
<h6 class="pt-3 mb-0">Chatbot answers</h6>
|
||||
<t t-foreach="expectAnswerSteps" t-as="step" t-key="step.id">
|
||||
<div class="d-flex align-items-center gap-1 bg-inherit rounded-3">
|
||||
<i class="fa fa-comment-o me-1"/>
|
||||
<span class="text-truncate" t-esc="step.answer"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="d-flex flex-column bg-inherit gap-1">
|
||||
<h6 class="pt-3 mb-1">Expertise</h6>
|
||||
<ExpertiseTagsAutocomplete channel="props.thread" disabled="!store.has_access_livechat"/>
|
||||
</div>
|
||||
<div t-if="props.thread.correspondentCountry or props.thread.livechat_lang_id">
|
||||
<h6 class="pt-3">Country & Language</h6>
|
||||
<div class="d-flex bg-inherit align-items-center gap-1">
|
||||
<img t-if="props.thread.showCorrespondentCountry" class="o_country_flag" t-att-src="props.thread.correspondentCountry.flagUrl" t-att-title="props.thread.correspondentCountry.code or props.thread.correspondentCountry.name" aria-label="Country"/>
|
||||
<span t-if="props.thread.livechat_lang_id" title="Language" t-esc="props.thread.livechat_lang_id.name"/>
|
||||
</div>
|
||||
</div>
|
||||
<t t-name="extra_infos"/>
|
||||
<div t-if="props.thread.livechat_end_dt" class="d-flex flex-column bg-inherit gap-1">
|
||||
<h6 class="pt-3 mb-0">Send conversation</h6>
|
||||
<TranscriptSender thread="props.thread"/>
|
||||
<a class="btn btn-outline-secondary" title="Download a copy of this conversation" target="_blank" t-att-href="props.thread.transcriptUrl"><i class="pe-2 fa fa-download"/>Download</a>
|
||||
</div>
|
||||
</ActionPanel>
|
||||
</t>
|
||||
|
||||
<t t-name="im_livechat.LivechatChannelInfoList.info_links">
|
||||
<div t-if="templateParams.info_records.length" class="d-flex flex-column bg-inherit gap-1">
|
||||
<h6 class="pt-3" t-out="templateParams.title"/>
|
||||
<t t-foreach="templateParams.info_records" t-as="record" t-key="record.localId">
|
||||
<a
|
||||
class="btn btn-sm btn-secondary d-flex align-items-center justify-content-start gap-1 px-3 py-1 rounded-3"
|
||||
t-att-href="record.href"
|
||||
t-att-data-oe-id="record.id"
|
||||
t-att-data-oe-model="templateParams.model"
|
||||
contenteditable="false"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fa fa-external-link opacity-75"/>
|
||||
<span class="ms-1 fw-bold" t-esc="record.name"/>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { LivechatChannel } from "@im_livechat/core/common/livechat_channel_model";
|
||||
|
||||
import { useSequential } from "@mail/utils/common/hooks";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
const sequential = useSequential();
|
||||
|
||||
const livechatChannelPatch = {
|
||||
async join({ notify = true } = {}) {
|
||||
this.are_you_inside = true;
|
||||
if (notify) {
|
||||
this.store.env.services.notification.add(_t("You joined %s.", this.name), {
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
await sequential(() =>
|
||||
this.store.env.services.orm.call("im_livechat.channel", "action_join", [this.id])
|
||||
);
|
||||
},
|
||||
get joinTitle() {
|
||||
return _t("Join %s", this.name);
|
||||
},
|
||||
async leave({ notify = true } = {}) {
|
||||
this.are_you_inside = false;
|
||||
if (notify) {
|
||||
this.store.env.services.notification.add(_t("You left %s.", this.name), {
|
||||
type: "info",
|
||||
});
|
||||
}
|
||||
await sequential(() =>
|
||||
this.store.env.services.orm.call("im_livechat.channel", "action_quit", [this.id])
|
||||
);
|
||||
},
|
||||
get leaveTitle() {
|
||||
return _t("Leave %s", this.name);
|
||||
},
|
||||
};
|
||||
patch(LivechatChannel.prototype, livechatChannelPatch);
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import { Component, onWillStart, useEffect, useState, xml } from "@odoo/owl";
|
||||
|
||||
import { useAutofocus, useService } from "@web/core/utils/hooks";
|
||||
import { useSequential } from "@mail/utils/common/hooks";
|
||||
import { highlightText } from "@web/core/utils/html";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
import { escapeRegExp } from "@web/core/utils/strings";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { NavigableList } from "@mail/core/common/navigable_list";
|
||||
|
||||
export class ConversationTagEdit extends Component {
|
||||
static components = { NavigableList };
|
||||
static props = ["thread", "autofocus?", "close?"];
|
||||
static template = "im_livechat.ConversationTagEdit";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.store = useService("mail.store");
|
||||
this.inputRef = useAutofocus();
|
||||
this.sequential = useSequential();
|
||||
this.state = useState({
|
||||
selectableTags: [],
|
||||
searchStr: "",
|
||||
});
|
||||
this.debouncedFetchConversationTags = useDebounced(
|
||||
this.fetchConversationTags.bind(this),
|
||||
250
|
||||
);
|
||||
onWillStart(() => {
|
||||
this.fetchConversationTags();
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
this.debouncedFetchConversationTags();
|
||||
},
|
||||
() => [this.state.searchStr]
|
||||
);
|
||||
}
|
||||
|
||||
get allSelectableTagNames() {
|
||||
return this.state.selectableTags.map((tag) => tag.name);
|
||||
}
|
||||
|
||||
get allSelectedTagNames() {
|
||||
return this.props.thread.livechat_conversation_tag_ids.map((tag) => tag.name);
|
||||
}
|
||||
|
||||
get remainingSelectableTags() {
|
||||
return this.state.selectableTags.filter(
|
||||
(tag) => !tag.in(this.props.thread.livechat_conversation_tag_ids)
|
||||
);
|
||||
}
|
||||
|
||||
get navigableListProps() {
|
||||
return {
|
||||
onSelect: (ev, option) => {
|
||||
this.toggleSelectedTag(option.tag);
|
||||
this.state.searchStr = "";
|
||||
},
|
||||
optionTemplate: xml`<t t-out="option.label"/>`,
|
||||
options: this.remainingSelectableTags.map((tag) => ({
|
||||
tag,
|
||||
label: highlightText(this.state.searchStr.trim(), tag.name, "text-primary"),
|
||||
buttonClass: "btn",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async toggleSelectedTag(tag) {
|
||||
await rpc("/im_livechat/conversation/update_tags", {
|
||||
channel_id: this.props.thread.id,
|
||||
tag_ids: [tag.id],
|
||||
method: this.props.thread.livechat_conversation_tag_ids.includes(tag)
|
||||
? "DELETE"
|
||||
: "ADD",
|
||||
});
|
||||
}
|
||||
|
||||
async fetchConversationTags() {
|
||||
const results = await this.sequential(() =>
|
||||
this.orm.searchRead(
|
||||
"im_livechat.conversation.tag",
|
||||
[["name", "ilike", this.state.searchStr]],
|
||||
["id", "name"],
|
||||
{ limit: 15 }
|
||||
)
|
||||
);
|
||||
if (!results) {
|
||||
return;
|
||||
}
|
||||
const result = this.store["im_livechat.conversation.tag"].insert(results);
|
||||
this.state.selectableTags = [...result];
|
||||
}
|
||||
|
||||
onKeydownSearchInput(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
if (!this.state.searchStr.trim()) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this.onClickCreateToggle();
|
||||
}
|
||||
}
|
||||
|
||||
async onClickFooterSelectedTag(tag) {
|
||||
this.toggleSelectedTag(tag);
|
||||
}
|
||||
|
||||
async onClickCreateToggle() {
|
||||
const tagName = this.state.searchStr.trim();
|
||||
const existingSelectableTag = this.state.selectableTags.find((tag) => tag.name === tagName);
|
||||
if (this.props.thread.livechat_conversation_tag_ids.includes(existingSelectableTag)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
existingSelectableTag &&
|
||||
!this.props.thread.livechat_conversation_tag_ids.includes(existingSelectableTag)
|
||||
) {
|
||||
this.toggleSelectedTag(existingSelectableTag);
|
||||
this.state.searchStr = "";
|
||||
return;
|
||||
}
|
||||
const [tagId] = await this.orm.create("im_livechat.conversation.tag", [
|
||||
{ name: escapeRegExp(tagName) },
|
||||
]);
|
||||
const newTag = this.store["im_livechat.conversation.tag"].insert({
|
||||
id: tagId,
|
||||
name: tagName,
|
||||
});
|
||||
this.state.selectableTags = [newTag, ...this.state.selectableTags];
|
||||
this.toggleSelectedTag(newTag);
|
||||
this.state.searchStr = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
.o-livechat-ConversationTagEdit {
|
||||
min-height: 0;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.o-livechat-ConversationTagEdit .o-mail-NavigableList-item {
|
||||
&:nth-child(odd) {
|
||||
background-color: $gray-100 !important;;
|
||||
}
|
||||
&:hover {
|
||||
background-color: mix($gray-100, $gray-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o-livechat-ConversationTagEdit-selectedList {
|
||||
max-height: 100px;
|
||||
|
||||
button {
|
||||
background-color: mix($o-view-background-color, $o-action, 85%);
|
||||
|
||||
&:not(:hover) .oi-close {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
&:hover .oi-close {
|
||||
color: $danger;
|
||||
border-color: $danger !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="im_livechat.ConversationTagEdit">
|
||||
<div class="o-livechat-ConversationTagEdit rounded d-flex flex-column p-2 flex-grow-1 bg-inherit mx-auto">
|
||||
<div class="d-flex">
|
||||
<input class="o-livechat-ConversationTagEdit-search form-control lh-1 px-2 bg-view shadow-sm" t-model="state.searchStr" t-ref="autofocus" placeholder="Search or create new tags" t-on-keydown="onKeydownSearchInput"/>
|
||||
<button class="btn btn-primary ms-1" t-att-disabled="!state.searchStr.trim() || allSelectableTagNames.includes(state.searchStr)" t-on-click="onClickCreateToggle">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<ul class="d-flex flex-column mx-0 pt-1 pb-0 overflow-auto o-scrollbar-thin list-group border-0 flex-grow-1" t-att-class="{ 'align-items-start': state.selectableTags.length === 0 }">
|
||||
<div t-if="allSelectedTagNames.includes(state.searchStr)" class="smaller fst-italic text-muted mx-1 opacity-50">
|
||||
Tag has already been selected
|
||||
</div>
|
||||
<div t-elif="remainingSelectableTags.length === 0" class="smaller fst-italic text-muted mx-1 opacity-50">
|
||||
No tags found
|
||||
</div>
|
||||
<NavigableList closeOnSelect="false" t-props="navigableListProps"/>
|
||||
</ul>
|
||||
<div t-if="props.thread.livechat_conversation_tag_ids.length > 0" class="overflow-auto pt-2">
|
||||
<div class="o-livechat-ConversationTagEdit-selectedList d-flex flex-wrap overflow-auto o-scrollbar-thin">
|
||||
<t t-foreach="props.thread.livechat_conversation_tag_ids" t-as="selectedTag" t-key="selectedTag.id">
|
||||
<button class="btn btn-light fw-bolder smaller pe-1 o_tag" t-att-class="'o_tag_color_' + selectedTag.color" title="Unselect tag" t-on-click="() => this.onClickFooterSelectedTag(selectedTag)">
|
||||
<t t-esc="selectedTag.name"/><i class="oi oi-close border border-dark-subtle ms-1"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_livechat_emoji_rating {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.NotificationItem" t-inherit-mode="extension">
|
||||
<xpath expr="//*[hasclass('o-mail-NotificationItem-avatarContainer')]" position="inside">
|
||||
<t t-if="props.thread?.channel_type === 'livechat'" t-call="im_livechat.LivechatStatusLabelOfThread">
|
||||
<t t-set="templateParams" t-value="{ livechatThread: props.thread }"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { partnerCompareRegistry } from "@mail/core/common/partner_compare";
|
||||
|
||||
partnerCompareRegistry.add(
|
||||
"im_livechat.available",
|
||||
(p1, p2, { thread }) => {
|
||||
if (thread?.channel_type === "livechat" && p1.is_available !== p2.is_available) {
|
||||
return p1.is_available ? -1 : 1;
|
||||
}
|
||||
},
|
||||
{ sequence: 15 }
|
||||
);
|
||||
|
||||
partnerCompareRegistry.add(
|
||||
"im_livechat.invite-count",
|
||||
(p1, p2, { thread }) => {
|
||||
if (
|
||||
thread?.channel_type === "livechat" &&
|
||||
p1.invite_by_self_count !== p2.invite_by_self_count
|
||||
) {
|
||||
return p2.invite_by_self_count - p1.invite_by_self_count;
|
||||
}
|
||||
},
|
||||
{ sequence: 20 }
|
||||
);
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { Store } from "@mail/core/common/store_service";
|
||||
import { compareDatetime } from "@mail/utils/common/misc";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/** @type {import("models").Store} */
|
||||
const storePatch = {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.livechatChannels = this.makeCachedFetchData("im_livechat.channel");
|
||||
this.livechatSelfExpertises = this.makeCachedFetchData("/im_livechat/fetch_self_expertise");
|
||||
this.has_access_livechat = false;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
onStarted() {
|
||||
super.onStarted(...arguments);
|
||||
if (this.discuss.isActive && this.has_access_livechat) {
|
||||
this.livechatChannels.fetch();
|
||||
this.livechatSelfExpertises.fetch();
|
||||
}
|
||||
},
|
||||
/** @returns {boolean} Whether the livechat thread changed. */
|
||||
goToOldestUnreadLivechatThread() {
|
||||
const [oldestUnreadThread] = this.discuss.livechats
|
||||
.filter((thread) => thread.isUnread)
|
||||
.sort(
|
||||
(t1, t2) =>
|
||||
!t2.livechat_end_dt - !t1.livechat_end_dt ||
|
||||
compareDatetime(t1.lastInterestDt, t2.lastInterestDt) ||
|
||||
t1.id - t2.id
|
||||
);
|
||||
if (!oldestUnreadThread) {
|
||||
return false;
|
||||
}
|
||||
if (this.discuss.isActive) {
|
||||
oldestUnreadThread.setAsDiscussThread();
|
||||
return true;
|
||||
}
|
||||
this.store.chatHub.initPromise.then(() => {
|
||||
const chatWindow = this.ChatWindow.insert({ thread: oldestUnreadThread });
|
||||
chatWindow.open({ focus: true, jumpToNewMessage: true });
|
||||
});
|
||||
return true;
|
||||
},
|
||||
get livechatStatusButtons() {
|
||||
return [
|
||||
{
|
||||
label: _t("In progress"),
|
||||
status: "in_progress",
|
||||
icon: "fa fa-comments",
|
||||
},
|
||||
{
|
||||
label: _t("Waiting for customer"),
|
||||
status: "waiting",
|
||||
icon: "fa fa-hourglass-start",
|
||||
},
|
||||
{
|
||||
label: _t("Looking for help"),
|
||||
status: "need_help",
|
||||
icon: "fa fa-lg fa-exclamation-circle",
|
||||
},
|
||||
];
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
tabToThreadType(tab) {
|
||||
const threadTypes = super.tabToThreadType(tab);
|
||||
if (tab === "chat" && !this.env.services.ui.isSmall) {
|
||||
threadTypes.push("livechat");
|
||||
}
|
||||
if (tab === "livechat") {
|
||||
threadTypes.push("livechat");
|
||||
}
|
||||
return threadTypes;
|
||||
},
|
||||
};
|
||||
patch(Store.prototype, storePatch);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { SuggestionService } from "@mail/core/common/suggestion_service";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(SuggestionService.prototype, {
|
||||
/** @override */
|
||||
getSupportedDelimiters(thread, env) {
|
||||
const res = super.getSupportedDelimiters(...arguments);
|
||||
return thread.channel_type === "livechat"
|
||||
? res.filter((delimiter) => delimiter.at(0) !== "#")
|
||||
: res;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { registerThreadAction } from "@mail/core/common/thread_actions";
|
||||
|
||||
import { LIVECHAT_INFO_DEFAULT_OPEN_LS } from "@im_livechat/core/public_web/discuss_app_model_patch";
|
||||
import { LivechatChannelInfoList } from "@im_livechat/core/web/livechat_channel_info_list";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
registerThreadAction("livechat-info", {
|
||||
actionPanelComponent: LivechatChannelInfoList,
|
||||
condition: ({ owner, store, thread }) =>
|
||||
thread?.channel_type === "livechat" &&
|
||||
store.self_partner?.main_user_id?.share === false &&
|
||||
!owner.isDiscussSidebarChannelActions,
|
||||
panelOuterClass: "o-livechat-ChannelInfoList bg-inherit",
|
||||
icon: "fa fa-fw fa-info",
|
||||
name: _t("Information"),
|
||||
open: ({ store }) => {
|
||||
store.discuss.isLivechatInfoPanelOpenByDefault = true;
|
||||
localStorage.removeItem(LIVECHAT_INFO_DEFAULT_OPEN_LS);
|
||||
},
|
||||
close: ({ action, store }) => {
|
||||
if (action.condition) {
|
||||
store.discuss.isLivechatInfoPanelOpenByDefault = false;
|
||||
localStorage.setItem(LIVECHAT_INFO_DEFAULT_OPEN_LS, "false");
|
||||
}
|
||||
},
|
||||
sequence: 10,
|
||||
sequenceGroup: 7,
|
||||
toggle: true,
|
||||
});
|
||||
registerThreadAction("livechat-status", {
|
||||
actionPanelComponent: LivechatChannelInfoList,
|
||||
condition: ({ owner, store, thread }) =>
|
||||
thread?.channel_type === "livechat" &&
|
||||
store.has_access_livechat &&
|
||||
!thread.livechat_end_dt &&
|
||||
!owner.isDiscussContent,
|
||||
dropdown: true,
|
||||
dropdownMenuClass: "p-0",
|
||||
dropdownTemplate: "im_livechat.LivechatStatusSelection",
|
||||
dropdownTemplateParams: ({ thread }) => ({ livechatThread: thread }),
|
||||
panelOuterClass: "o-livechat-ChannelInfoList bg-inherit",
|
||||
icon: ({ store, thread }) => {
|
||||
const btn = store.livechatStatusButtons.find(
|
||||
(btn) => btn.status === thread.livechat_status
|
||||
);
|
||||
if (!btn) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
template: "im_livechat.LivechatStatusLabel",
|
||||
params: { btn, inThreadActions: true },
|
||||
};
|
||||
},
|
||||
name: ({ thread }) => thread.livechatStatusLabel,
|
||||
nameClass: "fst-italic small",
|
||||
sequence: ({ owner }) => (owner.isDiscussSidebarChannelActions ? 10 : 5),
|
||||
sequenceGroup: ({ owner }) => (owner.isDiscussSidebarChannelActions ? 5 : 7),
|
||||
toggle: true,
|
||||
});
|
||||
registerThreadAction("join-livechat-needing-help", {
|
||||
condition: ({ owner, thread }) =>
|
||||
thread?.livechat_status === "need_help" &&
|
||||
!thread?.self_member_id &&
|
||||
!owner.isDiscussSidebarChannelActions,
|
||||
icon: "fa fa-fw fa-sign-in",
|
||||
name: _t("Join Chat"),
|
||||
nameClass: "text-success",
|
||||
open: async ({ store, thread }) => {
|
||||
const hasJoined = await store.env.services.orm.call(
|
||||
"discuss.channel",
|
||||
"livechat_join_channel_needing_help",
|
||||
[[thread.id]]
|
||||
);
|
||||
if (!hasJoined && thread.isDisplayed) {
|
||||
store.env.services.notification.add(
|
||||
_t("Someone has already joined this conversation"),
|
||||
{ type: "warning" }
|
||||
);
|
||||
}
|
||||
},
|
||||
sequence: 5,
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { ThreadIcon } from "@mail/core/common/thread_icon";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(ThreadIcon.prototype, {
|
||||
get defaultChatIcon() {
|
||||
if (this.props.thread.channel_type === "livechat") {
|
||||
return { class: "fa fa-comments opacity-75", title: _t("Livechat") };
|
||||
}
|
||||
return super.defaultChatIcon;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Thread } from "@mail/core/common/thread_model";
|
||||
import { fields } from "@mail/model/misc";
|
||||
import { convertBrToLineBreak } from "@mail/utils/common/format";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(Thread.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.hasFetchedLivechatSessionData = false;
|
||||
this.livechat_note = fields.Html();
|
||||
/** @type {string|undefined} */
|
||||
this.livechatNoteText = fields.Attr(undefined, {
|
||||
compute() {
|
||||
if (this.livechat_note !== undefined) {
|
||||
return convertBrToLineBreak(this.livechat_note || "");
|
||||
}
|
||||
return this.livechatNoteText;
|
||||
},
|
||||
});
|
||||
/** @type {"no_answer"|"no_agent"|"no_failure"|"escalated"|undefined} */
|
||||
this.livechat_outcome = undefined;
|
||||
},
|
||||
get livechatStatusLabel() {
|
||||
if (this.livechat_end_dt) {
|
||||
return _t("Conversation has ended");
|
||||
}
|
||||
const status = this.livechat_status;
|
||||
if (status === "waiting") {
|
||||
return _t("Waiting for customer");
|
||||
} else if (status === "need_help") {
|
||||
return _t("Looking for help");
|
||||
}
|
||||
return _t("In progress");
|
||||
},
|
||||
/** @param {"in_progress"|"waiting"|"need_help"} status */
|
||||
updateLivechatStatus(status) {
|
||||
if (this.livechat_status === status) {
|
||||
return;
|
||||
}
|
||||
rpc("/im_livechat/session/update_status", { channel_id: this.id, livechat_status: status });
|
||||
},
|
||||
});
|
||||
23
odoo-bringout-oca-ocb-im_livechat/im_livechat/static/src/embed/common/@types/models.d.ts
vendored
Normal file
23
odoo-bringout-oca-ocb-im_livechat/im_livechat/static/src/embed/common/@types/models.d.ts
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
declare module "models" {
|
||||
export interface Message {
|
||||
disableChatbotAnswers: boolean;
|
||||
}
|
||||
export interface Store {
|
||||
activeLivechats: Thread[];
|
||||
guest_token: null;
|
||||
livechat_available: boolean;
|
||||
livechat_rule: LivechatChannelRule;
|
||||
}
|
||||
export interface Thread {
|
||||
_toggleChatbot: boolean;
|
||||
chatbot: Chatbot;
|
||||
chatbotTypingMessage: Message;
|
||||
hasWelcomeMessage: Readonly<boolean>;
|
||||
isLastMessageFromCustomer: Readonly<boolean>;
|
||||
livechat_operator_id: ResPartner;
|
||||
livechatWelcomeMessage: Message;
|
||||
readyToSwapDeferred: Deferred;
|
||||
requested_by_operator: boolean;
|
||||
storeAsActiveLivechats: Store;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { AttachmentUploadService } from "@mail/core/common/attachment_upload_service";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(AttachmentUploadService.prototype, {
|
||||
async upload(thread, composer, file, options) {
|
||||
if (thread.channel_type === "livechat" && thread.isTransient) {
|
||||
thread = await this.env.services["im_livechat.livechat"].persist(thread);
|
||||
if (!thread) {
|
||||
return;
|
||||
}
|
||||
thread.readyToSwapDeferred.resolve();
|
||||
composer = thread.composer;
|
||||
}
|
||||
return super.upload(thread, composer, file, options);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { expirableStorage } from "@im_livechat/core/common/expirable_storage";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class AutopopupService {
|
||||
static STORAGE_KEY = "im_livechat_auto_popup";
|
||||
|
||||
/**
|
||||
* @param {import("@web/env").OdooEnv} env
|
||||
* @param {{
|
||||
* "im_livechat.livechat": import("@im_livechat/embed/common/livechat_service").LivechatService,
|
||||
* "mail.store": import("@mail/core/common/store_service").Store,
|
||||
* ui: typeof import("@web/core/ui/ui_service").uiService.start,
|
||||
* }} services
|
||||
*/
|
||||
constructor(env, { "im_livechat.livechat": livechatService, "mail.store": storeService, ui }) {
|
||||
this.storeService = storeService;
|
||||
this.livechatService = livechatService;
|
||||
this.ui = ui;
|
||||
|
||||
storeService.isReady.then(() => {
|
||||
browser.setTimeout(async () => {
|
||||
await storeService.chatHub.initPromise;
|
||||
if (this.allowAutoPopup) {
|
||||
expirableStorage.setItem(AutopopupService.STORAGE_KEY, true);
|
||||
livechatService.open();
|
||||
}
|
||||
}, storeService.livechat_rule?.auto_popup_timer * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
get allowAutoPopup() {
|
||||
return Boolean(
|
||||
!expirableStorage.getItem(AutopopupService.STORAGE_KEY) &&
|
||||
!this.ui.isSmall &&
|
||||
this.storeService.livechat_rule?.action === "auto_popup" &&
|
||||
this.storeService.livechat_available &&
|
||||
this.storeService.activeLivechats.length === 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const autoPopupService = {
|
||||
dependencies: ["im_livechat.livechat", "mail.store", "ui"],
|
||||
|
||||
start(env, services) {
|
||||
return new AutopopupService(env, services);
|
||||
},
|
||||
};
|
||||
registry.category("services").add("im_livechat.autopopup", autoPopupService);
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { url } from "@web/core/utils/urls";
|
||||
|
||||
async function loadFont(name, url, targetDocument) {
|
||||
await targetDocument.fonts.ready;
|
||||
if ([...targetDocument.fonts].some(({ family }) => family === name)) {
|
||||
// Font already loaded.
|
||||
return;
|
||||
}
|
||||
const link = document.createElement("link");
|
||||
link.rel = "preload";
|
||||
link.as = "font";
|
||||
link.href = url;
|
||||
link.crossOrigin = "";
|
||||
const style = document.createElement("style");
|
||||
style.appendChild(
|
||||
document.createTextNode(`
|
||||
@font-face {
|
||||
font-family: ${name};
|
||||
src: url('${url}') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
`)
|
||||
);
|
||||
const loadPromise = new Promise((res, rej) => {
|
||||
link.addEventListener("load", res);
|
||||
link.addEventListener("error", rej);
|
||||
});
|
||||
targetDocument.head.appendChild(link);
|
||||
targetDocument.head.appendChild(style);
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
function loadStyle(target) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = url("/im_livechat/assets_embed.css");
|
||||
const stylesLoadedPromise = new Promise((res, rej) => {
|
||||
link.addEventListener("load", res);
|
||||
link.addEventListener("error", rej);
|
||||
});
|
||||
target.appendChild(link);
|
||||
return stylesLoadedPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} target
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function makeRoot(target) {
|
||||
const root = document.createElement("div");
|
||||
root.classList.add("o-livechat-root");
|
||||
root.setAttribute("id", `o-livechat-root-${luxon.DateTime.now().ts + Math.random()}`);
|
||||
root.style.zIndex = "calc(9e999)";
|
||||
root.style.position = "relative";
|
||||
root.style.display = "block";
|
||||
target.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
export async function loadAssets(styleTarget) {
|
||||
const document = styleTarget.ownerDocument;
|
||||
await Promise.all([
|
||||
loadStyle(styleTarget),
|
||||
loadFont("FontAwesome", url("/im_livechat/font-awesome"), document),
|
||||
loadFont("odoo_ui_icons", url("/im_livechat/odoo_ui_icons"), document),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the livechat container by loading the styles and
|
||||
* the fonts.
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
* @returns {ShadowRoot}
|
||||
*/
|
||||
export async function makeShadow(root) {
|
||||
const shadow = root.attachShadow({ mode: "open" });
|
||||
await loadAssets(shadow);
|
||||
return shadow;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { LivechatButton } from "@im_livechat/embed/common/livechat_button";
|
||||
import { ChatHub } from "@mail/core/common/chat_hub";
|
||||
import { useExternalListener } from "@odoo/owl";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
ChatHub.components = { ...ChatHub.components, LivechatButton };
|
||||
|
||||
patch(ChatHub.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
useExternalListener(document, "scroll", this._onScroll);
|
||||
},
|
||||
_onScroll(ev) {
|
||||
if (this.position.dragged) {
|
||||
return;
|
||||
}
|
||||
const container = document.querySelector("html");
|
||||
this.position.bottom =
|
||||
container.scrollHeight - container.scrollTop === container.clientHeight
|
||||
? `${this.chatHub.BUBBLE_OUTER * 5}px`
|
||||
: `${this.chatHub.BUBBLE_OUTER}px`;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ChatHub" t-inherit-mode="extension">
|
||||
<xpath expr="//*[hasclass('o-mail-ChatHub')]" position="attributes">
|
||||
<attribute name="part">ChatHub</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//*[hasclass('o-mail-ChatHub-extraActions')]" position="inside">
|
||||
<LivechatButton/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { patch } from "@web/core/utils/patch";
|
||||
import { ChatWindow } from "@mail/core/common/chat_window_model";
|
||||
import { CW_LIVECHAT_STEP } from "@im_livechat/core/common/chat_window_model_patch";
|
||||
|
||||
patch(ChatWindow.prototype, {
|
||||
close() {
|
||||
super.close(...arguments);
|
||||
if (this.livechatStep === CW_LIVECHAT_STEP.FEEDBACK) {
|
||||
this.store.env.services["im_livechat.livechat"].leave(this.thread);
|
||||
this.thread.chatbot?.stop();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { CW_LIVECHAT_STEP } from "@im_livechat/core/common/chat_window_model_patch";
|
||||
import { FeedbackPanel } from "@im_livechat/embed/common/feedback_panel/feedback_panel";
|
||||
|
||||
import { ChatWindow } from "@mail/core/common/chat_window";
|
||||
import { useState } from "@odoo/owl";
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
Object.assign(ChatWindow.components, { FeedbackPanel });
|
||||
|
||||
patch(ChatWindow.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.livechatService = useService("im_livechat.livechat");
|
||||
this.livechatState = useState({ showCloseConfirmation: false });
|
||||
},
|
||||
async onClickNewSession() {
|
||||
await this.close();
|
||||
await this.livechatService.open();
|
||||
},
|
||||
onClickFeedback() {
|
||||
this.props.chatWindow.livechatStep = CW_LIVECHAT_STEP.CONFIRM_CLOSE; // Skip the confirmation step.
|
||||
this.close();
|
||||
},
|
||||
get showGiveFeedbackBtn() {
|
||||
const thread = this.props.chatWindow.thread;
|
||||
if (thread?.channel_type !== "livechat") {
|
||||
return false;
|
||||
}
|
||||
return thread.chatbot?.completed || thread.livechat_end_dt;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
.o-mail-ChatWindow-moreActions {
|
||||
&:hover, &.o-active, &.o-hover {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o-mail-ChatWindow-header .o-mail-ActionList {
|
||||
button {
|
||||
--btn-color: var(--o-mail-livechat-btn-color);
|
||||
--btn-hover-color: var(--o-mail-livechat-btn-color);
|
||||
--btn-active-color: var(--o-mail-livechat-btn-color);
|
||||
|
||||
&:not(:hover, &:focus-visible, &:active) {
|
||||
--o-mail-ActionList-Button-opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.ChatWindow" t-inherit-mode="extension">
|
||||
<xpath expr="//*[@name='thread content']" position="replace">
|
||||
<FeedbackPanel t-if="props.chatWindow.livechatStep === CW_LIVECHAT_STEP.FEEDBACK" onClickClose="() => this.close()" onClickNewSession="() => this.onClickNewSession()" thread="thread"/>
|
||||
<t t-else="">
|
||||
<t>$0</t>
|
||||
</t>
|
||||
</xpath>
|
||||
<xpath expr="//*[@t-ref='needactionCounter']" position="replace">
|
||||
<t t-if="!thread?.chatbot">$0</t>
|
||||
</xpath>
|
||||
<xpath expr="//*[hasclass('o-mail-ChatWindow-header')]" position="attributes">
|
||||
<attribute name="t-attf-style" add="color: {{ livechatService.options.title_color }}; --o-mail-livechat-btn-color: {{ livechatService.options.title_color }}; background-color: {{ livechatService.options.header_background_color }} !important;" separator=" "/>
|
||||
</xpath>
|
||||
<xpath expr="//*[@t-ref='composerDisabledContainer']" position="inside">
|
||||
<button t-if="showGiveFeedbackBtn" class="btn btn-link p-0" title="Give your feedback" t-on-click="() => this.onClickFeedback()">Continue</button>
|
||||
</xpath>
|
||||
<xpath expr="//*[@t-ref='composerDisabledContainer']" position="attributes">
|
||||
<attribute name="t-attf-class" add="{{ showGiveFeedbackBtn ? 'px-2' : '' }}" separator=" "/>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.o-mail-Composer-input, .o-mail-Composer-fake {
|
||||
&.o-mobile {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Composer } from "@mail/core/common/composer";
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(Composer.prototype, {
|
||||
get placeholder() {
|
||||
if (this.thread?.channel_type !== "livechat") {
|
||||
return super.placeholder;
|
||||
}
|
||||
return _t("Say something...");
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Thread } from "@mail/core/common/thread_model";
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(Thread.prototype, {
|
||||
get hasMemberList() {
|
||||
return false;
|
||||
},
|
||||
get hasAttachmentPanel() {
|
||||
return this.channel_type !== "livechat" && super.hasAttachmentPanel;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o-EmojiPicker {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue