[ADD] im_livechat,mail: canned responses support
Use the mention mechanism of the composer to autocomplete on canned responses (keyword :) with fuzzy search. Directly perform the string replacement in the composer, instead of server-side as before. Add a menu item to access an editable list view to create and edit canned responses. Add two canned responses as demo data.
This commit is contained in:
@@ -30,6 +30,7 @@ Help your customers with this chat, and analyse their feedback.
|
||||
],
|
||||
'demo': [
|
||||
"data/im_livechat_channel_demo.xml",
|
||||
'data/mail_shortcode_demo.xml',
|
||||
],
|
||||
'depends': ["mail", "rating"],
|
||||
'qweb': [
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<openerp>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="mail_canned_response_hello" model="mail.shortcode">
|
||||
<field name="source">hello</field>
|
||||
<field name="substitution">Hello. How may I help you?</field>
|
||||
<field name="shortcode_type">text</field>
|
||||
</record>
|
||||
|
||||
<record id="mail_canned_response_bye" model="mail.shortcode">
|
||||
<field name="source">bye</field>
|
||||
<field name="substitution">Thanks for your feedback. Good bye!</field>
|
||||
<field name="shortcode_type">text</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</openerp>
|
||||
@@ -174,6 +174,35 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Canned responses -->
|
||||
<record id="im_livechat_canned_response_view_tree" model="ir.ui.view">
|
||||
<field name="name">im_livechat.canned_response.tree</field>
|
||||
<field name="model">mail.shortcode</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="bottom">
|
||||
<field name="source"/>
|
||||
<field name="substitution"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="im_livechat_canned_response_action" model="ir.actions.act_window">
|
||||
<field name="name">Canned Responses</field>
|
||||
<field name="res_model">mail.shortcode</field>
|
||||
<field name="view_mode">tree</field>
|
||||
<field name="view_id" ref="im_livechat_canned_response_view_tree"/>
|
||||
<field name="domain">[('shortcode_type','=','text')]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="oe_view_nocontent_create">
|
||||
Click to create a new canned response.
|
||||
</p><p>
|
||||
Canned responses allow you to insert prewritten responses in
|
||||
your messages by typing <i>:shortcut</i>. The shortcut is
|
||||
replaced directly in your message, so that you can still edit
|
||||
it before sending.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu items -->
|
||||
<menuitem
|
||||
@@ -199,5 +228,13 @@
|
||||
groups="im_livechat_group_user"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem
|
||||
id="canned_responses"
|
||||
name="Canned Responses"
|
||||
parent="menu_livechat_root"
|
||||
action="im_livechat_canned_response_action"
|
||||
groups="im_livechat_group_user"
|
||||
sequence="15"/>
|
||||
|
||||
</data>
|
||||
</openerp>
|
||||
|
||||
@@ -243,7 +243,7 @@ class MailController(http.Controller):
|
||||
'starred_counter': request.env['res.partner'].get_starred_count(),
|
||||
'channel_slots': request.env['mail.channel'].channel_fetch_slot(),
|
||||
'mention_partner_suggestions': request.env['res.partner'].get_static_mention_suggestions(),
|
||||
'emoji': request.env['mail.shortcode'].sudo().search_read([('shortcode_type', '=', 'image')], ['source', 'substitution', 'description']),
|
||||
'shortcodes': request.env['mail.shortcode'].sudo().search_read([], ['shortcode_type', 'source', 'substitution', 'description']),
|
||||
'menu_id': request.env['ir.model.data'].xmlid_to_res_id('mail.mail_channel_menu_root_chat'),
|
||||
}
|
||||
return values
|
||||
|
||||
@@ -213,8 +213,6 @@ class Channel(models.Model):
|
||||
def message_post(self, body='', subject=None, message_type='notification', subtype=None, parent_id=False, attachments=None, content_subtype='html', **kwargs):
|
||||
# auto pin 'direct_message' channel partner
|
||||
self.filtered(lambda channel: channel.channel_type == 'chat').mapped('channel_last_seen_partner_ids').write({'is_pinned': True})
|
||||
# apply shortcode (text only) subsitution
|
||||
body = self.env['mail.shortcode'].apply_shortcode(body, shortcode_type='text')
|
||||
message = super(Channel, self.with_context(mail_create_nosubscribe=True)).message_post(body=body, subject=subject, message_type=message_type, subtype=subtype, parent_id=parent_id, attachments=attachments, content_subtype=content_subtype, **kwargs)
|
||||
return message
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ class MailShortcode(models.Model):
|
||||
_name = 'mail.shortcode'
|
||||
_description = 'Canned Response / Shortcode'
|
||||
|
||||
source = fields.Char('Shortcut', required=True, index=True, help="The shortcut which must be replace in the Chat Messages")
|
||||
substitution = fields.Char('Substitution', required=True, index=True, help="The escaped html code replacing the shortcut")
|
||||
source = fields.Char('Shortcut', required=True, index=True, help="The shortcut which must be replaced in the Chat Messages")
|
||||
substitution = fields.Text('Substitution', required=True, index=True, help="The escaped html code replacing the shortcut")
|
||||
description = fields.Char('Description')
|
||||
shortcode_type = fields.Selection([('image', 'Smiley'), ('text', 'Canned Response')], required=True, default='text',
|
||||
help="* Smiley are only used for HTML code to display an image "\
|
||||
"* Text (default value) is use to substitute text with another text")
|
||||
"* Text (default value) is used to substitute text with another text")
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
@@ -43,17 +43,3 @@ class MailShortcode(models.Model):
|
||||
if is_img_tag:
|
||||
return substitution
|
||||
return cgi.escape(substitution)
|
||||
|
||||
@api.model
|
||||
def apply_shortcode(self, message, shortcode_type=None):
|
||||
""" Apply the substitution on the given text
|
||||
:param message : the text where the shortcode will be applied
|
||||
:param shortcode_type : shortcode type that should be applied during the substitution
|
||||
"""
|
||||
domain = []
|
||||
if shortcode_type:
|
||||
domain.append(('shortcode_type', '=', shortcode_type))
|
||||
for shortcode in self.search(domain):
|
||||
regex = '(?:^|\s)(%s)(?:\s|$)' % re.escape(shortcode.source)
|
||||
message = re.sub(regex, " " + shortcode.substitution + " ", message)
|
||||
return message
|
||||
|
||||
@@ -34,6 +34,7 @@ var emoji_substitutions = {};
|
||||
var needaction_counter = 0;
|
||||
var starred_counter = 0;
|
||||
var mention_partner_suggestions = [];
|
||||
var canned_responses = [];
|
||||
var discuss_menu_id;
|
||||
var global_unread_counter = 0;
|
||||
var pinned_dm_partners = []; // partner_ids we have a pinned DM with
|
||||
@@ -757,6 +758,10 @@ var chat_manager = {
|
||||
return channel.members_deferred;
|
||||
},
|
||||
|
||||
get_canned_responses: function () {
|
||||
return canned_responses;
|
||||
},
|
||||
|
||||
get_emojis: function() {
|
||||
return emojis;
|
||||
},
|
||||
@@ -988,12 +993,18 @@ function init () {
|
||||
needaction_counter = result.needaction_inbox_counter;
|
||||
starred_counter = result.starred_counter;
|
||||
mention_partner_suggestions = result.mention_partner_suggestions;
|
||||
emojis = result.emoji;
|
||||
_.each(emojis, function(emoji) {
|
||||
emoji_substitutions[_.escape(emoji.source)] = emoji.substitution;
|
||||
});
|
||||
discuss_menu_id = result.menu_id;
|
||||
|
||||
// Shortcodes: canned responses and emojis
|
||||
_.each(result.shortcodes, function (s) {
|
||||
if (s.shortcode_type === 'text') {
|
||||
canned_responses.push(_.pick(s, ['id', 'source', 'substitution']));
|
||||
} else {
|
||||
emojis.push(_.pick(s, ['id', 'source', 'substitution', 'description']));
|
||||
emoji_substitutions[_.escape(s.source)] = s.substitution;
|
||||
}
|
||||
});
|
||||
|
||||
bus.start_polling();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ var NON_BREAKING_SPACE = '\u00a0';
|
||||
|
||||
var MENTION_PARTNER_DELIMITER = '@';
|
||||
var MENTION_CHANNEL_DELIMITER = '#';
|
||||
var MENTION_CANNED_RESPONSE_DELIMITER = ':';
|
||||
|
||||
// The MentionManager allows the Composer to register listeners. For each
|
||||
// listener, it detects if the user is currently typing a mention (starting by a
|
||||
@@ -59,8 +60,12 @@ var MentionManager = Widget.extend({
|
||||
var selected_suggestion = _.find(_.flatten(this.get('mention_suggestions')), function (s) {
|
||||
return s.id === id;
|
||||
});
|
||||
// replace white spaces with non-breaking spaces to facilitate mentions detection in text
|
||||
selected_suggestion.name = selected_suggestion.name.replace(/ /g, NON_BREAKING_SPACE);
|
||||
var substitution = selected_suggestion.substitution;
|
||||
if (!substitution) { // no substitution string given, so use the mention name instead
|
||||
// replace white spaces with non-breaking spaces to facilitate mentions detection in text
|
||||
selected_suggestion.name = selected_suggestion.name.replace(/ /g, NON_BREAKING_SPACE);
|
||||
substitution = this.active_listener.delimiter + selected_suggestion.name;
|
||||
}
|
||||
var get_mention_index = function (matches, cursor_position) {
|
||||
for (var i=0; i<matches.length; i++) {
|
||||
if (cursor_position <= matches[i].index) {
|
||||
@@ -84,10 +89,11 @@ var MentionManager = Widget.extend({
|
||||
var cursor_position = this._get_selection_positions().start;
|
||||
var text_left = text_input.substring(0, cursor_position-(this.mention_word.length+1));
|
||||
var text_right = text_input.substring(cursor_position, text_input.length);
|
||||
var text_input_new = text_left + this.active_listener.delimiter + selected_suggestion.name + ' ' + text_right;
|
||||
var text_input_new = text_left + substitution + ' ' + text_right;
|
||||
this.composer.$input.val(text_input_new);
|
||||
this._set_cursor_position(text_left.length+selected_suggestion.name.length+2);
|
||||
this._set_cursor_position(text_left.length+substitution.length+2);
|
||||
this.set('mention_suggestions', []);
|
||||
this.composer.focus(); // to trigger autoresize
|
||||
},
|
||||
|
||||
// Public API
|
||||
@@ -95,11 +101,16 @@ var MentionManager = Widget.extend({
|
||||
* Registers a new listener, described by an object containing the following keys
|
||||
* @param {char} [delimiter] the mention delimiter
|
||||
* @param {function} [fetch_callback] the callback to fetch mention suggestions
|
||||
* @param {string} [model] the model used for redirection
|
||||
* @param {string} [redirect_classname] the classname of the <a> wrapping the mention
|
||||
* @param {boolean} [generate_links] true to wrap mentions in <a> links
|
||||
* @param {string} [model] (optional) the model used for redirection
|
||||
* @param {string} [redirect_classname] (optional) the classname of the <a> wrapping the mention
|
||||
* @param {array} [selection] (optional) initial mentions for each listener
|
||||
* @param {string} [suggestion_template] the template of suggestions' dropdown
|
||||
*/
|
||||
register: function (listener) {
|
||||
this.listeners.push(_.defaults(listener, {
|
||||
model: '',
|
||||
redirect_classname: '',
|
||||
selection: [],
|
||||
}));
|
||||
},
|
||||
@@ -202,6 +213,9 @@ var MentionManager = Widget.extend({
|
||||
var base_href = session.url("/web");
|
||||
var mention_link = "<a href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'>%s%s</a>";
|
||||
_.each(this.listeners, function (listener) {
|
||||
if (!listener.generate_links) {
|
||||
return;
|
||||
}
|
||||
var selection = listener.selection;
|
||||
if (selection.length) {
|
||||
var matches = self._get_match(s, listener);
|
||||
@@ -266,12 +280,29 @@ var MentionManager = Widget.extend({
|
||||
return result;
|
||||
},
|
||||
_render_suggestions: function () {
|
||||
if (_.flatten(this.get('mention_suggestions')).length) {
|
||||
this.$el.html(QWeb.render('mail.ChatComposer.MentionSuggestions', {
|
||||
suggestions: this.get('mention_suggestions'),
|
||||
var suggestions = [];
|
||||
if (_.find(this.get('mention_suggestions'), _.isArray)) {
|
||||
// Array of arrays -> Flatten and insert dividers between groups
|
||||
var insert_divider = false;
|
||||
_.each(this.get('mention_suggestions'), function (suggestion_group) {
|
||||
if (suggestion_group.length > 0) {
|
||||
if (insert_divider) {
|
||||
suggestions.push({ divider: true });
|
||||
}
|
||||
suggestions = suggestions.concat(suggestion_group);
|
||||
insert_divider = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
suggestions = this.get('mention_suggestions');
|
||||
}
|
||||
if (suggestions.length) {
|
||||
this.$el.html(QWeb.render(this.active_listener.suggestion_template, {
|
||||
suggestions: suggestions,
|
||||
}));
|
||||
this.$el
|
||||
.addClass('open')
|
||||
.find('ul').css("max-width", this.composer.$input.width())
|
||||
.find('.o_mention_proposition').first().addClass('active');
|
||||
this.open = true;
|
||||
} else {
|
||||
@@ -346,16 +377,26 @@ var BasicComposer = Widget.extend({
|
||||
this.mention_manager.register({
|
||||
delimiter: MENTION_PARTNER_DELIMITER,
|
||||
fetch_callback: this.mention_fetch_partners.bind(this),
|
||||
generate_links: true,
|
||||
model: 'res.partner',
|
||||
redirect_classname: 'o_mail_redirect',
|
||||
selection: this.options.default_mention_selections[MENTION_PARTNER_DELIMITER],
|
||||
suggestion_template: 'mail.MentionPartnerSuggestions',
|
||||
});
|
||||
this.mention_manager.register({
|
||||
delimiter: MENTION_CHANNEL_DELIMITER,
|
||||
fetch_callback: this.mention_fetch_channels.bind(this),
|
||||
generate_links: true,
|
||||
model: 'mail.channel',
|
||||
redirect_classname: 'o_channel_redirect',
|
||||
selection: this.options.default_mention_selections[MENTION_CHANNEL_DELIMITER],
|
||||
suggestion_template: 'mail.MentionChannelSuggestions',
|
||||
});
|
||||
this.mention_manager.register({
|
||||
delimiter: MENTION_CANNED_RESPONSE_DELIMITER,
|
||||
fetch_callback: this.mention_get_canned_responses.bind(this),
|
||||
selection: this.options.default_mention_selections[MENTION_CANNED_RESPONSE_DELIMITER],
|
||||
suggestion_template: 'mail.MentionCannedResponseSuggestions',
|
||||
});
|
||||
|
||||
// Emojis
|
||||
@@ -621,8 +662,8 @@ var BasicComposer = Widget.extend({
|
||||
_.each(prefetched_partners, function (partners) {
|
||||
if (limit > 0) {
|
||||
var filtered_partners = _.filter(partners, function (partner) {
|
||||
return partner.email && partner.email.search(search_regexp) !== -1 ||
|
||||
partner.name && utils.unaccent(partner.name).search(search_regexp) !== -1;
|
||||
return partner.email && search_regexp.test(partner.email) ||
|
||||
partner.name && search_regexp.test(utils.unaccent(partner.name));
|
||||
});
|
||||
if (filtered_partners.length) {
|
||||
suggestions.push(filtered_partners.slice(0, limit));
|
||||
@@ -640,6 +681,14 @@ var BasicComposer = Widget.extend({
|
||||
return suggestions;
|
||||
});
|
||||
},
|
||||
mention_get_canned_responses: function (search) {
|
||||
var canned_responses = chat_manager.get_canned_responses();
|
||||
var matches = fuzzy.filter(utils.unaccent(search), _.pluck(canned_responses, 'source'));
|
||||
var indexes = _.pluck(matches.slice(0, this.options.mention_fetch_limit), 'index');
|
||||
return _.map(indexes, function (i) {
|
||||
return canned_responses[i];
|
||||
});
|
||||
},
|
||||
mention_set_prefetched_partners: function (prefetched_partners) {
|
||||
this.mention_prefetched_partners = prefetched_partners;
|
||||
},
|
||||
|
||||
@@ -14,6 +14,18 @@
|
||||
width: 100%;
|
||||
resize: none;
|
||||
}
|
||||
.o_composer_mention_dropdown .o_mention_proposition > a {
|
||||
.o-text-overflow();
|
||||
width: 100%;
|
||||
padding: 3px 15px;
|
||||
.o_mention_name {
|
||||
font-weight: 500;
|
||||
}
|
||||
.o_mention_info {
|
||||
font-style: italic;
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_composer_attachments_list {
|
||||
|
||||
@@ -46,21 +46,34 @@
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="mail.ChatComposer.MentionSuggestions">
|
||||
<t t-name="mail.AbstractMentionSuggestions">
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMailMentionMenu">
|
||||
<t t-set="show_divider" t-value="false"/>
|
||||
<t t-foreach="suggestions" t-as="group">
|
||||
<t t-foreach="group" t-as="suggestion">
|
||||
<li t-if="show_divider && (suggestion_index === 0)" class="divider"/>
|
||||
<t t-set="show_divider" t-value="true"/>
|
||||
<li class="o_mention_proposition" t-att-data-id="suggestion.id">
|
||||
<a href="#">
|
||||
<t t-esc="suggestion.name"/> <t t-if="suggestion.email">(<t t-esc="suggestion.email"/>)</t>
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
<t t-foreach="suggestions" t-as="suggestion">
|
||||
<li t-if="suggestion.divider" class="divider"/>
|
||||
<li t-if="!suggestion.divider" class="o_mention_proposition" t-att-data-id="suggestion.id">
|
||||
<a href="#"/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-name="mail.MentionPartnerSuggestions" t-extend="mail.AbstractMentionSuggestions">
|
||||
<t t-jquery=".o_mention_proposition a" t-operation="append">
|
||||
<span class="o_mention_name"><t t-esc="suggestion.name"/></span>
|
||||
<t t-if="suggestion.email">
|
||||
<span class="o_mention_info">(<t t-esc="suggestion.email"/>)</span>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="mail.MentionChannelSuggestions" t-extend="mail.AbstractMentionSuggestions">
|
||||
<t t-jquery=".o_mention_proposition a" t-operation="append">
|
||||
<span class="o_mention_name"><t t-esc="suggestion.name"/></span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="mail.MentionCannedResponseSuggestions" t-extend="mail.AbstractMentionSuggestions">
|
||||
<t t-jquery=".o_mention_proposition a" t-operation="append">
|
||||
<span class="o_mention_name"><t t-esc="suggestion.source"/></span>
|
||||
<span class="o_mention_info"><t t-esc="suggestion.substitution"/></span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
Reference in New Issue
Block a user