odoo.define('mail.composer', function (require) { "use strict"; var chat_mixin = require('mail.chat_mixin'); var DocumentViewer = require('mail.DocumentViewer'); var utils = require('mail.utils'); var config = require('web.config'); var core = require('web.core'); var data = require('web.data'); var dom = require('web.dom'); var session = require('web.session'); var Widget = require('web.Widget'); var QWeb = core.qweb; var _t = core._t; var NON_BREAKING_SPACE = '\u00a0'; var MENTION_PARTNER_DELIMITER = '@'; var MENTION_CHANNEL_DELIMITER = '#'; var MENTION_CANNED_RESPONSE_DELIMITER = ':'; var MENTION_COMMAND_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 // given delimiter). If so, if fetches mention suggestions and renders them. On // suggestion clicked, it updates the selection for the corresponding listener. var MentionManager = Widget.extend({ className: 'dropup o_composer_mention_dropdown', events: { "mouseover .o_mention_proposition": "on_hover_mention_proposition", "click .o_mention_proposition": "on_click_mention_item", }, init: function (parent, options) { this._super.apply(this, arguments); this.composer = parent; this.options = _.extend({}, options, { min_length: 0, }); this.open = false; this.listeners = []; this.set('mention_suggestions', []); this.on('change:mention_suggestions', this, this._render_suggestions); }, // Events on_hover_mention_proposition: function (event) { var $elem = $(event.currentTarget); this.$('.o_mention_proposition').removeClass('active'); $elem.addClass('active'); }, on_click_mention_item: function (event) { event.preventDefault(); var text_input = this.composer.$input.val(); var id = $(event.currentTarget).data('id'); var selected_suggestion = _.find(_.flatten(this.get('mention_suggestions')), function (s) { return s.id === id; }); 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 links * @param {string} [model] (optional) the model used for redirection * @param {string} [redirect_classname] (optional) the classname of the 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: [], })); }, /** * Returns true if the mention suggestions dropdown is open, false otherwise */ is_open: function () { return this.open; }, /** * Returns the mentions of the given listener that haven't been erased from the composer's input */ get_listener_selection: function (delimiter) { var listener = _.findWhere(this.listeners, {delimiter: delimiter}); if (listener) { var input_mentions = this.composer.$input.val().match(new RegExp(delimiter+'[^ ]+(?= | )', 'g')); return this._validate_selection(listener.selection, input_mentions); } return []; }, get_listener_selections: function () { var selections = {}; _.each(this.listeners, function (listener) { selections[listener.delimiter] = listener.selection; }); return selections; }, /** * Detects if the user is currently typing a mention word * @return the search string if it is, false otherwise */ detect_delimiter: function () { var self = this; var text_val = this.composer.$input.val(); var cursor_position = this._get_selection_positions().start; var left_string = text_val.substring(0, cursor_position); function validate_keyword (delimiter, beginning_only) { var delimiter_position = left_string.lastIndexOf(delimiter) - 1; if (beginning_only && delimiter_position > 0) { return false; } var search_str = text_val.substring(delimiter_position, cursor_position); 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.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; } this.active_listener = undefined; for (var i=0; i 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 { this.$el.removeClass('open'); this.$el.empty(); this.open = false; } }, _validate_selection: function (selection, input_mentions) { var validated_selection = []; _.each(input_mentions, function (mention) { var validated_mention = _.findWhere(selection, {name: mention.slice(1)}); if (validated_mention) { validated_selection.push(validated_mention); } }); return validated_selection; }, // Cursor position and selection utils _get_selection_positions: function () { var el = this.composer.$input.get(0); return el ? dom.getSelectionRange(el) : {start: 0, end: 0}; }, _set_cursor_position: function (pos) { this.composer.$input.each(function (index, elem) { dom.setSelectionRange(elem, {start: pos, end: pos}); }); }, }); var BasicComposer = Widget.extend(chat_mixin, { template: "mail.ChatComposer", events: { "keydown .o_composer_input textarea": "on_keydown", "keyup .o_composer_input": "on_keyup", "change input.o_input_file": "on_attachment_change", "click .o_composer_button_send": "send_message", "click .o_composer_button_add_attachment": "on_click_add_attachment", "click .o_attachment_delete": "on_attachment_delete", "click .o_attachment_download": "_onAttachmentDownload", "click .o_attachment_view": "_onAttachmentView", 'click .o_composer_button_emoji': '_onEmojiButtonClick', 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout', 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus', 'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick', }, // RPCs done to fetch the mention suggestions are throttled with the following value MENTION_THROTTLE: 200, init: function (parent, options) { this._super.apply(this, arguments); this.options = _.defaults(options || {}, { commands_enabled: true, context: {}, input_baseline: 18, input_max_height: 150, input_min_height: 28, mention_fetch_limit: 8, mention_partners_restricted: false, // set to true to only suggest prefetched partners send_text: _t('Send'), default_body: '', default_mention_selections: {}, isMobile: config.device.isMobile }); this.context = this.options.context; // Attachments this.AttachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context); this.fileupload_id = _.uniqueId('o_chat_fileupload'); this.set('attachment_ids', []); // Mention this.mention_manager = new MentionManager(this); 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', }); if (this.options.commands_enabled) { this.mention_manager.register({ beginning_only: true, delimiter: MENTION_COMMAND_DELIMITER, fetch_callback: this.mention_get_commands.bind(this), selection: this.options.default_mention_selections[MENTION_COMMAND_DELIMITER], suggestion_template: 'mail.MentionCommandSuggestions', }); } this.isMini = options.isMini; this.avatarURL = session.uid > 0 ? session.url('/web/image', { model: 'res.users', field: 'image_small', id: session.uid, }) : '/web/static/src/img/user_menu_avatar.png'; }, start: function () { var self = this; this.$attachment_button = this.$(".o_composer_button_add_attachment"); this.$attachments_list = this.$('.o_composer_attachments_list'); this.$input = this.$('.o_composer_input textarea'); this.$input.focus(function () { self.trigger('input_focused'); }); this.$input.val(this.options.default_body); dom.autoresize(this.$input, {parent: this, min_height: this.options.input_min_height}); // Attachments $(window).on(this.fileupload_id, this.on_attachment_loaded); this.on("change:attachment_ids", this, this.render_attachments); // Mention this.mention_manager.prependTo(this.$('.o_composer')); return this._super(); }, destroy: function () { $(window).off(this.fileupload_id); return this._super.apply(this, arguments); }, preprocess_message: function () { // Return a deferred as this function is extended with asynchronous // behavior for the chatter composer var value = _.escape(this.$input.val()).replace(/\n|\r/g, '
'); // prevent html space collapsing value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2'); var commands = this.options.commands_enabled ? this.mention_manager.get_listener_selection('/') : []; return $.when({ content: this.mention_manager.generate_links(value), attachment_ids: _.pluck(this.get('attachment_ids'), 'id'), partner_ids: _.uniq(_.pluck(this.mention_manager.get_listener_selection('@'), 'id')), command: commands.length > 0 ? commands[0].name : undefined, }); }, send_message: function () { if (this.is_empty() || !this.do_check_attachment_upload()) { return; } clearTimeout(this.canned_timeout); var self = this; this.preprocess_message().then(function (message) { self.trigger('post_message', message); self.clear_composer_on_send(); self.$input.focus(); }); }, clear_composer: function() { // Empty input, selected partners and attachments this.$input.val(''); this.mention_manager.reset_selections(); this.set('attachment_ids', []); }, clear_composer_on_send: function() { this.clear_composer(); }, getState: function () { return { attachments: this.get('attachment_ids'), text: this.$input.val(), }; }, // Events on_click_add_attachment: function () { this.$('input.o_input_file').click(); this.$input.focus(); // set ignoreEscape to avoid escape_pressed event when file selector dialog is opened // when user press escape to cancel file selector dialog then escape_pressed event should not be trigerred this.ignoreEscape = true; }, setState: function (state) { this.set('attachment_ids', state.attachments); this.$input.val(state.text); }, /** * Send the message on ENTER, but go to new line on SHIFT+ENTER */ should_send: function (event) { return !event.shiftKey; }, on_keydown: function (event) { switch(event.which) { // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions case $.ui.keyCode.UP: case $.ui.keyCode.DOWN: case $.ui.keyCode.TAB: if (this.mention_manager.is_open()) { event.preventDefault(); } break; // ENTER: submit the message only if the dropdown mention proposition is not displayed case $.ui.keyCode.ENTER: if (this.mention_manager.is_open()) { event.preventDefault(); } else { var send_message = event.ctrlKey || this.should_send(event); if (send_message) { event.preventDefault(); this.send_message(); } } break; } }, 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: break; // ESCAPE: close mention propositions case $.ui.keyCode.ESCAPE: if (this.mention_manager.is_open()) { event.stopPropagation(); this.mention_manager.reset_suggestions(); } else if (this.ignoreEscape) { this.ignoreEscape = false; } else { this.trigger_up("escape_pressed"); } break; // ENTER, UP, DOWN, TAB: check if navigation in mention propositions case $.ui.keyCode.ENTER: case $.ui.keyCode.UP: case $.ui.keyCode.DOWN: case $.ui.keyCode.TAB: if (this.mention_manager.is_open()) { this.mention_manager._propositionNavigation(event.which); } break; // Otherwise, check if a mention is typed default: this.mention_manager.detect_delimiter(); } }, // Attachments on_attachment_change: function(event) { var self = this, files = event.target.files, attachments = self.get('attachment_ids'); _.each(files, function(file){ var attachment = _.findWhere(attachments, {name: file.name}); // if the files already exits, delete the file before upload if(attachment){ self.AttachmentDataSet.unlink([attachment.id]); attachments = _.without(attachments, attachment); } }); this.$('form.o_form_binary_form').submit(); this.$attachment_button.prop('disabled', true); var upload_attachments = _.map(files, function(file){ return { 'id': 0, 'name': file.name, 'filename': file.name, 'url': '', 'upload': true, 'mimetype': '', }; }); attachments = attachments.concat(upload_attachments); this.set('attachment_ids', attachments); }, on_attachment_loaded: function(event) { var self = this, attachments = this.get('attachment_ids'), files = Array.prototype.slice.call(arguments, 1); _.each(files, function(file){ if(file.error || !file.id){ this.do_warn(file.error); attachments = _.filter(attachments, function (attachment) { return !attachment.upload; }); }else{ var attachment = _.findWhere(attachments, {filename: file.filename, upload: true}); if(attachment){ attachments = _.without(attachments, attachment); attachments.push({ 'id': file.id, 'name': file.name || file.filename, 'filename': file.filename, 'mimetype': file.mimetype, 'url': session.url('/web/content', {'id': file.id, download: true}), }); } } }.bind(this)); this.set('attachment_ids', attachments); this.$attachment_button.prop('disabled', false); }, on_attachment_delete: function(event){ event.stopPropagation(); var self = this; var attachment_id = $(event.target).data("id"); if (attachment_id) { var attachments = []; _.each(this.get('attachment_ids'), function(attachment){ if (attachment_id !== attachment.id) { attachments.push(attachment); } else { self.AttachmentDataSet.unlink([attachment_id]); } }); this.set('attachment_ids', attachments); this.$('input.o_input_file').val(''); } }, do_check_attachment_upload: function () { if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) { this.do_warn(_t("Uploading error"), _t("Please, wait while the file is uploading.")); return false; } return true; }, render_attachments: function() { this.$attachments_list.html(QWeb.render('mail.ChatComposer.Attachments', { attachments: this.get('attachment_ids'), })); }, // Mention mention_fetch_throttled: function (model, method, kwargs) { var self = this; // Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing var def = $.Deferred(); clearTimeout(this.mention_fetch_timer); this.mention_fetch_timer = setTimeout(function () { return self._rpc({model: model, method: method, kwargs: kwargs}) .then(function (results) { def.resolve(results); }); }, this.MENTION_THROTTLE); return def; }, mention_fetch_channels: function (search) { return this.mention_fetch_throttled('mail.channel', 'get_mention_suggestions', { limit: this.options.mention_fetch_limit, search: search, }).then(function (suggestions) { return _.partition(suggestions, function (suggestion) { return _.contains(['public', 'groups'], suggestion.public); }); }); }, mention_fetch_partners: function (search) { var self = this; return $.when(this.mention_prefetched_partners).then(function (prefetched_partners) { // filter prefetched partners with the given search string var suggestions = []; var limit = self.options.mention_fetch_limit; var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i'); _.each(prefetched_partners, function (partners) { if (limit > 0) { var filtered_partners = _.filter(partners, function (partner) { 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)); limit -= filtered_partners.length; } } }); if (!suggestions.length && !self.options.mention_partners_restricted) { // no result found among prefetched partners, fetch other suggestions suggestions = self.mention_fetch_throttled('res.partner', 'get_mention_suggestions', { limit: limit, search: search, }); } return suggestions; }); }, mention_get_canned_responses: function (search) { var self = this; var def = $.Deferred(); clearTimeout(this.canned_timeout); this.canned_timeout = setTimeout(function() { var canned_responses = self._getCannedResponses(); var matches = fuzzy.filter(utils.unaccent(search), _.pluck(canned_responses, 'source')); var indexes = _.pluck(matches.slice(0, self.options.mention_fetch_limit), 'index'); def.resolve(_.map(indexes, function (i) { return canned_responses[i]; })); }, 500); return def; }, mention_get_commands: function (search) { var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i'); return _.filter(this.mention_commands, function (command) { return search_regexp.test(command.name); }).slice(0, this.options.mention_fetch_limit); }, mention_set_prefetched_partners: function (prefetched_partners) { this.mention_prefetched_partners = prefetched_partners; }, mention_set_enabled_commands: function (commands) { this.mention_commands = commands; }, mention_get_listener_selections: function () { return this.mention_manager.get_listener_selections(); }, // Others is_empty: function () { return !this.$input.val().trim() && !this.$('.o_attachments').children().length; }, focus: function () { this.$input.focus(); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Hides the emojis container. * * @private */ _hideEmojis: function () { this.$emojisContainer.remove(); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private * @param {MouseEvent} event */ _onAttachmentDownload: function (event) { event.stopPropagation(); }, /** * @private * @param {MouseEvent} event */ _onAttachmentView: function (event) { event.stopPropagation(); var activeAttachmentID = $(event.currentTarget).data('id'); var attachments = this.get('attachment_ids'); if (activeAttachmentID) { var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID); attachmentViewer.appendTo($('body')); } }, /** * Called when the emoji button is clicked -> opens/hides the emoji panel. * Also, this method is in charge of the rendering of this panel the first * time it is opened. * * @private */ _onEmojiButtonClick: function () { if (!this.$emojisContainer) { // lazy rendering this.$emojisContainer = $(QWeb.render('mail.ChatComposer.emojis', { emojis: this._getEmojis(), })); } if (this.$emojisContainer.parent().length) { this._hideEmojis(); } else { this.$emojisContainer.appendTo(this.$('.o_composer')); } }, /** * Called when the emoji button is blurred -> closes the emoji panel. The * closing is scheduled to be done at the end of the current execution * stack to allow stoping this closing if the button was focusout to select * an emoji (for example). * * @private */ _onEmojiButtonFocusout: function () { if (this.$emojisContainer) { this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0); } }, /** * Called when an emoji is focused -> @see _onEmojiButtonFocusout * * @private */ _onEmojiImageFocus: function () { clearTimeout(this._hideEmojisTimeout); }, /** * Called when an emoji is clicked -> adds it in the , focuses the * and closes the emoji panel. * * @private * @param {Event} ev */ _onEmojiImageClick: function (ev) { this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " "); this.$input.focus(); this._hideEmojis(); }, }); var ExtendedComposer = BasicComposer.extend({ init: function (parent, options) { options = _.defaults(options || {}, { input_min_height: 120, }); this._super(parent, options); this.extended = true; }, start: function () { this.$subject_input = this.$(".o_composer_subject input"); return this._super.apply(this, arguments); }, preprocess_message: function () { var self = this; return this._super().then(function (message) { var subject = self.$subject_input.val(); self.$subject_input.val(""); message.subject = subject; return message; }); }, clear_composer: function () { this._super.apply(this, arguments); this.$subject_input.val(''); }, getState: function () { var state = this._super.apply(this, arguments); state.subject = this.$subject_input.val(); return state; }, should_send: function () { return false; }, focus: function (target) { if (target === 'body') { this.$input.focus(); } else { this.$subject_input.focus(); } }, set_subject: function(subject) { this.$('.o_composer_subject input').val(subject); }, setState: function (state) { this._super.apply(this, arguments); this.set_subject(state.subject); }, }); return { BasicComposer: BasicComposer, ExtendedComposer: ExtendedComposer, }; });