diff options
| author | Alexandre Kühn <aku@odoo.com> | 2018-06-12 15:46:46 +0200 |
|---|---|---|
| committer | Alexandre Kühn <aku@odoo.com> | 2018-07-02 15:06:54 +0200 |
| commit | cd34f6de727d5b3858420cff3c9d3c5995c4e75c (patch) | |
| tree | 8d25861f98a038b71728355375d634a5c178290e /addons/mail/static/src/js/composers | |
| parent | ee9c1f52c316c83775969a3c9038e7e9f560f7e7 (diff) | |
[REF] mail: JS mail refactoring
----------------------
Summary
----------------------
The purpose of this commit is to improve the JS code of the `mail` module.
It applies the new coding guidelines and makes some changes on the design
of some modules, such as the old ChatManager module.
Here is a short summary of the changes that have been made:
1. New coding guidelines
- snake_case to camelCase
- prefix private attributes and methods with '_'
- jsdoc on most methods
- one class per module
2. Rename/Merge some classes
- 'chat manager' becomes 'mail manager' (internal) and 'mail service' (external)
- 'chat window manager' is now included in mail manager
- 'thread' widget now named 'thread widget'
3. New model abstraction for mail objects:
- modules 'mail.model.*'
- modeling:
0..1 0..1 * *
ThreadWindow <------> Thread <-------> Message
/ \
/ \
Thread With Cache Document Thread
/ | \
/ | \
Mailbox Channel Support Channel
|
|
DM
- Thread: the superclass of threads.
- ThreadWindow: the window component of a thread.
- Message: mail objects representing messages.
- Thread With Cache: threads that can be used with search view (Discuss app compatible).
- Document Thread: represents the thread part of a chatter.
- Mailbox: represents what was previously called 'static' channel, e.g. 'Inbox'.
- Channel: mail objects representing channels, including livechat.
- DM: special kind of Channel for 1:1 communication in the backend.
- Support Channel: special channel for im_support module.
This new modeling approach let us easily add features on all threads, such as the
possibility to put any thread in a small window.
----------------------
Known issues
----------------------
[Already Present in Master]
1. When the Discuss app is in the background with 'Inbox' as the selected
Thread, when clicking on a document thread preview in the messaging menu
of the systray, the rainbow man appears.
2. When a document with the chatter is in the background, when receiving an
inbox notification from this document thread, the document thread is
automatically marked as read, which removes the notification right away.
3. Sometimes, opening a DM window from the "blank" thread window does not work.
4. Reply-to feature on Inbox is not working: no message is sent in the document
thread.
5. On the first login of admin user with demo data, the inbox counter is wrong
(it displays 6, instead of 3).
Explanation after investigation:
> On page load, it fetches the correct number of Inbox messages (3),
but the server notifies of 3 needaction messages right away,
so it wrongly assumes these are new needaction messages.
> Not possible for web client to detect that these messages should not
increment the Inbox counter while keeping same API.
6. Notifications for new document thread messages only work when the user sets
'handle with Odoo' for the Notification Management in the preferences.
> due to notifications on the longpoll bus for document thread
messages that come from needaction notifications.
> requires server-side changes to send notification on the longpoll
bus to mentionned user.
[New]
7. When receiving a message on a unjoined channel, thread window flickers
('open' > 'close' > 'open')
Explanation after investigation:
> JS logic:
a) On auto-join, ask server to join the channel and get channel infos.
b) The info tells the channel is not detached, but JS code makes decision
to detach it, and tells server the channel is now detached.
c) From (a), server notifies on longpoll bus the state of channel, which is
not detached. The web client thinks that the window state of the channel
has been changed somewhere else, and the channel is now closed.
d) From (b), server notifies on longpoll bus the state of channel, which is
detached. The web client opens the thread window of this channel.
> The flicker didn't occur before refactoring because the web client was only
updating the model of channel when it receives the longpoll notification.
> Server behaviour on 1st longpoll notification is necessary for cross-tab
synchronization for channel window state.
> New design implies that model and view should be synchronized, hence the
issue now.
> Solution: remove server-side thread window synchronization and replace with
client-side synchronization.
----------------------
Hacks
----------------------
The module `im_livechat` now uses mail objects that are compatible with Message
and Window objects:
Modeling for messages:
AbstractMessage
/ \
/ \
LivechatMessage Message
- AbstractMessage: message compatible with the thread widget.
- LivechatMessage: message used by im_livechat.
- Message: message used with the mail manager.
Modeling for thread windows:
AbstractThreadWindow
/ \
/ \
LivechatWindow ThreadWindow
- AbstractThreadWindow: behaviour share between all types of thread windows.
- LivechatWindow: window used by im_livechat.
- ThreadWindow: window used with mail_manager.
The reason for these hacks are twofold:
1. Use the thread widget in the frontend and livechat external lib bundles.
2. Do not have a dependency with the mail manager in the frontend and external
lib bundles.
Diffstat (limited to 'addons/mail/static/src/js/composers')
| -rw-r--r-- | addons/mail/static/src/js/composers/basic_composer.js | 640 | ||||
| -rw-r--r-- | addons/mail/static/src/js/composers/chatter_composer.js | 295 | ||||
| -rw-r--r-- | addons/mail/static/src/js/composers/extended_composer.js | 100 | ||||
| -rw-r--r-- | addons/mail/static/src/js/composers/mention_manager.js | 432 |
4 files changed, 1467 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, ' ').replace(/([^>]) ([^<])/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; + +}); diff --git a/addons/mail/static/src/js/composers/chatter_composer.js b/addons/mail/static/src/js/composers/chatter_composer.js new file mode 100644 index 00000000000..d1a990b31e4 --- /dev/null +++ b/addons/mail/static/src/js/composers/chatter_composer.js @@ -0,0 +1,295 @@ +odoo.define('mail.composer.Chatter', function (require) { +"use strict"; + +var BasicComposer = require('mail.composer.Basic'); +var mailUtils = require('mail.utils'); + +var core = require('web.core'); +var viewDialogs = require('web.view_dialogs'); + +var _t = core._t; + +/** + * Chat Composer for the Chatter + * + * Extends the basic Composer Widget to add 'suggested partner' layer (open + * popup when suggested partner is selected without email, or other + * informations), and the button to open the full composer wizard. + */ +var ChatterComposer = BasicComposer.extend({ + template: 'mail.chatter.Composer', + events: _.extend({}, BasicComposer.prototype.events, { + 'click .o_composer_button_full_composer': '_onOpenFullComposer', + }), + init: function (parent, model, suggestedPartners, options) { + this._super(parent, options); + this._model = model; + this.suggestedPartners = suggestedPartners; + this.options = _.defaults(this.options, { + display_mode: 'textarea', + recordName: false, + isLog: false, + }); + if (this.options.isLog) { + this.options.sendText = _t("Log"); + } + this.notInline = true; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Chatter don't clear message on sent but after successful sent + * + * @override + */ + clearComposerOnSend: function () {}, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Check the additional partners (not necessary registered partners), and + * open a popup form view for the ones who informations is missing. + * + * @private + * @param {Array} checkedSuggestedPartners list of 'recipient' partners to + * complete informations or validate + * @returns {Deferred} resolved with the list of checked suggested partners + * (real partner) + **/ + _checkSuggestedPartners: function (checkedSuggestedPartners) { + var self = this; + var checkDone = $.Deferred(); + + var recipients = _.filter(checkedSuggestedPartners, function (recipient) { + return recipient.checked; + }); + var recipientsToFind = _.filter(recipients, function (recipient) { + return (! recipient.partner_id); + }); + var namesToFind = _.pluck(recipientsToFind, 'full_name'); + var recipientsToCheck = _.filter(recipients, function (recipient) { + return (recipient.partner_id && ! recipient.email_address); + }); + var recipientIDs = _.pluck(_.filter(recipients, function (recipient) { + return recipient.partner_id && recipient.email_address; + }), 'partner_id'); + + var namesToRemove = []; + var recipientIDsToRemove = []; + + // have unknown names + // -> call message_get_partner_info_from_emails to try to find + // partner_id + var def; + if (namesToFind.length > 0) { + def = this._rpc({ + model: this._model, + method: 'message_partner_info_from_emails', + args: [[this.context.default_res_id], namesToFind], + }); + } + + // for unknown names + incomplete partners + // -> open popup - cancel = remove from recipients + $.when(def).pipe(function (result) { + result = result || []; + var emailDefs = []; + var recipientPopups = result.concat(recipientsToCheck); + + _.each(recipientPopups, function (partnerInfo) { + var deferred = $.Deferred(); + emailDefs.push(deferred); + + var partnerName = partnerInfo.full_name; + var partnerID = partnerInfo.partner_id; + var parsedEmail = mailUtils.parseEmail(partnerName); + + var dialog = new viewDialogs.FormViewDialog(self, { + res_model: 'res.partner', + res_id: partnerID, + context: { + active_model: self._model, + active_id: self.context.default_res_id, + force_email: true, + ref: 'compound_context', + default_name: parsedEmail[0], + default_email: parsedEmail[1], + }, + title: _t("Please complete customer's informations"), + disable_multiple_selection: true, + }).open(); + dialog.on('closed', self, function () { + deferred.resolve(); + }); + dialog.opened().then(function () { + dialog.form_view.on('on_button_cancel', self, function () { + namesToRemove.push(partnerName); + if (partnerID) { + recipientIDsToRemove.push(partnerID); + } + }); + }); + }); + $.when.apply($, emailDefs).then(function () { + var newNamesToFind = _.difference(namesToFind, namesToRemove); + var def; + if (newNamesToFind.length > 0) { + def = self._rpc({ + model: self._model, + method: 'message_partner_info_from_emails', + args: [[self.context.default_res_id], newNamesToFind, true], + }); + } + $.when(def).pipe(function (result) { + result = result || []; + var recipientPopups = result.concat(recipientsToCheck); + _.each(recipientPopups, function (partnerInfo) { + if ( + partnerInfo.partner_id && + _.indexOf(partnerInfo.partner_id, recipientIDsToRemove) === -1 + ) { + recipientIDs.push(partnerInfo.partner_id); + } + }); + }).pipe(function () { + checkDone.resolve(recipientIDs); + }); + }); + }); + return checkDone; + }, + /** + * Get the list of selected suggested partners + * + * @private + * @returns {Array} list of 'recipient' selected partners (may not be + * created in db) + **/ + _getCheckedSuggestedPartners: function () { + var self = this; + var checkedPartners = []; + this.$('.o_composer_suggested_partners input:checked').each(function () { + var fullName = $(this).data('fullname'); + checkedPartners = checkedPartners.concat( + _.filter(self.suggestedPartners, function (item) { + return fullName === item.full_name; + }) + ); + }); + return checkedPartners; + }, + /** + * @override + * @private + * @returns {$.Deferred} + */ + _preprocessMessage: function () { + var self = this; + var def = $.Deferred(); + this._super().then(function (message) { + message = _.extend(message, { + subtype: 'mail.mt_comment', + message_type: 'comment', + context: self.context, + }); + + // Subtype + if (self.options.isLog) { + message.subtype = 'mail.mt_note'; + } + + // Partner_ids + if (!self.options.isLog) { + var checkedSuggestedPartners = self._getCheckedSuggestedPartners(); + self._checkSuggestedPartners(checkedSuggestedPartners).done(function (partnerIDs) { + message.partner_ids = (message.partner_ids || []).concat(partnerIDs); + // update context + message.context = _.defaults({}, message.context, { + mail_post_autofollow: true, + }); + def.resolve(message); + }); + } else { + def.resolve(message); + } + + }); + + return def; + }, + /** + * @override + * @private + */ + _shouldSend: function () { + return false; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onOpenFullComposer: function () { + if (!this._doCheckAttachmentUpload()){ + return false; + } + + var self = this; + var recipientDoneDef = $.Deferred(); + + // any operation on the full-composer will reload the record, so + // warn the user that any unsaved changes on the record will be lost. + this.trigger_up('discard_record_changes', { + proceed: function () { + if (self.options.isLog) { + recipientDoneDef.resolve([]); + } else { + var checkedSuggestedPartners = self._getCheckedSuggestedPartners(); + self._checkSuggestedPartners(checkedSuggestedPartners) + .then(recipientDoneDef.resolve.bind(recipientDoneDef)); + } + }, + }); + + recipientDoneDef.then(function (partnerIDs) { + var context = { + default_parent_id: self.id, + default_body: mailUtils.getTextToHTML(self.$input.val()), + default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'), + default_partner_ids: partnerIDs, + default_is_log: self.options.isLog, + mail_post_autofollow: true, + }; + + if (self.context.default_model && self.context.default_res_id) { + context.default_model = self.context.default_model; + context.default_res_id = self.context.default_res_id; + } + + var action = { + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + view_mode: 'form', + view_type: 'form', + views: [[false, 'form']], + target: 'new', + context: context, + }; + self.do_action(action, { + on_close: self.trigger.bind(self, 'need_refresh'), + }).then(self.trigger.bind(self, 'close_composer')); + }); + } +}); + +return ChatterComposer; + +}); diff --git a/addons/mail/static/src/js/composers/extended_composer.js b/addons/mail/static/src/js/composers/extended_composer.js new file mode 100644 index 00000000000..f6e8560da8f --- /dev/null +++ b/addons/mail/static/src/js/composers/extended_composer.js @@ -0,0 +1,100 @@ +odoo.define('mail.composer.Extended', function (require) { +"use strict"; + +var BasicComposer = require('mail.composer.Basic'); + +var ExtendedComposer = BasicComposer.extend({ + /** + * @override + */ + init: function (parent, options) { + options = _.defaults(options || {}, { + inputMinHeight: 120, + }); + this._super(parent, options); + this.extended = true; + }, + /** + * @override + */ + start: function () { + this._$subjectInput = this.$('.o_composer_subject input'); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + clearComposer: function () { + this._super.apply(this, arguments); + this._$subjectInput.val(''); + }, + /** + * @override + * @param {string} target + */ + focus: function (target) { + if (target === 'body') { + this.$input.focus(); + } else { + this._$subjectInput.focus(); + } + }, + /** + * @override + */ + getState: function () { + var state = this._super.apply(this, arguments); + state.subject = this._$subjectInput.val(); + return state; + }, + /** + * @override + * @param {Object} state + * @param {string} state.subject + */ + setState: function (state) { + this._super.apply(this, arguments); + this.setSubject(state.subject); + }, + /** + * @param {string} subject + */ + setSubject: function (subject) { + this.$('.o_composer_subject input').val(subject); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @returns {$.Deferred} + */ + _preprocessMessage: function () { + var self = this; + return this._super().then(function (message) { + var subject = self._$subjectInput.val(); + self._$subjectInput.val(""); + message.subject = subject; + return message; + }); + }, + /** + * @override + * @private + */ + _shouldSend: function () { + return false; + }, +}); + +return ExtendedComposer; + +}); diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js new file mode 100644 index 00000000000..fb8d9ddc065 --- /dev/null +++ b/addons/mail/static/src/js/composers/mention_manager.js @@ -0,0 +1,432 @@ +odoo.define('mail.composer.MentionManager', function (require) { +"use strict"; + +var core = require('web.core'); +var dom = require('web.dom'); +var session = require('web.session'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + +var NON_BREAKING_SPACE = '\u00a0'; + +// 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: { + 'click .o_mention_proposition': '_onClickMentionItem', + 'mouseover .o_mention_proposition': '_onHoverMentionProposition', + }, + init: function (parent) { + this._super.apply(this, arguments); + + this._composer = parent; + + this._open = false; + this._listeners = []; + this.set('mention_suggestions', []); + this.on('change:mention_suggestions', this, this._renderSuggestions); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns true if the mention suggestions dropdown is open, false otherwise + * + * @returns {boolean} + */ + isOpen: function () { + return this._open; + }, + /** + * Returns the mentions of the given listener that haven't been erased from + * the composer's input + * + * @returns {Array} + */ + getListenerSelection: function (delimiter) { + var listener = _.findWhere(this._listeners, { delimiter: delimiter }); + if (listener) { + var inputMentions = this._composer + .$input + .val() + .match(new RegExp(delimiter+'[^ ]+(?= | )', 'g')); + return this._validateSelection(listener.selection, inputMentions); + } + return []; + }, + /** + * @returns {Object} containing listener selections, grouped by delimiter + * as key of the object. + */ + getListenerSelections: 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 + */ + detectDelimiter: function () { + var self = this; + var textVal = this._composer.$input.val(); + var cursorPosition = this._getSelectionPositions().start; + var leftString = textVal.substring(0, cursorPosition); + + function validateKeyword(delimiter, beginningOnly) { + // use position before delimiter because there should be whitespaces + // or line feed/carriage return before the delimiter + var beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1; + if (beginningOnly && beforeDelimiterPosition > 0) { + return false; + } + var searchStr = textVal.substring(beforeDelimiterPosition, cursorPosition); + // regex string start with delimiter or whitespace then delimiter + var pattern = "^"+delimiter+"|^\\s"+delimiter; + var regexStart = new RegExp(pattern, 'g'); + // trim any left whitespaces or the left line feed/ carriage return + // at the beginning of the string + searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, ''); + if (regexStart.test(searchStr) && searchStr.length) { + searchStr = searchStr.replace(pattern, ''); + return searchStr.indexOf(' ') < 0 && !/[\r\n]/.test(searchStr) ? + searchStr.replace(delimiter, '') : + false; + } + return false; + } + + this._activeListener = undefined; + for (var i = 0; i < this._listeners.length; i++) { + var listener = this._listeners[i]; + this._mentionWord = validateKeyword(listener.delimiter, listener.beginningOnly); + + if (this._mentionWord !== false) { + this._activeListener = listener; + break; + } + } + + if (this._activeListener) { + var mentionWord = this._mentionWord; + $.when(this._activeListener.fetchCallback(mentionWord)) + .then(function (suggestions) { + if (mentionWord === self._mentionWord) { + // update suggestions only if mentionWord didn't change + // in the meantime + self.set('mention_suggestions', suggestions); + } + }); + } else { + this.set('mention_suggestions', []); // close the dropdown + } + }, + /** + * Replaces mentions appearing in the string 's' by html links with proper + * redirection + * + * @param {string} s + * @returns {string} + */ + generateLinks: function (s) { + var self = this; + var baseHREF = session.url('/web'); + var linkParams = "href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'"; + var mentionLink = "<a " + linkParams + " >" + + "%s%s" + + "</a>"; + _.each(this._listeners, function (listener) { + if (!listener.generateLinks) { + return; + } + var selection = listener.selection; + if (selection.length) { + var matches = self._getMatch(s, listener); + var substrings = []; + var startIndex = 0; + for (var i = 0; i < matches.length; i++) { + var match = matches[i]; + var endIndex = match.index + match[0].length; + var selectionID = self._getSelectionID(match, selection); + // put back white spaces instead of non-breaking spaces in + // mention's name + var matchName = match[0].substring(1) + .replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' '); + var href = baseHREF + + _.str.sprintf("#model=%s&id=%s", listener.model, selectionID); + var processedText = _.str.sprintf(mentionLink, + href, + listener.redirectClassname, + selectionID, + listener.model, + listener.delimiter, + matchName); + var subtext = s.substring(startIndex, endIndex) + .replace(match[0], processedText); + substrings.push(subtext); + startIndex = endIndex; + } + substrings.push(s.substring(startIndex, s.length)); + s = substrings.join(''); + } + }); + return s; + }, + /** + * @param {integer} keycode + */ + propositionNavigation: function (keycode) { + var $active = this.$('.o_mention_proposition.active'); + if (keycode === $.ui.keyCode.ENTER) { + // selecting proposition + $active.click(); + } else { + // navigation in propositions + var $to; + if (keycode === $.ui.keyCode.DOWN) { + $to = $active.nextAll('.o_mention_proposition').first(); + } else if (keycode === $.ui.keyCode.UP) { + $to = $active.prevAll('.o_mention_proposition').first(); + } else if (keycode === $.ui.keyCode.TAB) { + $to = $active.nextAll('.o_mention_proposition').first(); + if (!$to.length) { + $to = $active.prevAll('.o_mention_proposition').last(); + } + } + if ($to && $to.length) { + $active.removeClass('active'); + $to.addClass('active'); + } + } + }, + /** + * Registers a new listener, described by an object containing the following + * keys + * + * @param {Object} listener + * @param {boolean} [listener.beginningOnly] true to enable autocomplete + * only at first position of input + * @param {char} [listener.delimiter] the mention delimiter + * @param {function} [listener.fetchCallback] the callback to fetch mention + * suggestions + * @param {boolean} [listener.generateLinks] true to wrap mentions in <a> + * links + * @param {string} [listener.model=''] (optional) the model used for + * redirection + * @param {string} [listener.redirectClassname=''] (optional) the classname + * of the <a> wrapping the mention + * @param {Array} [listener.selection=[]] (optional) initial mentions for + * each listener + * @param {string} [listener.suggestionTemplate] the template of + * suggestions' dropdown + */ + register: function (listener) { + this._listeners.push(_.defaults(listener, { + model: '', + redirectClassname: '', + selection: [], + })); + }, + resetSuggestions: function () { + this.set('mention_suggestions', []); + }, + resetSelections: function () { + _.each(this._listeners, function (listener) { + listener.selection = []; + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the matches (as RexExp.exec does) for the mention in the input + * text + * + * @private + * @param {string} inputText: the text to search matches + * @param {Object} listener: the listener for which we want to find a match + * @returns {Object[]} matches in the same format as RexExp.exec() + */ + _getMatch: function (inputText, listener) { + // create the regex of all mention's names + var names = _.pluck(listener.selection, 'name'); + var escapedNames = _.map(names, function (str) { + return "("+_.str.escapeRegExp(listener.delimiter+str)+")"; + }); + var regexStr = escapedNames.join('|'); + // extract matches + var result = []; + if (regexStr.length) { + var myRegexp = new RegExp(regexStr, 'g'); + var match = myRegexp.exec(inputText); + while (match !== null) { + result.push(match); + match = myRegexp.exec(inputText); + } + } + return result; + }, + /** + * @private + * @param {string[]} match + * @param {Object} selection + * @returns {Object} + */ + _getSelectionID: function (match, selection) { + return _.findWhere(selection, { name: match[0].slice(1) }).id; + }, + /** + * Get cursor position and selection + * + * @private + * @returns a current cursor position + */ + _getSelectionPositions: function () { + var InputElement = this._composer.$input.get(0); + return InputElement ? dom.getSelectionRange(InputElement) : { start: 0, end: 0 }; + }, + /** + * @private + */ + _renderSuggestions: function () { + var suggestions = []; + if (_.find(this.get('mention_suggestions'), _.isArray)) { + // Array of arrays -> Flatten and insert dividers between groups + var insertDivider = false; + _.each(this.get('mention_suggestions'), function (suggestionGroup) { + if (suggestionGroup.length > 0) { + if (insertDivider) { + suggestions.push({ divider: true }); + } + suggestions = suggestions.concat(suggestionGroup); + insertDivider = true; + } + }); + } else { + suggestions = this.get('mention_suggestions'); + } + if (suggestions.length) { + this.$el.html(QWeb.render(this._activeListener.suggestionTemplate, { + 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; + } + }, + /* + * Set cursor position + * + * @private + * @param {integer} pos + */ + _setCursorPosition: function (pos) { + this._composer.$input.each(function (index, elem) { + dom.setSelectionRange(elem, { start: pos, end: pos }); + }); + }, + /** + * @private + * @param {Object} selection + * @param {Array} inputMentions + * @returns {Array} + */ + _validateSelection: function (selection, inputMentions) { + var validatedSelection = []; + _.each(inputMentions, function (mention) { + var validatedMention = _.findWhere(selection, { name: mention.slice(1) }); + if (validatedMention) { + validatedSelection.push(validatedMention); + } + }); + return validatedSelection; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {KeyboardEvent} ev - to get a current selected mention from list + * and eventlistener. + */ + _onClickMentionItem: function (ev) { + ev.preventDefault(); + + var textInput = this._composer.$input.val(); + var id = $(ev.currentTarget).data('id'); + var suggestions = _.flatten(this.get('mention_suggestions')); + var selectedSuggestion = _.find(suggestions, function (s) { + return s.id === id; + }); + var substitution = selectedSuggestion.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 + selectedSuggestion.name = selectedSuggestion.name.replace(/ /g, NON_BREAKING_SPACE); + substitution = this._activeListener.delimiter + selectedSuggestion.name; + } + var getMentionIndex = function (matches, cursorPosition) { + for (var i = 0; i < matches.length; i++) { + if (cursorPosition <= matches[i].index) { + return i; + } + } + return i; + }; + + // add the selected suggestion to the list + if (this._activeListener.selection.length) { + // get mention matches (ordered by index in the text) + var matches = this._getMatch(textInput, this._activeListener); + var index = getMentionIndex(matches, this._getSelectionPositions().start); + this._activeListener.selection.splice(index, 0, selectedSuggestion); + } else { + this._activeListener.selection.push(selectedSuggestion); + } + + // update input text, and reset dropdown + var cursorPosition = this._getSelectionPositions().start; + var textLeft = textInput.substring(0, cursorPosition-(this._mentionWord.length+1)); + var textRight = textInput.substring(cursorPosition, textInput.length); + var textInputNew = textLeft + substitution + ' ' + textRight; + this._composer.$input.val(textInputNew); + this._setCursorPosition(textLeft.length+substitution.length+2); + this.set('mention_suggestions', []); + this._composer.focus(); // to trigger autoresize + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onHoverMentionProposition: function (ev) { + var $elem = $(ev.currentTarget); + this.$('.o_mention_proposition').removeClass('active'); + $elem.addClass('active'); + }, + +}); + +return MentionManager; + +}); |
