[IMP] mail : add '@' mention feature

When composing a message (on client action, or chatter) if the user type '@', he will be able to tag partner, like facebook do. The mentionned partner are recipient of the message, even if there are not follower of the document. This message is a needaction for them.
This commit is contained in:
Jérome Maes
2015-09-01 20:16:09 +02:00
committed by Aaron Bohy
parent 449f254638
commit d3279983c8
3 changed files with 359 additions and 12 deletions
+327 -11
View File
@@ -3,6 +3,7 @@ odoo.define('mail.thread', function (require) {
var core = require('web.core');
var data = require('web.data');
var Model = require('web.Model');
var time = require('web.time');
var Widget = require('web.Widget');
var session = require('web.session');
@@ -28,10 +29,13 @@ var MailComposeMessage = Widget.extend({
template: 'mail.ComposeMessage',
events: {
"keydown .o_mail_compose_message_input": "on_keydown",
"keyup .o_mail_compose_message_input": "on_keyup",
"change input.oe_form_binary_file": "on_attachment_change",
"click .o_mail_compose_message_attachment_list .o_mail_attachment_delete": "on_attachment_delete",
"click .o_mail_compose_message_button_attachment": 'on_click_attachment',
"click .o_mail_compose_message_button_send": 'message_send',
"click .o_mail_mention_proposition": "on_click_mention_item",
"mouseover .o_mail_mention_proposition": "on_hover_mention_proposition",
},
/**
* Contructor
@@ -39,6 +43,11 @@ var MailComposeMessage = Widget.extend({
* @param {Object[]} [options.emoji_list] : the list of emoji
* @param {Object} options.context : the context. It should contains the default_res_id' (id of the current document).
* @param {chat|mail} options.display_mode : the 'chat' mode will display an input, when the 'mail' mode will display a texarea.
* @param {Char} [options.mention_delimiter] : the delimiter char to distinguish the beginning of a mention
* @param {Integer} [options.mention_min_length] : min length of the mention word required to start a search on partner
* @param {Integer} [options.mention_typing_speed] : delay before starting a search with the mention word
* @param {Integer} [options.mention_fetch_limit] : limit of partner fetch
* @param {top|bottom} [options.mention_menu_orientation] : orientation of the dropdown menu regarding the input text
*/
init: function(parent, dataset, options){
this._super.apply(this, arguments);
@@ -47,13 +56,24 @@ var MailComposeMessage = Widget.extend({
'emoji_list': {},
'context': {},
'display_mode': 'mail',
'mention_delimiter': '@',
'mention_min_length': 2,
'mention_typing_speed': 400,
'mention_fetch_limit': 8,
'mention_menu_orientation': 'top',
});
this.emoji_list = this.options.emoji_list;
this.context = this.options.context;
this.input_buffer = ''; // save the value at keydown
// attachment handeling
this.AttachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context);
this.fileupload_id = _.uniqueId('o_mail_chat_fileupload');
this.set('attachment_ids', []);
// mention
this.PartnerModel = new Model('res.partner');
this.set('mention_word', false); // word typed after the delimiter
this.set('mention_partners', []); // proposition of not-mention partner matching the mention_word
this.set('mention_selected_partners', []); // contains the mention partners sorted as they appear in the input text
},
start: function(){
var self = this;
@@ -77,6 +97,10 @@ var MailComposeMessage = Widget.extend({
container: '.o_mail_compose_message_emoji',
trigger: 'focus',
});
// mention
this.on('change:mention_word', this, this.mention_word_change);
this.on('change:mention_partners', this, this.mention_render_partner);
this.on('change:mention_selected_partners', this, this.mention_render_selected_partners);
return this._super();
},
// events
@@ -89,19 +113,61 @@ var MailComposeMessage = Widget.extend({
this.$input.focus();
},
on_keydown: function(event){
if(event.which === 13 && this.options.display_mode === 'chat') {
this.message_send();
// Save the old input
this.input_buffer = this.$input.val();
// Key Down displatching
switch(event.which) {
// BACKSPACE : check if need to remove a mention
case $.ui.keyCode.BACKSPACE:
this.mention_check_remove(event.which);
break;
// ENTER : submit the message only if the dropdown mention proposition is not displayed
case $.ui.keyCode.ENTER:
if(!this.get('mention_partners').length && this.options.display_mode === 'chat'){
this.message_send();
}
break;
}
},
message_send: function(){
var $input = this.$('.o_mail_compose_message_input');
var mes = mail_utils.get_text2html($input.val());
var mes = mail_utils.get_text2html(this.$input.val());
if (! mes.trim() && this.do_check_attachment_upload()) {
return;
}
$input.val("");
this.message_post(mes, _.pluck(this.get('attachment_ids'), 'id'));
},
on_keyup: function(event){
switch(event.which) {
// ESCAPED KEYS : do nothing
case $.ui.keyCode.END:
case $.ui.keyCode.PAGE_UP:
case $.ui.keyCode.PAGE_DOWN:
case $.ui.keyCode.ESCAPE:
// do nothing
break;
// ENTER, UP, DOWN : check if navigation in mention propositions
case $.ui.keyCode.UP:
case $.ui.keyCode.DOWN:
case $.ui.keyCode.ENTER:
this.mention_proposition_navigation(event.which);
break;
// DELETE : check if need to remove a mention
case $.ui.keyCode.DELETE:
this.mention_check_remove(event.which);
break;
// Otherwise, check is a mention is typed
default:
var mention_word = this.mention_detect_delimiter();
if(mention_word){
this.set('mention_word', mention_word);
}else{
this.set('mention_partners', []); // close the dropdown
}
}
},
/**
* Sent the message to the server to create a mail.message, using 'message_post' method
* @param {String} body : the core of the message (html, or plaintext)
@@ -112,15 +178,18 @@ var MailComposeMessage = Widget.extend({
message_post: function(body, attachment_ids, kwargs){
var self = this;
var values = _.defaults(kwargs || {}, {
'body': body,
'attachment_ids': attachment_ids || [],
'message_type': 'comment',
'content_subtype': 'html',
'partner_ids': [],
'subtype': 'mail.mt_comment',
});
values = _.extend(values, {
'body': this.mention_preprocess_message(body),
'attachment_ids': attachment_ids || [],
'partner_ids': _.pluck(this.get('mention_selected_partners'), 'id').concat(values['partner_ids']),
});
return this.thread_dataset._model.call('message_post', [this.context.default_res_id], values).then(function(message_id){
self.clean_attachments(); // empty attachment list
self.reset(); // empty attachment, mention partners, ...
self.trigger('message_sent', message_id);
return message_id;
});
@@ -206,16 +275,252 @@ var MailComposeMessage = Widget.extend({
}
return true;
},
clean_attachments: function(){
this.set('attachment_ids', []);
},
// ui
attachment_render: function(){
this.$('.o_mail_compose_message_attachment_list').html(QWeb.render('mail.ComposeMessage.attachments', {'widget': this}));
},
// Mention
on_click_mention_item: function(event){
event.preventDefault();
var text_input = this.$input.val();
var partner_id = this.$(event.currentTarget).data('partner-id');
var selected_partner = _.filter(this.get('mention_partners'), function(p){
return p.id === partner_id;
})[0];
// add the mention partner to the list
var mention_selected_partners = this.get('mention_selected_partners');
if(mention_selected_partners.length){ // there are already mention partner
// get mention matches (ordered by index in the text)
var matches = this.mention_get_match(text_input);
var index = this.mention_get_index(matches, this.get_cursor_position());
mention_selected_partners.splice(index, 0, selected_partner);
mention_selected_partners = _.clone(mention_selected_partners);
}else{ // this is the first mentionned partner
mention_selected_partners = mention_selected_partners.concat([selected_partner]);
}
this.set('mention_selected_partners', mention_selected_partners);
// update input text, and reset dropdown
var mention_word = this.get('mention_word');
var cursor_position = this.get_cursor_position();
var text_left = text_input.substring(0, cursor_position-(mention_word.length+1));
var text_right = text_input.substring(cursor_position, text_input.length);
var text_input_new = text_left + this.options.mention_delimiter + selected_partner.name + ' ' + text_right;
this.$input.val(text_input_new);
this.set_cursor_position(text_left.length+selected_partner.name.length+2);
this.set('mention_partners', []);
},
on_hover_mention_proposition: function(event){
var $elem = this.$(event.currentTarget);
this.$('.o_mail_mention_proposition').removeClass('active');
$elem.addClass('active');
},
mention_proposition_navigation: function(keycode){
var $active = this.$('.o_mail_mention_proposition.active');
if(keycode === $.ui.keyCode.ENTER){ // selecting proposition
if($active){
$active.click();
}
}else{ // navigation in propositions
var $to = undefined;
if(keycode === $.ui.keyCode.DOWN){
$to = $active.next('.o_mail_mention_proposition:not(.active)');
}else{
$to = $active.prev('.o_mail_mention_proposition:not(.active)');
}
if($to.length){
this.$('.o_mail_mention_proposition').removeClass('active');
$to.addClass('active');
}
}
},
/**
* Return the text attached to the mention delimiter
* @returns {String|false} : the text right after the delimiter or false
*/
mention_detect_delimiter: function(){
var self = this;
var delimiter = self.options.mention_delimiter;
var validate_keyword = function(search_str){
var pattern = "(^"+delimiter+"|(^\\s"+delimiter+"))";
var regex_start = new RegExp(pattern, "g");
search_str = search_str.replace(/^\s\s*|^[\n\r]/g, '');
if (regex_start.test(search_str) && search_str.length > self.options.mention_min_length){
search_str = search_str.replace(pattern, '');
return search_str.indexOf(' ') < 0 && !/[\r\n]/.test(search_str) ? search_str.replace(delimiter, '') : false;
}
return false;
};
var text_val = this.$input.val();
var left_string = text_val.substring(0, this.get_cursor_position());
var search_str = text_val.substring(left_string.lastIndexOf(delimiter) - 1, this.get_cursor_position());
return validate_keyword(search_str);
},
mention_word_change: function(){
var self = this;
var word = this.get('mention_word');
if(word){
// start a timeout to fetch partner with the current 'mention word'. The timer avoid to start
// a RPC for each pushed key when the user is typing the partner name. The 'mention_typing_speed'
// option should approach the time for a human to type a letter.
clearTimeout(this.mention_fetch_timer);
this.mention_fetch_timer = setTimeout(function() {
self.mention_fetch_partner();
}, this.options.mention_typing_speed);
}else{
// when deleting mention word, avoid RPC call with last long enough word
clearTimeout(this.mention_fetch_timer);
}
},
mention_fetch_partner: function(event){
var self = this;
var search_str = this.get('mention_word');
this.PartnerModel.query(['id', 'name', 'email'])
.filter([['id', 'not in', _.pluck(this.get('mention_selected_partners'), 'id')],'|', ['name', 'ilike', search_str], ['email', 'ilike', search_str]])
.limit(this.options.mention_fetch_limit)
.all().then(function(partners){
self.set('mention_partners', partners);
});
},
mention_check_remove: function(keycode){
var mention_selected_partners = this.get('mention_selected_partners');
var cursor = this.get_cursor_position();
var input_text = this.$input.val();
switch(keycode) {
// Remove a mention when a character belonging to a mention word is removed from the input text
case $.ui.keyCode.BACKSPACE:
var matches = this.mention_get_match(this.$input.val());
for(var i=0 ; i< matches.length ; i++){
var m = matches[i];
if(m.index <= cursor && cursor <= m.index+m[0].length){
mention_selected_partners.splice(i, 1);
}
}
break;
// Remove a mention if the deleted string overlapse a mention
case $.ui.keyCode.DELETE:
var left_text = input_text.substring(0, cursor);
var right_text = input_text.substring(cursor, input_text.length);
var temp = this.input_buffer.substring(0, this.input_buffer.length - right_text.length);
var deleted_text = temp.substring(left_text.length, temp.length);
var deleted_binf = left_text.length;
var deleted_bsup = left_text.length + deleted_text.length;
var matches = this.mention_get_match(this.input_buffer);
for(var i=0 ; i< matches.length ; i++){
var m = matches[i];
var m1 = m.index;
var m2 = m.index+m[0].length;
if(deleted_binf <= m2 && m1 <= deleted_bsup){
mention_selected_partners.splice(i, 1);
}
}
break;
}
this.set('mention_selected_partners', _.clone(mention_selected_partners));
},
mention_preprocess_message: function(body){
var partners = this.get('mention_selected_partners');
if(partners.length){
var matches = this.mention_get_match(body);
var substrings = [];
var start_index = 0;
for(var i=0; i < matches.length ; i++){
var match = matches[i];
var end_index = match.index+match[0].length;
var subtext = body.substring(start_index, end_index);
subtext = subtext.replace(match[0], _.str.sprintf("<span data-oe-model='res.partner' data-oe-id='%s'>%s</span>", partners[i].id, match[0]))
substrings.push(subtext);
start_index = end_index;
}
substrings.push(body.substring(start_index, body.length));
return substrings.join('');
}
return body;
},
mention_render_partner: function(){
this.$('.o_mail_mention_dropdown').html(QWeb.render('mail.ComposeMessage.mention_menu', {'widget': this}));
if(this.get('mention_partners').length){
this.$('.o_mail_mention_dropdown').addClass('open');
}else{
this.$('.o_mail_mention_dropdown').removeClass('open');
}
},
mention_render_selected_partners: function(){
this.$('.o_mail_mention_partner_tags').html(QWeb.render('mail.ComposeMessage.mention_tags', {'widget': this}));
},
/**
* Return the matches (as RexExp.exec do) for the partner mention in the input text
* @param {String} input_text : the text to search matches
* @returns {Object[]} matches in the same format as RexExp.exec()
*/
mention_get_match: function(input_text){
var self = this;
// create the regex of all mention partner name
var partner_names = _.pluck(this.get('mention_selected_partners'), 'name');
var escaped_partner_names = _.map(partner_names, function(str){
return "("+_.str.escapeRegExp(self.options.mention_delimiter+str)+")";
});
var regex_str = escaped_partner_names.join('|');
// extract matches
var result = [];
if(regex_str.length){
var myRegexp = new RegExp(regex_str, 'g');
var match = myRegexp.exec(input_text);
while (match != null) {
result.push(match);
match = myRegexp.exec(input_text);
}
}
return result;
},
mention_get_index: function(matches, cursor_position){
for(var i = 0 ; i < matches.length ; i++){
if(cursor_position < matches[i].index){
return i;
}else{
if(i === matches.length-1){
return i+1;
}
}
}
return 0;
},
// others
reset: function(){
this.set('attachment_ids', []);
this.set('mention_partners', []);
this.set('mention_selected_partners', []);
},
focus: function(){
this.$input.focus();
}
},
get_cursor_position: function() {
var el = this.$input.get(0);
if(!el){
return 0;
}
if('selectionStart' in el) {
return el.selectionStart;
} else if('selection' in document) {
var cr = document.selection.createRange();
return cr.moveStart('character', -el.focus().value.length).text.length - cr.text.length;
}
return 0;
},
set_cursor_position: function(pos) {
this.$input.each(function(index, elem) {
if (elem.setSelectionRange){
elem.setSelectionRange(pos, pos);
}
else if (elem.createTextRange){
elem.createTextRange().collapse(true).moveEnd('character', pos).moveStart('character', pos).select();
}
});
},
});
@@ -383,6 +688,7 @@ var MailThreadMixin = {
m.date = moment(time.str_to_datetime(m.date)).format('YYYY-MM-DD HH:mm:ss'); // set the date in the correct browser user timezone
if(m.body){
m.body = mail_utils.shortcode_apply(m.body, self.emoji_substitution);
m.body = self._message_preprocess_mention(m.body);
}
_.each(m.attachment_ids, function(a){
a.url = mail_utils.get_attachment_url(session, m.id, a.id);
@@ -390,6 +696,16 @@ var MailThreadMixin = {
});
return _.sortBy(messages, 'date');
},
/**
* Transform the mention 'span' tag into a link tag to the partner form
* @param {String} body : the message body (html) to transform
* @returns {String} the transformed body
*/
_message_preprocess_mention: function(body){
var re = /<span data-oe-model="([^"]*)"\s+data-oe-id="([^"]*)">(.*?)\<\/span>/g;
var subst = "<a href='#model=$1&id=$2' class='o_mail_redirect' data-oe-model='$1' data-oe-id='$2'>$3</a>";
return body.replace(re, subst);
},
/**
* Take the current messages, render them, and insert the rendering in the DOM.
* This is triggered when the mesasge list change.
@@ -56,6 +56,10 @@
width: 100%;
}
}
.o_mail_mention_partner_tags{
margin-bottom: 5px;
}
}
// ------------------------------------------------------------------
+28 -1
View File
@@ -6,6 +6,9 @@
-->
<t t-name="mail.ComposeMessage">
<div class="form-group col-md-12 o_mail_compose_message">
<div t-if="widget.options.mention_menu_orientation === 'top'" class="o_mail_mention_dropdown">
<t t-call="mail.ComposeMessage.mention_menu"/>
</div>
<!-- input text : depending on the display_mode option -->
<div t-att-class="widget.options.display_mode === 'chat' ? 'input-group o_mail_compose_message_mode_chat' : 'o_mail_compose_message_mode_mail'">
<t t-if="widget.options.display_mode === 'chat'">
@@ -26,6 +29,12 @@
</button>
</span>
</div>
<div t-if="widget.options.mention_menu_orientation === 'bottom'" class="o_mail_mention_dropdown">
<t t-call="mail.ComposeMessage.mention_menu"/>
</div>
<div class="o_mail_mention_partner_tags">
<t t-call="mail.ComposeMessage.mention_tags"/>
</div>
<span class="o_mail_attachment_file hide">
<t t-call="HiddenInputFile">
@@ -44,7 +53,7 @@
<t t-name="mail.ComposeMessage.emoji">
<div class="o_mail_compose_message_emoji_content">
<t t-foreach="widget.emoji_list" t-as="emoji">
<t t-foreach="widget.options.emoji_list" t-as="emoji">
<span t-att-data-emoji="emoji.source" class="o_mail_compose_message_emoji_img" t-att-title="emoji.description">
<t t-raw="emoji.substitution"/>
</span>
@@ -87,6 +96,24 @@
</div>
</t>
<t t-name="mail.ComposeMessage.mention_menu">
<ul class="dropdown-menu o_mail_mention_dropdown_menu" aria-labelledby="dropdownMailMentionMenu">
<t t-foreach="widget.get('mention_partners')" t-as="partner">
<li t-att-data-partner-id="partner.id" t-att-class="'o_mail_mention_proposition ' + (partner_index === 0 ? 'active' : '')">
<a href="#">
<t t-esc="partner.name"/> (<t t-esc="partner.email || _t('No email')"/>)</a>
</li>
</t>
</ul>
</t>
<t t-name="mail.ComposeMessage.mention_tags">
<t t-if="widget.get('mention_selected_partners').length">Mention(s) :</t>
<t t-foreach="widget.get('mention_selected_partners')" t-as="partner">
<span class="badge"><t t-esc="partner.name"/></span>
</t>
</t>
<!--
Message Thread