summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/js/composers/basic_composer.js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/static/src/js/composers/basic_composer.js')
-rw-r--r--addons/mail/static/src/js/composers/basic_composer.js640
1 files changed, 640 insertions, 0 deletions
diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js
new file mode 100644
index 00000000000..ce7c1017ffa
--- /dev/null
+++ b/addons/mail/static/src/js/composers/basic_composer.js
@@ -0,0 +1,640 @@
+odoo.define('mail.composer.Basic', function (require) {
+"use strict";
+
+var emojis = require('mail.emojis');
+var MentionManager = require('mail.composer.MentionManager');
+var DocumentViewer = require('mail.DocumentViewer');
+var mailUtils = 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 BasicComposer = Widget.extend({
+ template: "mail.Composer",
+ events: {
+ 'change input.o_input_file': '_onAttachmentChange',
+ 'click .o_attachment_delete': '_onAttachmentDelete',
+ 'click .o_attachment_download': '_onAttachmentDownload',
+ 'click .o_attachment_view': '_onAttachmentView',
+ 'click .o_composer_button_add_attachment': '_onClickAddAttachment',
+ 'click .o_composer_button_emoji': '_onEmojiButtonClick',
+ 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout',
+ 'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick',
+ 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus',
+ 'keydown .o_composer_input textarea': '_onKeydown',
+ 'keyup .o_composer_input': '_onKeyup',
+ 'click .o_composer_button_send': '_sendMessage',
+ },
+ // 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 || {}, {
+ commandsEnabled: true,
+ context: {},
+ inputMinHeight: 28,
+ mentionFetchLimit: 8,
+ // set to true to only suggest prefetched partners
+ mentionPartnersRestricted: false,
+ sendText: _t("Send"),
+ defaultBody: '',
+ defaultMentionSelections: {},
+ isMobile: config.device.isMobile
+ });
+ this.context = this.options.context;
+
+ // Attachments
+ this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context);
+ this.fileuploadID = _.uniqueId('o_chat_fileupload');
+ this.set('attachment_ids', []);
+
+ // Mention
+ this._mentionManager = new MentionManager(this);
+ this._mentionManager.register({
+ delimiter: '@',
+ fetchCallback: this._mentionFetchPartners.bind(this),
+ generateLinks: true,
+ model: 'res.partner',
+ redirectClassname: 'o_mail_redirect',
+ selection: this.options.defaultMentionSelections['@'],
+ suggestionTemplate: 'mail.MentionSuggestions.Partner',
+ });
+ this._mentionManager.register({
+ delimiter: '#',
+ fetchCallback: this._mentionFetchChannels.bind(this),
+ generateLinks: true,
+ model: 'mail.channel',
+ redirectClassname: 'o_channel_redirect',
+ selection: this.options.defaultMentionSelections['#'],
+ suggestionTemplate: 'mail.MentionSuggestions.Channel',
+ });
+ this._mentionManager.register({
+ delimiter: ':',
+ fetchCallback: this._mentionGetCannedResponses.bind(this),
+ selection: this.options.defaultMentionSelections[':'],
+ suggestionTemplate: 'mail.MentionSuggestions.CannedResponse',
+ });
+ if (this.options.commandsEnabled) {
+ this._mentionManager.register({
+ beginningOnly: true,
+ delimiter: '/',
+ fetchCallback: this._mentionGetCommands.bind(this),
+ selection: this.options.defaultMentionSelections['/'],
+ suggestionTemplate: 'mail.MentionSuggestions.Command',
+ });
+ }
+
+ 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._$attachmentButton = this.$('.o_composer_button_add_attachment');
+ this._$attachmentsList = 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.defaultBody);
+ dom.autoresize(this.$input, {
+ parent: this,
+ min_height: this.options.inputMinHeight
+ });
+
+ // Attachments
+ $(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this));
+ this.on('change:attachment_ids', this, this._renderAttachments);
+
+ // Mention
+ this._mentionManager.prependTo(this.$('.o_composer'));
+
+ return this._super();
+ },
+ destroy: function () {
+ $(window).off(this.fileuploadID);
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Empty input, selected partners and attachments
+ */
+ clearComposer: function () {
+ this.$input.val('');
+ this._mentionManager.resetSelections();
+ this.set('attachment_ids', []);
+ },
+ /**
+ * Set the focus on the input
+ */
+ focus: function () {
+ this.$input.focus();
+ },
+ /**
+ * Get the state of the composer.
+ *
+ * This is useful in order to (re)store its state when switchng to another
+ * thread with a composer, and coming back to the thread with this composer.
+ *
+ * @returns {Object}
+ */
+ getState: function () {
+ return {
+ attachments: this.get('attachment_ids'),
+ text: this.$input.val(),
+ };
+ },
+ /**
+ * @returns {integer} it returns length of attachment and trim content of
+ * input box to check for empty.
+ */
+ isEmpty: function () {
+ return !this.$input.val().trim() &&
+ !this.$('.o_attachments').children().length;
+ },
+ /**
+ * Set the state of the composer.
+ *
+ * This is useful in order to (re)store its state when switching to another
+ * thread with a composer, and coming back to the thread with this composer.
+ *
+ * @param {Object} state
+ * @param {Array} state.attachments
+ * @param {string} state.text
+ */
+ setState: function (state) {
+ this.set('attachment_ids', state.attachments);
+ this.$input.val(state.text);
+ },
+ /**
+ * Set the list of command suggestions on the thread.
+ *
+ * When the user partially types a command, matching commands are displayed
+ * to the user (@see _mentionGetCommands).
+ *
+ * @param {Object[]} commands
+ */
+ mentionSetCommands: function (commands) {
+ this._mentionCommands = commands;
+ },
+ /**
+ * Set the list of partner mentions suggestions that has been prefetched.
+ *
+ * When the user partially types a mention, matching prefetched partners are
+ * displayed to the user. If none of them match, then it will fetch for more
+ * partner suggestions (@see _mentionFetchPartners).
+ *
+ * @param {$.Deferred<Object[]>} prefetchedPartners
+ */
+ mentionSetPrefetchedPartners: function (prefetchedPartners) {
+ this._mentionPrefetchedPartners = prefetchedPartners;
+ },
+ /**
+ * @returns {Object} containing listener selections, grouped by delimiter
+ * as key of the object.
+ */
+ getMentionListenerSelections: function () {
+ return this._mentionManager.getListenerSelections();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _clearComposerOnSend: function () {
+ this.clearComposer();
+ },
+ /**
+ * @private
+ */
+ _doCheckAttachmentUpload: 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;
+ },
+ /**
+ * Hides the emojis container.
+ *
+ * @private
+ */
+ _hideEmojis: function () {
+ this._$emojisContainer.remove();
+ },
+ /**
+ * @private
+ * @param {string} search
+ * @returns {$.Deferred<Object[]>}
+ */
+ _mentionGetCannedResponses: function (search) {
+ var self = this;
+ var def = $.Deferred();
+ clearTimeout(this._cannedTimeout);
+ this._cannedTimeout = setTimeout(function () {
+ var cannedResponses = self.call('mail_service', 'getCannedResponses');
+ var matches = fuzzy.filter(mailUtils.unaccent(search), _.pluck(cannedResponses, 'source'));
+ var indexes = _.pluck(matches.slice(0, self.options.mentionFetchLimit), 'index');
+ def.resolve(_.map(indexes, function (index) {
+ return cannedResponses[index];
+ }));
+ }, 500);
+ return def;
+ },
+ /**
+ * @private
+ * @param {string} search
+ * @returns {Array}
+ */
+ _mentionGetCommands: function (search) {
+ var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i');
+ return _.filter(this._mentionCommands, function (command) {
+ return searchRegexp.test(command.name);
+ }).slice(0, this.options.mentionFetchLimit);
+ },
+ /**
+ * @private
+ * @param {string} search
+ */
+ _mentionFetchChannels: function (search) {
+ return this._mentionFetchThrottled('mail.channel', 'get_mention_suggestions', {
+ limit: this.options.mentionFetchLimit,
+ search: search,
+ }).then(function (suggestions) {
+ return _.partition(suggestions, function (suggestion) {
+ return _.contains(['public', 'groups'], suggestion.public);
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {string} search
+ */
+ _mentionFetchPartners: function (search) {
+ var self = this;
+ return $.when(this._mentionPrefetchedPartners).then(function (partners) {
+ // filter prefetched partners with the given search string
+ var suggestions = [];
+ var limit = self.options.mentionFetchLimit;
+ var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i');
+ if (limit > 0) {
+ var filteredPartners = _.filter(partners, function (partner) {
+ return partner.email && searchRegexp.test(partner.email) ||
+ partner.name && searchRegexp.test(mailUtils.unaccent(partner.name));
+ });
+ if (filteredPartners.length) {
+ suggestions.push(filteredPartners.slice(0, limit));
+ limit -= filteredPartners.length;
+ }
+ }
+ if (!suggestions.length && !self.options.mentionPartnersRestricted) {
+ // no result found among prefetched partners, fetch other suggestions
+ suggestions = self._mentionFetchThrottled(
+ 'res.partner',
+ 'get_mention_suggestions',
+ { limit: limit, search: search }
+ );
+ }
+ return suggestions;
+ });
+ },
+ /**
+ * @private
+ * @param {string} model
+ * @param {string} method
+ * @param {Object} kwargs
+ * @return {$.Deferred}
+ */
+ _mentionFetchThrottled: 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.mentionFetchTimer);
+ this.mentionFetchTimer = setTimeout(function () {
+ return self._rpc({model: model, method: method, kwargs: kwargs})
+ .then(function (results) {
+ def.resolve(results);
+ });
+ }, this.MENTION_THROTTLE);
+ return def;
+ },
+ /**
+ * @private
+ * @returns {$.Deferred}
+ */
+ _preprocessMessage: function () {
+ // Return a deferred as this function is extended with asynchronous
+ // behavior for the chatter composer
+
+ //Removing unwanted extra spaces from message
+ var value = _.escape(this.$input.val()).trim();
+ value = value.replace(/(\r|\n){2,}/g, '<br/><br/>');
+ value = value.replace(/(\r|\n)/g, '<br/>');
+
+ // prevent html space collapsing
+ value = value.replace(/ /g, '&nbsp;').replace(/([^>])&nbsp;([^<])/g, '$1 $2');
+ var commands = this.options.commandsEnabled ?
+ this._mentionManager.getListenerSelection('/') :
+ [];
+ return $.when({
+ content: this._mentionManager.generateLinks(value),
+ attachment_ids: _.pluck(this.get('attachment_ids'), 'id'),
+ partner_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelection('@'), 'id')),
+ command: commands.length > 0 ? commands[0].name : undefined,
+ });
+ },
+ /**
+ * @private
+ */
+ _renderAttachments: function () {
+ this._$attachmentsList.html(QWeb.render('mail.composer.Attachments', {
+ attachments: this.get('attachment_ids'),
+ }));
+ },
+ /**
+ * @private
+ */
+ _sendMessage: function () {
+ if (this.isEmpty() || !this._doCheckAttachmentUpload()) {
+ return;
+ }
+
+ clearTimeout(this._cannedTimeout);
+ var self = this;
+ this._preprocessMessage().then(function (message) {
+ self.trigger('post_message', message);
+ self._clearComposerOnSend();
+ self.$input.focus();
+ });
+ },
+ /**
+ * Send the message on ENTER, but go to new line on SHIFT+ENTER
+ *
+ * @private
+ */
+ _shouldSend: function (ev) {
+ return !ev.shiftKey;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQuery.Event} ev
+ */
+ _onAttachmentChange: function (ev) {
+ var self = this;
+ var files = ev.target.files;
+ var attachments = this.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._$attachmentButton.prop('disabled', true);
+ var uploadAttachments = _.map(files, function (file){
+ return {
+ id: 0,
+ name: file.name,
+ filename: file.name,
+ url: '',
+ upload: true,
+ mimetype: '',
+ };
+ });
+ attachments = attachments.concat(uploadAttachments);
+ this.set('attachment_ids', attachments);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAttachmentDelete: function (ev){
+ ev.stopPropagation();
+ var self = this;
+ var attachmentID = $(ev.target).data('id');
+ if (attachmentID) {
+ var attachments = [];
+ _.each(this.get('attachment_ids'), function (attachment){
+ if (attachmentID !== attachment.id) {
+ attachments.push(attachment);
+ } else {
+ self._attachmentDataSet.unlink([attachmentID]);
+ }
+ });
+ this.set('attachment_ids', attachments);
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAttachmentDownload: function (ev) {
+ ev.stopPropagation();
+ },
+ /**
+ * @private
+ */
+ _onAttachmentLoaded: function () {
+ var attachments = this.get('attachment_ids');
+ var 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._$attachmentButton.prop('disabled', false);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAttachmentView: function (ev) {
+ var activeAttachmentID = $(ev.currentTarget).data('id');
+ var attachments = this.get('attachment_ids');
+ if (activeAttachmentID) {
+ var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID);
+ attachmentViewer.appendTo($('body'));
+ }
+ },
+ _onClickAddAttachment: function () {
+ this.$('input.o_input_file').click();
+ this.$input.focus();
+ },
+ /**
+ * 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.Composer.emojis', {
+ emojis: emojis,
+ }));
+ }
+ 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 clicked -> adds it in the <input/>, focuses the
+ * <input/> 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();
+ },
+ /**
+ * Called when an emoji is focused -> @see _onEmojiButtonFocusout
+ *
+ * @private
+ */
+ _onEmojiImageFocus: function () {
+ clearTimeout(this._hideEmojisTimeout);
+ },
+ /**
+ * _onKeydown event is triggered when is key is pressed
+ * - on UP and DOWN arrow is pressed then event prevents it's default
+ * behaviour if mention manager is open else it break it.
+ * - on ENTER key is pressed and mention manager is open then event
+ * prevents it's default behaviour else check if ControlKey is
+ * pressed or not if yes then it send a message
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown: function (ev) {
+ switch (ev.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._mentionManager.isOpen()) {
+ ev.preventDefault();
+ }
+ break;
+ // ENTER: submit the message only if the dropdown mention proposition is not displayed
+ case $.ui.keyCode.ENTER:
+ if (this._mentionManager.isOpen()) {
+ ev.preventDefault();
+ } else {
+ var sendMessage = ev.ctrlKey || this._shouldSend(ev);
+ if (sendMessage) {
+ ev.preventDefault();
+ this._sendMessage();
+ }
+ }
+ break;
+ }
+ },
+ /**
+ * _onKeyup event is triggered when key is released.
+ * on ESCAP key it close the mention suggestion dropdown menu.
+ * on ENTER key it selects that mention and add to a content editable div.
+ * on UP or DOWN key it highlights previous or next mention respectively.
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeyup: function (ev) {
+ switch (ev.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._mentionManager.isOpen()) {
+ ev.stopPropagation();
+ this._mentionManager.resetSuggestions();
+ } 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._mentionManager.isOpen()) {
+ this._mentionManager.propositionNavigation(ev.which);
+ }
+ break;
+ // Otherwise, check if a mention is typed
+ default:
+ this._mentionManager.detectDelimiter();
+ }
+ },
+});
+
+return BasicComposer;
+
+});