summaryrefslogtreecommitdiff
path: root/addons/mail
diff options
context:
space:
mode:
authorXavier Dubuc <xdu@odoo.com>2021-03-10 16:45:49 +0000
committerSébastien Theys <seb@odoo.com>2021-03-10 16:48:19 +0000
commitfbdec5fe29827b88ec0d1421f68716e1febefd89 (patch)
treeafbbc6fd6da16ace2af4c2992678657b814d0197 /addons/mail
parentf326ba2d14e4fdd811426c6381b7e5bc3c1d657b (diff)
[FIX] mail; hr: restore faster and more accurate mention suggestions
The code from v13 used to look for partners in JS before making the RPC, which was much faster, and also gave better results by returning partners that were already known and therefore more likely to be selected. The same logic is reintroduced here, and further improved to take into account all known partners and sort them according to the likeliness they will be selected based on various criteria. task-2413776 closes odoo/odoo#64161 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com> Co-authored-by: Sébastien Theys <seb@odoo.com>
Diffstat (limited to 'addons/mail')
-rw-r--r--addons/mail/models/mail_channel.py11
-rw-r--r--addons/mail/models/res_partner.py60
-rw-r--r--addons/mail/static/src/bugfix/bugfix.js2
-rw-r--r--addons/mail/static/src/components/composer/composer_tests.js79
-rw-r--r--addons/mail/static/src/components/composer_suggestion/composer_suggestion.js4
-rw-r--r--addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js16
-rw-r--r--addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml6
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.js9
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.xml2
-rw-r--r--addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml2
-rw-r--r--addons/mail/static/src/components/thread_view/thread_view_tests.js1
-rw-r--r--addons/mail/static/src/models/canned_response/canned_response.js78
-rw-r--r--addons/mail/static/src/models/channel_command/channel_command.js96
-rw-r--r--addons/mail/static/src/models/composer/composer.js1025
-rw-r--r--addons/mail/static/src/models/messaging_initializer/messaging_initializer.js3
-rw-r--r--addons/mail/static/src/models/partner/partner.js183
-rw-r--r--addons/mail/static/src/models/thread/thread.js121
-rw-r--r--addons/mail/static/src/models/user/user.js6
-rw-r--r--addons/mail/static/src/utils/utils.js14
19 files changed, 1222 insertions, 496 deletions
diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py
index 778b362a893..ab5033c4642 100644
--- a/addons/mail/models/mail_channel.py
+++ b/addons/mail/models/mail_channel.py
@@ -413,7 +413,14 @@ class Channel(models.Model):
self.filtered(lambda channel: channel.is_chat).mapped('channel_last_seen_partner_ids').sudo().write({'is_pinned': True})
- message = super(Channel, self.with_context(mail_create_nosubscribe=True)).message_post(message_type=message_type, moderation_status=moderation_status, **kwargs)
+ # mail_post_autofollow=False is necessary to prevent adding followers
+ # when using mentions in channels. Followers should not be added to
+ # channels, and especially not automatically (because channel membership
+ # should be managed with channel.partner instead).
+ # The current client code might be setting the key to True on sending
+ # message but it is only useful when targeting customers in chatter.
+ # This value should simply be set to False in channels no matter what.
+ message = super(Channel, self.with_context(mail_create_nosubscribe=True, mail_post_autofollow=False)).message_post(message_type=message_type, moderation_status=moderation_status, **kwargs)
# Notifies the message author when his message is pending moderation if required on channel.
# The fields "email_from" and "reply_to" are filled in automatically by method create in model mail.message.
@@ -1013,7 +1020,7 @@ class Channel(models.Model):
[('channel_partner_ids', 'in', [self.env.user.partner_id.id])]
])
])
- return self.search_read(domain, ['id', 'name', 'public'], limit=limit)
+ return self.search_read(domain, ['id', 'name', 'public', 'channel_type'], limit=limit)
@api.model
def channel_fetch_listeners(self, uuid):
diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py
index f139e483d58..ce1fcb35a23 100644
--- a/addons/mail/models/res_partner.py
+++ b/addons/mail/models/res_partner.py
@@ -6,6 +6,7 @@ import logging
from odoo import _, api, fields, models, tools
from odoo.addons.bus.models.bus_presence import AWAY_TIMER
from odoo.addons.bus.models.bus_presence import DISCONNECTION_TIMER
+from odoo.exceptions import AccessError
from odoo.osv import expression
_logger = logging.getLogger(__name__)
@@ -64,13 +65,20 @@ class Partner(models.Model):
def mail_partner_format(self):
self.ensure_one()
- return {
- "id": self.name_get()[0][0],
- "display_name": self.name_get()[0][1],
+ internal_users = self.user_ids - self.user_ids.filtered('share')
+ main_user = internal_users[0] if len(internal_users) else self.user_ids[0] if len(self.user_ids) else self.env['res.users']
+ res = {
+ "id": self.id,
+ "display_name": self.display_name,
"name": self.name,
+ "email": self.email,
"active": self.active,
"im_status": self.im_status,
+ "user_id": main_user.id,
}
+ if main_user:
+ res["is_internal_user"] = not main_user.share
+ return res
@api.model
def get_needaction_count(self):
@@ -99,36 +107,46 @@ class Partner(models.Model):
@api.model
def get_static_mention_suggestions(self):
- """ To be overwritten to return the id, name and email of partners used as static mention
- suggestions loaded once at webclient initialization and stored client side. """
- return []
+ """Returns static mention suggestions of partners, loaded once at
+ webclient initialization and stored client side.
+ By default all the internal users are returned.
+
+ The return format is a list of lists. The first level of list is an
+ arbitrary split that allows overrides to return their own list.
+ The second level of list is a list of partner data (as per returned by
+ `mail_partner_format()`).
+ """
+ suggestions = []
+ try:
+ suggestions.append([partner.mail_partner_format() for partner in self.env.ref('base.group_user').users.partner_id])
+ except AccessError:
+ pass
+ return suggestions
@api.model
- def get_mention_suggestions(self, search, limit=8):
+ def get_mention_suggestions(self, search, limit=8, channel_id=None):
""" Return 'limit'-first partners' id, name and email such that the name or email matches a
- 'search' string. Prioritize users, and then extend the research to all partners. """
+ 'search' string. Prioritize users, and then extend the research to all partners.
+ If channel_id is given, only members of this channel are returned.
+ """
search_dom = expression.OR([[('name', 'ilike', search)], [('email', 'ilike', search)]])
search_dom = expression.AND([[('active', '=', True)], search_dom])
- fields = ['id', 'name', 'email']
+ if channel_id:
+ search_dom = expression.AND([[('channel_ids', 'in', channel_id)], search_dom])
# Search users
domain = expression.AND([[('user_ids.id', '!=', False), ('user_ids.active', '=', True)], search_dom])
- users = self.search_read(domain, fields, limit=limit)
+ users = self.search(domain, limit=limit)
# Search partners if less than 'limit' users found
- partners = []
+ partners = self.env['res.partner']
if len(users) < limit:
- partners = self.search_read(search_dom, fields, limit=limit)
- # Remove duplicates
- partners = [p for p in partners if not len([u for u in users if u['id'] == p['id']])]
-
- # add OdooBot even if its partner is archived
- if len(partners) + len(users) < limit and "odoobot".startswith(search.lower()):
- odoobot = self.env.ref("base.partner_root")
- if not any(elem['id'] == odoobot.id for elem in partners):
- partners.append(odoobot.read(fields)[0])
+ partners = self.search(expression.AND([[('id', 'not in', users.ids)], search_dom]), limit=limit)
- return [users, partners]
+ return [
+ [partner.mail_partner_format() for partner in users],
+ [partner.mail_partner_format() for partner in partners],
+ ]
@api.model
def im_search(self, name, limit=20):
diff --git a/addons/mail/static/src/bugfix/bugfix.js b/addons/mail/static/src/bugfix/bugfix.js
index 4a8bb890bff..20800dc7046 100644
--- a/addons/mail/static/src/bugfix/bugfix.js
+++ b/addons/mail/static/src/bugfix/bugfix.js
@@ -61,7 +61,7 @@ function executeNextInQueue() {
return;
}
const { component, func } = executionQueue.shift();
- if (!component.__owl__.isDestroyed) {
+ if (component.__owl__.status !== 5 /* DESTROYED */) {
func();
}
executeNextInQueue();
diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js
index 98f969438eb..77a4f2adb4c 100644
--- a/addons/mail/static/src/components/composer/composer_tests.js
+++ b/addons/mail/static/src/components/composer/composer_tests.js
@@ -333,8 +333,6 @@ QUnit.test('use a canned response', async function (assert) {
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, ":");
- });
- await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -387,10 +385,8 @@ QUnit.test('use a canned response some text', async function (assert) {
"bluhbluh ",
"text content of composer should have content"
);
- await afterNextRender(() =>
- document.execCommand('insertText', false, ":")
- );
await afterNextRender(() => {
+ document.execCommand('insertText', false, ":");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -437,8 +433,6 @@ QUnit.test('add an emoji after a canned response', async function (assert) {
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, ":");
- });
- await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -531,8 +525,6 @@ QUnit.test('mention a channel', async function (assert) {
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "#");
- });
- await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -584,10 +576,8 @@ QUnit.test('mention a channel after some text', async function (assert) {
"bluhbluh ",
"text content of composer should have content"
);
- await afterNextRender(() =>
- document.execCommand('insertText', false, "#")
- );
await afterNextRender(() => {
+ document.execCommand('insertText', false, "#");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -633,8 +623,6 @@ QUnit.test('add an emoji after a channel mention', async function (assert) {
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "#");
- });
- await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -739,8 +727,6 @@ QUnit.test('use a command for a specific channel type', async function (assert)
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "/");
- });
- await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -828,10 +814,8 @@ QUnit.test('use a command after some text', async function (assert) {
"bluhbluh ",
"text content of composer should have content"
);
- await afterNextRender(() =>
- document.execCommand('insertText', false, "/")
- );
await afterNextRender(() => {
+ document.execCommand('insertText', false, "/");
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -883,8 +867,6 @@ QUnit.test('add an emoji after a command', async function (assert) {
await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`).focus();
document.execCommand('insertText', false, "/");
- });
- await afterNextRender(() => {
document.querySelector(`.o_ComposerTextInput_textarea`)
.dispatchEvent(new window.KeyboardEvent('keydown'));
document.querySelector(`.o_ComposerTextInput_textarea`)
@@ -2041,61 +2023,6 @@ QUnit.test('send message only once when enter is pressed twice quickly', async f
);
});
-QUnit.test("mentioned partners should not be notified if they are not member of current channel", async function (assert) {
- assert.expect(4);
-
- this.data['res.partner'].records.push({
- email: "testpartner@example.com",
- name: "TestPartner",
- });
- this.data['mail.channel'].records.push({
- id: 10,
- members: [this.data.currentPartnerId],
- });
- await this.start({
- async mockRPC(route, args) {
- if (args.model === 'mail.channel' && args.method === 'message_post') {
- assert.step('message_post');
- assert.strictEqual(
- args.kwargs.partner_ids.length,
- 0,
- "message_post should not contain mentioned partners that are not members of channel"
- );
- }
- return this._super(...arguments);
- },
- });
- const thread = this.env.models['mail.thread'].findFromIdentifyingData({
- id: 10,
- model: 'mail.channel',
- });
- await this.createComposerComponent(thread.composer);
- await afterNextRender(() => {
- document.querySelector(`.o_ComposerTextInput_textarea`).focus();
- document.execCommand('insertText', false, "@");
- document.querySelector(`.o_ComposerTextInput_textarea`)
- .dispatchEvent(new window.KeyboardEvent('keydown'));
- document.querySelector(`.o_ComposerTextInput_textarea`)
- .dispatchEvent(new window.KeyboardEvent('keyup'));
- });
- await afterNextRender(() => {
- document.querySelector(`.o_ComposerTextInput_textarea`).focus();
- document.execCommand('insertText', false, "Test");
- document.querySelector(`.o_ComposerTextInput_textarea`)
- .dispatchEvent(new window.KeyboardEvent('keydown'));
- document.querySelector(`.o_ComposerTextInput_textarea`)
- .dispatchEvent(new window.KeyboardEvent('keyup'));
- });
- await afterNextRender(() => document.querySelector('.o_ComposerSuggestion').click());
- assert.strictEqual(
- document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "),
- "@TestPartner ",
- "text content of composer should have mentioned partner + additional whitespace afterwards"
- );
- await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
- assert.verifySteps(['message_post'], "the message should be posted");
-});
-
QUnit.test('[technical] does not crash when an attachment is removed before its upload starts', async function (assert) {
// Uploading multiple files uploads attachments one at a time, this test
// ensures that there is no crash when an attachment is destroyed before its
diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js
index 4ae48db623b..da54ab54311 100644
--- a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js
+++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js
@@ -116,9 +116,7 @@ class ComposerSuggestion extends Component {
*/
_onClick(ev) {
ev.preventDefault();
- this.composer.update({
- [this.composer.activeSuggestedRecordName]: [['link', this.record]],
- });
+ this.composer.update({ activeSuggestedRecord: [['link', this.record]] });
this.composer.insertSuggestion();
this.composer.closeSuggestions();
this.trigger('o-composer-suggestion-clicked');
diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js
index ea2153dbf6f..23f08399594 100644
--- a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js
+++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js
@@ -22,23 +22,23 @@ class ComposerSuggestionList extends Component {
const activeSuggestedRecord = composer
? composer.activeSuggestedRecord
: undefined;
- const extraSuggestedRecordsList = composer
- ? composer.extraSuggestedRecordsList
+ const extraSuggestedRecords = composer
+ ? composer.extraSuggestedRecords
: [];
- const mainSuggestedRecordsList = composer
- ? composer.mainSuggestedRecordsList
+ const mainSuggestedRecords = composer
+ ? composer.mainSuggestedRecords
: [];
return {
activeSuggestedRecord,
composer,
composerSuggestionModelName: composer && composer.suggestionModelName,
- extraSuggestedRecordsList: extraSuggestedRecordsList,
- mainSuggestedRecordsList: mainSuggestedRecordsList,
+ extraSuggestedRecords,
+ mainSuggestedRecords,
};
}, {
compareDepth: {
- extraSuggestedRecordsList: 1,
- mainSuggestedRecordsList: 1,
+ extraSuggestedRecords: 1,
+ mainSuggestedRecords: 1,
},
});
}
diff --git a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml
index 8bcd87c2d07..9747109a994 100644
--- a/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml
+++ b/addons/mail/static/src/components/composer_suggestion_list/composer_suggestion_list.xml
@@ -5,7 +5,7 @@
<div class="o_ComposerSuggestionList" t-att-class="{ 'o-lowPosition': props.isBelow }">
<div class="o_ComposerSuggestionList_drop" t-att-class="{ 'dropdown': props.isBelow, 'dropup': !props.isBelow }">
<div class="o_ComposerSuggestionList_list dropdown-menu show">
- <t t-foreach="composer.mainSuggestedRecordsList" t-as="record" t-key="record.localId">
+ <t t-foreach="composer.mainSuggestedRecords" t-as="record" t-key="record.localId">
<ComposerSuggestion
composerLocalId="props.composerLocalId"
isActive="record === composer.activeSuggestedRecord"
@@ -13,10 +13,10 @@
recordLocalId="record.localId"
/>
</t>
- <t t-if="composer.mainSuggestedRecordsList.length > 0 and composer.extraSuggestedRecordsList.length > 0">
+ <t t-if="composer.mainSuggestedRecords.length > 0 and composer.extraSuggestedRecords.length > 0">
<div role="separator" class="dropdown-divider"/>
</t>
- <t t-foreach="composer.extraSuggestedRecordsList" t-as="record" t-key="record.localId">
+ <t t-foreach="composer.extraSuggestedRecords" t-as="record" t-key="record.localId">
<ComposerSuggestion
composerLocalId="props.composerLocalId"
isActive="record === composer.activeSuggestedRecord"
diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js
index 6c0705ee40a..7c11c7f5790 100644
--- a/addons/mail/static/src/components/composer_text_input/composer_text_input.js
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js
@@ -195,6 +195,14 @@ class ComposerTextInput extends Component {
/**
* @private
*/
+ _onClickTextarea() {
+ // clicking might change the cursor position
+ this.saveStateInStore();
+ }
+
+ /**
+ * @private
+ */
_onFocusinTextarea() {
this.composer.focus();
this.trigger('o-focusin-composer');
@@ -372,7 +380,6 @@ class ComposerTextInput extends Component {
// Otherwise, check if a mention is typed
default:
this.saveStateInStore();
- this.composer.detectSuggestionDelimiter();
}
}
diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml
index 5b120fabd90..a14fdee8712 100644
--- a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml
@@ -10,7 +10,7 @@
isBelow="props.hasMentionSuggestionsBelowPosition"
/>
</t>
- <textarea class="o_ComposerTextInput_textarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-att-placeholder="textareaPlaceholder" t-on-focusin="_onFocusinTextarea" t-on-focusout="_onFocusoutTextarea" t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
+ <textarea class="o_ComposerTextInput_textarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-att-placeholder="textareaPlaceholder" t-on-click="_onClickTextarea" t-on-focusin="_onFocusinTextarea" t-on-focusout="_onFocusoutTextarea" t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
<!--
This is an invisible textarea used to compute the composer
height based on the text content. We need it to downsize
diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml
index 9b52651f0c6..74aace2181b 100644
--- a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml
+++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml
@@ -16,7 +16,7 @@
<div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name o-editable">
<EditableText
class="o_DiscussSidebarItem_nameInput"
- placeholder="thread.correspondent.name"
+ placeholder="thread.correspondent ? thread.correspondent.name : thread.name"
value="thread.displayName"
t-on-o-cancel="_onCancelRenaming"
t-on-o-clicked="_onClickedEditableText"
diff --git a/addons/mail/static/src/components/thread_view/thread_view_tests.js b/addons/mail/static/src/components/thread_view/thread_view_tests.js
index f436ddf582d..58a059890e3 100644
--- a/addons/mail/static/src/components/thread_view/thread_view_tests.js
+++ b/addons/mail/static/src/components/thread_view/thread_view_tests.js
@@ -1463,6 +1463,7 @@ QUnit.test('mention 2 different channels that have the same name', async functio
{
id: 11,
name: "my channel",
+ public: 'public', // mentioning another channel is possible only from a public channel
},
{
id: 12,
diff --git a/addons/mail/static/src/models/canned_response/canned_response.js b/addons/mail/static/src/models/canned_response/canned_response.js
index 19375fd7ccd..41e917d2ac4 100644
--- a/addons/mail/static/src/models/canned_response/canned_response.js
+++ b/addons/mail/static/src/models/canned_response/canned_response.js
@@ -3,10 +3,86 @@ odoo.define('mail/static/src/models/canned_response/canned_response.js', functio
const { registerNewModel } = require('mail/static/src/model/model_core.js');
const { attr } = require('mail/static/src/model/model_field.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
function factory(dependencies) {
- class CannedResponse extends dependencies['mail.model'] {}
+ class CannedResponse extends dependencies['mail.model'] {
+
+ /**
+ * Fetches canned responses matching the given search term to extend the
+ * JS knowledge and to update the suggestion list accordingly.
+ *
+ * In practice all canned responses are already fetched at init so this
+ * method does nothing.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static fetchSuggestions(searchTerm, { thread } = {}) {}
+
+ /**
+ * Returns a sort function to determine the order of display of canned
+ * responses in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const cleanedAName = cleanSearchTerm(a.source || '');
+ const cleanedBName = cleanSearchTerm(b.source || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /*
+ * Returns canned responses that match the given search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.canned_response[], mail.canned_response[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return [this.env.messaging.cannedResponses.filter(cannedResponse =>
+ cleanSearchTerm(cannedResponse.source).includes(cleanedSearchTerm)
+ )];
+ }
+
+ /**
+ * Returns the text that identifies this canned response in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.substitution;
+ }
+
+ }
CannedResponse.fields = {
id: attr(),
diff --git a/addons/mail/static/src/models/channel_command/channel_command.js b/addons/mail/static/src/models/channel_command/channel_command.js
index 12054c1a35b..728acdb9eab 100644
--- a/addons/mail/static/src/models/channel_command/channel_command.js
+++ b/addons/mail/static/src/models/channel_command/channel_command.js
@@ -3,10 +3,104 @@ odoo.define('mail/static/src/models/channel_command/channel_command.js', functio
const { registerNewModel } = require('mail/static/src/model/model_core.js');
const { attr } = require('mail/static/src/model/model_field.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
function factory(dependencies) {
- class ChannelCommand extends dependencies['mail.model'] {}
+ class ChannelCommand extends dependencies['mail.model'] {
+
+ /**
+ * Fetches channel commands matching the given search term to extend the
+ * JS knowledge and to update the suggestion list accordingly.
+ *
+ * In practice all channel commands are already fetched at init so this
+ * method does nothing.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static fetchSuggestions(searchTerm, { thread } = {}) {}
+
+ /**
+ * Returns a sort function to determine the order of display of channel
+ * commands in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const isATypeSpecific = a.channel_types;
+ const isBTypeSpecific = b.channel_types;
+ if (isATypeSpecific && !isBTypeSpecific) {
+ return -1;
+ }
+ if (!isATypeSpecific && isBTypeSpecific) {
+ return 1;
+ }
+ const cleanedAName = cleanSearchTerm(a.name || '');
+ const cleanedBName = cleanSearchTerm(b.name || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /**
+ * Returns channel commands that match the given search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.channel_command[], mail.channel_command[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ if (thread.model !== 'mail.channel') {
+ // channel commands are channel specific
+ return [[]];
+ }
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return [this.env.messaging.commands.filter(command => {
+ if (!cleanSearchTerm(command.name).includes(cleanedSearchTerm)) {
+ return false;
+ }
+ if (command.channel_types) {
+ return command.channel_types.includes(thread.channel_type);
+ }
+ return true;
+ })];
+ }
+
+ /**
+ * Returns the text that identifies this channel command in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.name;
+ }
+
+ }
ChannelCommand.fields = {
/**
diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js
index 8caa8498856..3f6ac94a256 100644
--- a/addons/mail/static/src/models/composer/composer.js
+++ b/addons/mail/static/src/models/composer/composer.js
@@ -4,6 +4,7 @@ odoo.define('mail/static/src/models/composer/composer.js', function (require) {
const emojis = require('mail.emojis');
const { registerNewModel } = require('mail/static/src/model/model_core.js');
const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js');
+const { clear } = require('mail/static/src/model/model_field_command.js');
const mailUtils = require('mail.utils');
const {
@@ -48,80 +49,18 @@ function factory(dependencies) {
// Public
//----------------------------------------------------------------------
+ /**
+ * Closes the suggestion list.
+ */
closeSuggestions() {
- if (this.activeSuggestedRecordName) {
- this.update({
- [this.activeSuggestedRecordName]: [['unlink']],
- });
- }
- if (this.extraSuggestedRecordsListName) {
- this.update({
- [this.extraSuggestedRecordsListName]: [['unlink-all']],
- });
- }
- if (this.mainSuggestedRecordsListName) {
- this.update({
- [this.mainSuggestedRecordsListName]: [['unlink-all']],
- });
- }
- this.update({
- activeSuggestedRecordName: "",
- extraSuggestedRecordsListName: "",
- mainSuggestedRecordsListName: "",
- suggestionDelimiter: "",
- });
+ this.update({ suggestionDelimiterPosition: clear() });
}
- detectSuggestionDelimiter() {
- if (this.textInputCursorStart !== this.textInputCursorEnd) {
- return;
- }
- const lastInputChar = this.textInputContent.substring(this.textInputCursorStart - 1, this.textInputCursorStart);
- const suggestionDelimiters = ['@', ':', '#', '/'];
- if (suggestionDelimiters.includes(lastInputChar) && !this.hasSuggestions) {
- this.update({ suggestionDelimiter: lastInputChar });
- }
- const mentionKeyword = this._validateMentionKeyword(false);
- if (mentionKeyword !== false) {
- switch (this.suggestionDelimiter) {
- case '@':
- this.update({
- activeSuggestedRecordName: "activeSuggestedPartner",
- extraSuggestedRecordsListName: "extraSuggestedPartners",
- mainSuggestedRecordsListName: "mainSuggestedPartners",
- suggestionModelName: "mail.partner",
- });
- this._executeOrQueueFunction(() => this._updateSuggestedPartners(mentionKeyword));
- break;
- case ':':
- this.update({
- activeSuggestedRecordName: "activeSuggestedCannedResponse",
- mainSuggestedRecordsListName: "suggestedCannedResponses",
- suggestionModelName: "mail.canned_response",
- });
- this._executeOrQueueFunction(() => this._updateSuggestedCannedResponses(mentionKeyword));
- break;
- case '/':
- this.update({
- activeSuggestedRecordName: "activeSuggestedChannelCommand",
- mainSuggestedRecordsListName: "suggestedChannelCommands",
- suggestionModelName: "mail.channel_command",
- });
- this._executeOrQueueFunction(() => this._updateSuggestedChannelCommands(mentionKeyword));
- break;
- case '#':
- this.update({
- activeSuggestedRecordName: "activeSuggestedChannel",
- mainSuggestedRecordsListName: "suggestedChannels",
- suggestionModelName: "mail.thread",
- });
- this._executeOrQueueFunction(() => this._updateSuggestedChannels(mentionKeyword));
- break;
- }
- } else {
- this.closeSuggestions();
- }
- }
+ /**
+ * @deprecated what this method used to do is now automatically computed
+ * based on composer state
+ */
+ async detectSuggestionDelimiter() {}
/**
* Hides the composer, which only makes sense if the composer is
@@ -159,11 +98,19 @@ function factory(dependencies) {
this.textInputCursorEnd,
this.textInputContent.length
);
+ let suggestionDelimiterPosition = this.suggestionDelimiterPosition;
+ if (
+ suggestionDelimiterPosition !== undefined &&
+ suggestionDelimiterPosition >= this.textInputCursorStart
+ ) {
+ suggestionDelimiterPosition = suggestionDelimiterPosition + content.length;
+ }
this.update({
isLastStateChangeProgrammatic: true,
+ suggestionDelimiterPosition,
textInputContent: partA + content + partB,
- textInputCursorStart: this.textInputCursorStart + content.length,
textInputCursorEnd: this.textInputCursorStart + content.length,
+ textInputCursorStart: this.textInputCursorStart + content.length,
});
}
@@ -171,7 +118,7 @@ function factory(dependencies) {
const cursorPosition = this.textInputCursorStart;
let textLeft = this.textInputContent.substring(
0,
- this.textInputContent.substring(0, cursorPosition).lastIndexOf(this.suggestionDelimiter) + 1
+ this.suggestionDelimiterPosition + 1
);
let textRight = this.textInputContent.substring(
cursorPosition,
@@ -180,40 +127,32 @@ function factory(dependencies) {
if (this.suggestionDelimiter === ':') {
textLeft = this.textInputContent.substring(
0,
- this.textInputContent.substring(0, cursorPosition).lastIndexOf(this.suggestionDelimiter)
+ this.suggestionDelimiterPosition
);
textRight = this.textInputContent.substring(
cursorPosition,
this.textInputContent.length
);
}
- let recordReplacement = "";
- switch (this.activeSuggestedRecordName) {
- case 'activeSuggestedCannedResponse':
- recordReplacement = this[this.activeSuggestedRecordName].substitution;
- break;
- case 'activeSuggestedChannel':
- recordReplacement = this[this.activeSuggestedRecordName].name;
- this.update({
- mentionedChannels: [['link', this[this.activeSuggestedRecordName]]],
- });
- break;
- case 'activeSuggestedChannelCommand':
- recordReplacement = this[this.activeSuggestedRecordName].name;
- break;
- case 'activeSuggestedPartner':
- recordReplacement = this[this.activeSuggestedRecordName].name;
- this.update({
- mentionedPartners: [['link', this[this.activeSuggestedRecordName]]],
- });
- break;
- }
- this.update({
+ const recordReplacement = this.activeSuggestedRecord.getMentionText();
+ const updateData = {
isLastStateChangeProgrammatic: true,
textInputContent: textLeft + recordReplacement + ' ' + textRight,
textInputCursorEnd: textLeft.length + recordReplacement.length + 1,
textInputCursorStart: textLeft.length + recordReplacement.length + 1,
- });
+ };
+ // Specific cases for channel and partner mentions: the message with
+ // the mention will appear in the target channel, or be notified to
+ // the target partner.
+ switch (this.activeSuggestedRecord.constructor.modelName) {
+ case 'mail.thread':
+ Object.assign(updateData, { mentionedChannels: [['link', this.activeSuggestedRecord]] });
+ break;
+ case 'mail.partner':
+ Object.assign(updateData, { mentionedPartners: [['link', this.activeSuggestedRecord]] });
+ break;
+ }
+ this.update(updateData);
}
/**
@@ -221,10 +160,6 @@ function factory(dependencies) {
* @returns {mail.partner[]}
*/
_computeRecipients() {
- if (this.thread && this.thread.model === 'mail.channel') {
- // prevent from notifying/adding to followers non-members
- return [['unlink-all']];
- }
const recipients = [...this.mentionedPartners];
if (this.thread && !this.isLog) {
for (const recipient of this.thread.suggestedRecipientInfoList) {
@@ -383,95 +318,60 @@ function factory(dependencies) {
}
}
+ /**
+ * Sets the first suggestion as active. Main and extra records are
+ * considered together.
+ */
setFirstSuggestionActive() {
- if (!this[this.mainSuggestedRecordsListName][0]) {
- if (!this[this.extraSuggestedRecordsListName][0]) {
- return;
- }
- this.update({
- [this.activeSuggestedRecordName]: [['link', this[this.extraSuggestedRecordsListName][0]]],
- });
- } else {
- this.update({
- [this.activeSuggestedRecordName]: [['link', this[this.mainSuggestedRecordsListName][0]]],
- });
- }
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const firstRecord = suggestedRecords[0];
+ this.update({ activeSuggestedRecord: [['link', firstRecord]] });
}
+ /**
+ * Sets the last suggestion as active. Main and extra records are
+ * considered together.
+ */
setLastSuggestionActive() {
- if (this[this.extraSuggestedRecordsListName].length === 0) {
- if (this[this.mainSuggestedRecordsListName].length === 0) {
- return;
- }
- this.update({
- [this.activeSuggestedRecordName]: [[
- 'link',
- this[this.mainSuggestedRecordsListName][this[this.mainSuggestedRecordsListName].length - 1]
- ]],
- });
- return;
- }
- this.update({
- [this.activeSuggestedRecordName]: [[
- 'link',
- this[this.extraSuggestedRecordsListName][this[this.extraSuggestedRecordsListName].length - 1]
- ]],
- });
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const { length, [length - 1]: lastRecord } = suggestedRecords;
+ this.update({ activeSuggestedRecord: [['link', lastRecord]] });
}
+ /**
+ * Sets the next suggestion as active. Main and extra records are
+ * considered together.
+ */
setNextSuggestionActive() {
- const fullList = this.extraSuggestedRecordsListName ?
- this[this.mainSuggestedRecordsListName].concat(this[this.extraSuggestedRecordsListName]) :
- this[this.mainSuggestedRecordsListName];
- if (fullList.length === 0) {
- return;
- }
- const activeElementIndex = fullList.findIndex(
- suggestion => suggestion === this[this.activeSuggestedRecordName]
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const activeElementIndex = suggestedRecords.findIndex(
+ suggestion => suggestion === this.activeSuggestedRecord
);
- if (activeElementIndex !== fullList.length - 1) {
- this.update({
- [this.activeSuggestedRecordName]: [[
- 'link',
- fullList[activeElementIndex + 1]
- ]],
- });
- } else {
- this.update({
- [this.activeSuggestedRecordName]: [['link', fullList[0]]],
- });
+ if (activeElementIndex === suggestedRecords.length - 1) {
+ // loop when reaching the end of the list
+ this.setFirstSuggestionActive();
+ return;
}
+ const nextRecord = suggestedRecords[activeElementIndex + 1];
+ this.update({ activeSuggestedRecord: [['link', nextRecord]] });
}
+ /**
+ * Sets the previous suggestion as active. Main and extra records are
+ * considered together.
+ */
setPreviousSuggestionActive() {
- const fullList = this.extraSuggestedRecordsListName ?
- this[this.mainSuggestedRecordsListName].concat(this[this.extraSuggestedRecordsListName]) :
- this[this.mainSuggestedRecordsListName];
- if (fullList.length === 0) {
- return;
- }
- const activeElementIndex = fullList.findIndex(
- suggestion => suggestion === this[this.activeSuggestedRecordName]
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const activeElementIndex = suggestedRecords.findIndex(
+ suggestion => suggestion === this.activeSuggestedRecord
);
- if (activeElementIndex === -1) {
- this.update({
- [this.activeSuggestedRecordName]: [['link', fullList[0]]]
- });
- } else if (activeElementIndex !== 0) {
- this.update({
- [this.activeSuggestedRecordName]: [[
- 'link',
- fullList[activeElementIndex - 1]
- ]],
- });
- } else {
- this.update({
- [this.activeSuggestedRecordName]: [[
- 'link',
- fullList[fullList.length - 1]
- ]],
- });
+ if (activeElementIndex === 0) {
+ // loop when reaching the start of the list
+ this.setLastSuggestionActive();
+ return;
}
+ const previousRecord = suggestedRecords[activeElementIndex - 1];
+ this.update({ activeSuggestedRecord: [['link', previousRecord]] });
}
//----------------------------------------------------------------------
@@ -479,11 +379,96 @@ function factory(dependencies) {
//----------------------------------------------------------------------
/**
+ * @deprecated
+ * @private
+ * @returns {mail.canned_response}
+ */
+ _computeActiveSuggestedCannedResponse() {
+ if (this.suggestionDelimiter === ':' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.thread}
+ */
+ _computeActiveSuggestedChannel() {
+ if (this.suggestionDelimiter === '#' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.channel_command}
+ */
+ _computeActiveSuggestedChannelCommand() {
+ if (this.suggestionDelimiter === '/' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.partner}
+ */
+ _computeActiveSuggestedPartner() {
+ if (this.suggestionDelimiter === '@' && this.activeSuggestedRecord) {
+ return [['link', this.activeSuggestedRecord]];
+ }
+ return [['unlink']];
+ }
+
+ /**
+ * Clears the active suggested record on closing mentions or adapt it if
+ * the active current record is no longer part of the suggestions.
+ *
* @private
* @returns {mail.model}
*/
_computeActiveSuggestedRecord() {
- return this[this.activeSuggestedRecordName];
+ if (
+ this.mainSuggestedRecords.length === 0 &&
+ this.extraSuggestedRecords.length === 0
+ ) {
+ return [['unlink']];
+ }
+ if (
+ this.mainSuggestedRecords.includes(this.activeSuggestedRecord) ||
+ this.extraSuggestedRecords.includes(this.activeSuggestedRecord)
+ ) {
+ return;
+ }
+ const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
+ const firstRecord = suggestedRecords[0];
+ return [['link', firstRecord]];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeActiveSuggestedRecordName() {
+ switch (this.suggestionDelimiter) {
+ case '@':
+ return "activeSuggestedPartner";
+ case ':':
+ return "activeSuggestedCannedResponse";
+ case '/':
+ return "activeSuggestedChannelCommand";
+ case '#':
+ return "activeSuggestedChannel";
+ default:
+ return clear();
+ }
}
/**
@@ -498,25 +483,51 @@ function factory(dependencies) {
}
/**
- * Ensure extraSuggestedPartners does not contain any partner already
- * present in mainSuggestedPartners. This is necessary for the
- * consistency of suggestion list.
- *
+ * @deprecated
* @private
* @returns {mail.partner[]}
*/
_computeExtraSuggestedPartners() {
- return [['unlink', this.mainSuggestedPartners]];
+ if (this.suggestionDelimiter === '@') {
+ return [['replace', this.extraSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * Clears the extra suggested record on closing mentions, and ensures
+ * the extra list does not contain any element already present in the
+ * main list, which is a requirement for the navigation process.
+ *
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeExtraSuggestedRecords() {
+ if (this.suggestionDelimiterPosition === undefined) {
+ return [['unlink-all']];
+ }
+ return [['unlink', this.mainSuggestedRecords]];
}
/**
+ * @deprecated
* @private
* @returns {mail.model[]}
*/
_computeExtraSuggestedRecordsList() {
- return this.extraSuggestedRecordsListName
- ? this[this.extraSuggestedRecordsListName]
- : [];
+ return this.extraSuggestedRecords;
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeExtraSuggestedRecordsListName() {
+ if (this.suggestionDelimiter === '@') {
+ return "extraSuggestedPartners";
+ }
+ return clear();
}
/**
@@ -524,9 +535,7 @@ function factory(dependencies) {
* @return {boolean}
*/
_computeHasSuggestions() {
- const hasMainSuggestedRecordsList = this.mainSuggestedRecordsListName ? this[this.mainSuggestedRecordsListName].length > 0 : false;
- const hasExtraSuggestedRecordsList = this.extraSuggestedRecordsListName ? this[this.extraSuggestedRecordsListName].length > 0 : false;
- return hasMainSuggestedRecordsList || hasExtraSuggestedRecordsList;
+ return this.mainSuggestedRecords.length > 0 || this.extraSuggestedRecords.length > 0;
}
/**
@@ -538,13 +547,56 @@ function factory(dependencies) {
}
/**
+ * @deprecated
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeMainSuggestedPartners() {
+ if (this.suggestionDelimiter === '@') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * Clears the main suggested record on closing mentions.
+ *
+ * @private
+ * @returns {mail.model[]}
+ */
+ _computeMainSuggestedRecords() {
+ if (this.suggestionDelimiterPosition === undefined) {
+ return [['unlink-all']];
+ }
+ }
+
+ /**
+ * @deprecated
* @private
* @returns {mail.model[]}
*/
_computeMainSuggestedRecordsList() {
- return this.mainSuggestedRecordsListName
- ? this[this.mainSuggestedRecordsListName]
- : [];
+ return this.mainSuggestedRecords;
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {string}
+ */
+ _computeMainSuggestedRecordsListName() {
+ switch (this.suggestionDelimiter) {
+ case '@':
+ return "mainSuggestedPartners";
+ case ':':
+ return "suggestedCannedResponses";
+ case '/':
+ return "suggestedChannelCommands";
+ case '#':
+ return "suggestedChannels";
+ default:
+ return clear();
+ }
}
/**
@@ -600,6 +652,132 @@ function factory(dependencies) {
}
/**
+ * @deprecated
+ * @private
+ * @returns {mail.canned_response[]}
+ */
+ _computeSuggestedCannedResponses() {
+ if (this.suggestionDelimiter === ':') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.thread[]}
+ */
+ _computeSuggestedChannels() {
+ if (this.suggestionDelimiter === '#') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeSuggestionDelimiter() {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionDelimiterPosition >= this.textInputContent.length
+ ) {
+ return clear();
+ }
+ return this.textInputContent[this.suggestionDelimiterPosition];
+ }
+
+ /**
+ * @private
+ * @returns {integer}
+ */
+ _computeSuggestionDelimiterPosition() {
+ if (this.textInputCursorStart !== this.textInputCursorEnd) {
+ // avoid interfering with multi-char selection
+ return clear();
+ }
+ const candidatePositions = [];
+ // keep the current delimiter if it is still valid
+ if (
+ this.suggestionDelimiterPosition !== undefined &&
+ this.suggestionDelimiterPosition < this.textInputCursorStart
+ ) {
+ candidatePositions.push(this.suggestionDelimiterPosition);
+ }
+ // consider the char before the current cursor position if the
+ // current delimiter is no longer valid (or if there is none)
+ if (this.textInputCursorStart > 0) {
+ candidatePositions.push(this.textInputCursorStart - 1);
+ }
+ const suggestionDelimiters = ['@', ':', '#', '/'];
+ for (const candidatePosition of candidatePositions) {
+ if (
+ candidatePosition < 0 ||
+ candidatePosition >= this.textInputContent.length
+ ) {
+ continue;
+ }
+ const candidateChar = this.textInputContent[candidatePosition];
+ if (!suggestionDelimiters.includes(candidateChar)) {
+ continue;
+ }
+ const charBeforeCandidate = this.textInputContent[candidatePosition - 1];
+ if (charBeforeCandidate && !/\s/.test(charBeforeCandidate)) {
+ continue;
+ }
+ return candidatePosition;
+ }
+ return clear();
+ }
+
+ /**
+ * @deprecated
+ * @private
+ * @returns {mail.channel_command[]}
+ */
+ _computeSuggestedChannelCommands() {
+ if (this.suggestionDelimiter === '/') {
+ return [['replace', this.mainSuggestedRecords]];
+ }
+ return [['unlink-all']];
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeSuggestionModelName() {
+ switch (this.suggestionDelimiter) {
+ case '@':
+ return 'mail.partner';
+ case ':':
+ return 'mail.canned_response';
+ case '/':
+ return 'mail.channel_command';
+ case '#':
+ return 'mail.thread';
+ default:
+ return clear();
+ }
+ }
+
+ /**
+ * @private
+ * @returns {string}
+ */
+ _computeSuggestionSearchTerm() {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionDelimiterPosition >= this.textInputCursorStart
+ ) {
+ return clear();
+ }
+ return this.textInputContent.substring(this.suggestionDelimiterPosition + 1, this.textInputCursorStart);
+ }
+
+ /**
* Executes the given async function, only when the last function
* executed by this method terminates. If there is already a pending
* function it is replaced by the new one. This ensures the result of
@@ -718,10 +896,44 @@ function factory(dependencies) {
}
/**
+ * Updates the suggestion state based on the currently saved composer
+ * state (in particular content and cursor position).
+ *
+ * @private
+ */
+ _onChangeUpdateSuggestionList() {
+ // Update the suggestion list immediately for a reactive UX...
+ this._updateSuggestionList();
+ // ...and then update it again after the server returned data.
+ this._executeOrQueueFunction(async () => {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionSearchTerm === undefined ||
+ !this.suggestionModelName
+ ) {
+ // ignore obsolete call
+ return;
+ }
+ const Model = this.env.models[this.suggestionModelName];
+ const searchTerm = this.suggestionSearchTerm;
+ await this.async(() => Model.fetchSuggestions(searchTerm, { thread: this.thread }));
+ this._updateSuggestionList();
+ if (
+ this.suggestionSearchTerm &&
+ this.suggestionSearchTerm === searchTerm &&
+ this.suggestionModelName &&
+ this.env.models[this.suggestionModelName] === Model &&
+ !this.hasSuggestions
+ ) {
+ this.closeSuggestions();
+ }
+ });
+ }
+
+ /**
* @private
*/
_reset() {
- this.closeSuggestions();
this.update({
attachments: [['unlink-all']],
isLastStateChangeProgrammatic: true,
@@ -735,141 +947,41 @@ function factory(dependencies) {
}
/**
+ * Updates the current suggestion list. This method should be called
+ * whenever the UI has to be refreshed following change in state.
+ *
+ * This method should ideally be a compute, but its dependencies are
+ * currently too complex to express due to accessing plenty of fields
+ * from all records of dynamic models.
+ *
* @private
- * @param {string} mentionKeyword
- */
- _updateSuggestedCannedResponses(mentionKeyword) {
- this.update({
- suggestedCannedResponses: [['replace', this.env.messaging.cannedResponses.filter(
- cannedResponse => cannedResponse.source.includes(mentionKeyword)
- )]],
- });
-
- if (this.suggestedCannedResponses[0]) {
- this.update({
- activeSuggestedCannedResponse: [['link', this.suggestedCannedResponses[0]]],
- hasToScrollToActiveSuggestion: true,
- });
- } else {
- this.update({
- activeSuggestedCannedResponse: [['unlink']],
- });
- }
- }
-
- /**
- * @private
- * @param {string} mentionKeyword
*/
- async _updateSuggestedChannels(mentionKeyword) {
- const mentions = await this.async(() => this.env.services.rpc(
- {
- model: 'mail.channel',
- method: 'get_mention_suggestions',
- kwargs: {
- limit: 8,
- search: mentionKeyword,
- },
- },
- { shadow: true }
- ));
-
- this.update({
- suggestedChannels: [[
- 'insert-and-replace',
- mentions.map(data => {
- const threadData = this.env.models['mail.thread'].convertData(data);
- return Object.assign({ model: 'mail.channel' }, threadData);
- })
- ]],
- });
-
- if (this.suggestedChannels[0]) {
- this.update({
- activeSuggestedChannel: [['link', this.suggestedChannels[0]]],
- hasToScrollToActiveSuggestion: true,
- });
- } else {
- this.update({
- activeSuggestedChannel: [['unlink']],
- });
- }
- }
-
- /**
- * @param {string} mentionKeyword
- */
- _updateSuggestedChannelCommands(mentionKeyword) {
- const commands = this.env.messaging.commands.filter(command => {
- if (!command.name.includes(mentionKeyword)) {
- return false;
- }
- if (command.channel_types) {
- return command.channel_types.includes(this.thread.channel_type);
- }
- return true;
- });
- this.update({ suggestedChannelCommands: [['replace', commands]] });
- if (this.suggestedChannelCommands[0]) {
- this.update({
- activeSuggestedChannelCommand: [['link', this.suggestedChannelCommands[0]]],
- hasToScrollToActiveSuggestion: true,
- });
- } else {
- this.update({
- activeSuggestedChannelCommand: [['unlink']],
- });
+ _updateSuggestionList() {
+ if (
+ this.suggestionDelimiterPosition === undefined ||
+ this.suggestionSearchTerm === undefined ||
+ !this.suggestionModelName
+ ) {
+ return;
}
- }
-
- /**
- * @private
- * @param {string} mentionKeyword
- */
- async _updateSuggestedPartners(mentionKeyword) {
- const mentions = await this.async(() => this.env.services.rpc(
- {
- model: 'res.partner',
- method: 'get_mention_suggestions',
- kwargs: {
- limit: 8,
- search: mentionKeyword,
- },
- },
- { shadow: true }
- ));
-
- const mainSuggestedPartners = mentions[0];
- const extraSuggestedPartners = mentions[1];
+ const Model = this.env.models[this.suggestionModelName];
+ const [
+ mainSuggestedRecords,
+ extraSuggestedRecords = [],
+ ] = Model.searchSuggestions(this.suggestionSearchTerm, { thread: this.thread });
+ const sortFunction = Model.getSuggestionSortFunction(this.suggestionSearchTerm, { thread: this.thread });
+ mainSuggestedRecords.sort(sortFunction);
+ extraSuggestedRecords.sort(sortFunction);
+ // arbitrary limit to avoid displaying too many elements at once
+ // ideally a load more mechanism should be introduced
+ const limit = 8;
+ mainSuggestedRecords.length = Math.min(mainSuggestedRecords.length, limit);
+ extraSuggestedRecords.length = Math.min(extraSuggestedRecords.length, limit - mainSuggestedRecords.length);
this.update({
- extraSuggestedPartners: [[
- 'insert-and-replace',
- extraSuggestedPartners.map(data =>
- this.env.models['mail.partner'].convertData(data)
- )
- ]],
- mainSuggestedPartners: [[
- 'insert-and-replace',
- mainSuggestedPartners.map(data =>
- this.env.models['mail.partner'].convertData(data))
- ]],
+ extraSuggestedRecords: [['replace', extraSuggestedRecords]],
+ hasToScrollToActiveSuggestion: true,
+ mainSuggestedRecords: [['replace', mainSuggestedRecords]],
});
-
- if (this.mainSuggestedPartners[0]) {
- this.update({
- activeSuggestedPartner: [['link', this.mainSuggestedPartners[0]]],
- hasToScrollToActiveSuggestion: true,
- });
- } else if (this.extraSuggestedPartners[0]) {
- this.update({
- activeSuggestedPartner: [['link', this.extraSuggestedPartners[0]]],
- hasToScrollToActiveSuggestion: true,
- });
- } else {
- this.update({
- activeSuggestedPartner: [['unlink']],
- });
- }
}
/**
@@ -878,16 +990,15 @@ function factory(dependencies) {
* Returns the mention keyword without the suggestion delimiter if it
* has been validated and false if not.
*
+ * @deprecated
* @private
* @param {boolean} beginningOnly
* @returns {string|boolean}
*/
_validateMentionKeyword(beginningOnly) {
- const leftString = this.textInputContent.substring(0, this.textInputCursorStart);
-
// use position before suggestion delimiter because there should be whitespaces
// or line feed/carriage return before the suggestion delimiter
- const beforeSuggestionDelimiterPosition = leftString.lastIndexOf(this.suggestionDelimiter) - 1;
+ const beforeSuggestionDelimiterPosition = this.suggestionDelimiterPosition - 1;
if (beginningOnly && beforeSuggestionDelimiterPosition > 0) {
return false;
}
@@ -912,22 +1023,68 @@ function factory(dependencies) {
}
Composer.fields = {
- activeSuggestedCannedResponse: many2one('mail.canned_response'),
- activeSuggestedChannel: many2one('mail.thread'),
- activeSuggestedChannelCommand: many2one('mail.channel_command'),
- activeSuggestedPartner: many2one('mail.partner'),
- activeSuggestedRecord: attr({
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedCannedResponse: many2one('mail.canned_response', {
+ compute: '_computeActiveSuggestedCannedResponse',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedChannel: many2one('mail.thread', {
+ compute: '_computeActiveSuggestedChannel',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedChannelCommand: many2one('mail.channel_command', {
+ compute: '_computeActiveSuggestedChannelCommand',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `activeSuggestedRecord` instead.
+ */
+ activeSuggestedPartner: many2one('mail.partner', {
+ compute: '_computeActiveSuggestedPartner',
+ dependencies: [
+ 'activeSuggestedRecord',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Determines the suggested record that is currently active. This record
+ * is highlighted in the UI and it will be the selected record if the
+ * suggestion is confirmed by the user.
+ */
+ activeSuggestedRecord: many2one('mail.model', {
compute: '_computeActiveSuggestedRecord',
dependencies: [
- 'activeSuggestedCannedResponse',
- 'activeSuggestedChannel',
- 'activeSuggestedChannelCommand',
- 'activeSuggestedPartner',
- 'activeSuggestedRecordName',
+ 'activeSuggestedRecord',
+ 'extraSuggestedRecords',
+ 'mainSuggestedRecords',
],
}),
+ /**
+ * Deprecated, suggestions should be used in a manner that does not
+ * depend on their type. Use `activeSuggestedRecord` directly instead.
+ */
activeSuggestedRecordName: attr({
- default: "",
+ compute: '_computeActiveSuggestedRecordName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
}),
attachments: many2many('mail.attachment', {
inverse: 'composers',
@@ -960,26 +1117,48 @@ function factory(dependencies) {
discussAsReplying: one2one('mail.discuss', {
inverse: 'replyingToMessageOriginThreadComposer',
}),
+ /**
+ * Deprecated. Use `extraSuggestedRecords` instead.
+ */
extraSuggestedPartners: many2many('mail.partner', {
compute: '_computeExtraSuggestedPartners',
dependencies: [
- 'extraSuggestedPartners',
- 'mainSuggestedPartners',
+ 'extraSuggestedRecords',
+ 'suggestionDelimiter',
],
}),
+ /**
+ * Determines the extra records that are currently suggested.
+ * Allows to have different model types of mentions through a dynamic
+ * process. 2 arbitrary lists can be provided and the second is defined
+ * as "extra".
+ */
+ extraSuggestedRecords: many2many('mail.model', {
+ compute: '_computeExtraSuggestedRecords',
+ dependencies: [
+ 'extraSuggestedRecords',
+ 'mainSuggestedRecords',
+ 'suggestionDelimiterPosition',
+ ],
+ }),
+ /**
+ * Deprecated. Use `extraSuggestedRecords` instead.
+ */
extraSuggestedRecordsList: attr({
compute: '_computeExtraSuggestedRecordsList',
dependencies: [
- 'extraSuggestedPartners',
- 'extraSuggestedRecordsListName',
+ 'extraSuggestedRecords',
],
}),
/**
- * Allows to have different model types of mentions through a dynamic process
- * RPC can provide 2 lists and the second is defined as "extra"
+ * Deprecated, suggestions should be used in a manner that does not
+ * depend on their type. Use `extraSuggestedRecords` directly instead.
*/
extraSuggestedRecordsListName: attr({
- default: "",
+ compute: '_computeExtraSuggestedRecordsListName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
}),
/**
* This field determines whether some attachments linked to this
@@ -995,16 +1174,15 @@ function factory(dependencies) {
hasFocus: attr({
default: false,
}),
+ /**
+ * States whether there is any result currently found for the current
+ * suggestion delimiter and search term, if applicable.
+ */
hasSuggestions: attr({
compute: '_computeHasSuggestions',
dependencies: [
- 'extraSuggestedRecordsListName',
- 'extraSuggestedPartners',
- 'mainSuggestedRecordsListName',
- 'mainSuggestedPartners',
- 'suggestedCannedResponses',
- 'suggestedChannelCommands',
- 'suggestedChannels',
+ 'extraSuggestedRecords',
+ 'mainSuggestedRecords',
],
default: false,
}),
@@ -1035,31 +1213,78 @@ function factory(dependencies) {
* Determines whether a post_message request is currently pending.
*/
isPostingMessage: attr(),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ mainSuggestedPartners: many2many('mail.partner', {
+ compute: '_computeMainSuggestedPartners',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Determines the main records that are currently suggested.
+ * Allows to have different model types of mentions through a dynamic
+ * process. 2 arbitrary lists can be provided and the first is defined
+ * as "main".
+ */
+ mainSuggestedRecords: many2many('mail.model', {
+ compute: '_computeMainSuggestedRecords',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiterPosition',
+ ],
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
mainSuggestedRecordsList: attr({
compute: '_computeMainSuggestedRecordsList',
dependencies: [
- 'mainSuggestedPartners',
- 'mainSuggestedRecordsListName',
- 'suggestedCannedResponses',
- 'suggestedChannelCommands',
- 'suggestedChannels',
+ 'mainSuggestedRecords',
],
}),
/**
- * Allows to have different model types of mentions through a dynamic process
- * RPC can provide 2 lists and the first is defined as "main"
+ * Deprecated, suggestions should be used in a manner that does not
+ * depend on their type. Use `mainSuggestedRecords` directly instead.
*/
mainSuggestedRecordsListName: attr({
- default: "",
+ compute: '_computeMainSuggestedRecordsListName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
}),
- mainSuggestedPartners: many2many('mail.partner'),
mentionedChannels: many2many('mail.thread', {
compute: '_computeMentionedChannels',
dependencies: ['textInputContent'],
}),
mentionedPartners: many2many('mail.partner', {
compute: '_computeMentionedPartners',
- dependencies: ['textInputContent'],
+ dependencies: [
+ 'mentionedPartners',
+ 'mentionedPartnersName',
+ 'textInputContent',
+ ],
+ }),
+ /**
+ * Serves as compute dependency.
+ */
+ mentionedPartnersName: attr({
+ related: 'mentionedPartners.name',
+ }),
+ /**
+ * Not a real field, used to trigger `_onChangeUpdateSuggestionList`
+ * when one of the dependencies changes.
+ */
+ onChangeUpdateSuggestionList: attr({
+ compute: '_onChangeUpdateSuggestionList',
+ dependencies: [
+ 'suggestionDelimiterPosition',
+ 'suggestionModelName',
+ 'suggestionSearchTerm',
+ 'thread',
+ ],
}),
/**
* Determines the extra `mail.partner` (on top of existing followers)
@@ -1095,18 +1320,84 @@ function factory(dependencies) {
subjectContent: attr({
default: "",
}),
- suggestedCannedResponses: many2many('mail.canned_response'),
- suggestedChannelCommands: many2many('mail.channel_command'),
- suggestedChannels: many2many('mail.thread'),
/**
- * Special character used to trigger different kinds of suggestions
- * such as canned responses (:), channels (#), commands (/) and partners (@)
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ suggestedCannedResponses: many2many('mail.canned_response', {
+ compute: '_computeSuggestedCannedResponses',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ suggestedChannelCommands: many2many('mail.channel_command', {
+ compute: '_computeSuggestedChannelCommands',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * Deprecated. Use `mainSuggestedRecords` instead.
+ */
+ suggestedChannels: many2many('mail.thread', {
+ compute: '_computeSuggestedChannels',
+ dependencies: [
+ 'mainSuggestedRecords',
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * States which type of suggestion is currently in progress, if any.
+ * The value of this field contains the magic char that corresponds to
+ * the suggestion currently in progress, and it must be one of these:
+ * canned responses (:), channels (#), commands (/) and partners (@)
*/
suggestionDelimiter: attr({
- default: "",
+ compute: '_computeSuggestionDelimiter',
+ dependencies: [
+ 'suggestionDelimiterPosition',
+ 'textInputContent',
+ ],
+ }),
+ /**
+ * States the position inside textInputContent of the suggestion
+ * delimiter currently in consideration. Useful if the delimiter char
+ * appears multiple times in the content.
+ * Note: the position is 0 based so it's important to compare to
+ * `undefined` when checking for the absence of a value.
+ */
+ suggestionDelimiterPosition: attr({
+ compute: '_computeSuggestionDelimiterPosition',
+ dependencies: [
+ 'textInputContent',
+ 'textInputCursorEnd',
+ 'textInputCursorStart',
+ ],
}),
+ /**
+ * States the target model name of the suggestion currently in progress,
+ * if any.
+ */
suggestionModelName: attr({
- default: "",
+ compute: '_computeSuggestionModelName',
+ dependencies: [
+ 'suggestionDelimiter',
+ ],
+ }),
+ /**
+ * States the search term to use for suggestions (if any).
+ */
+ suggestionSearchTerm: attr({
+ compute: '_computeSuggestionSearchTerm',
+ dependencies: [
+ 'suggestionDelimiterPosition',
+ 'textInputContent',
+ 'textInputCursorStart',
+ ],
}),
textInputContent: attr({
default: "",
diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
index a0c8372940d..97d0d3b1e83 100644
--- a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
+++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js
@@ -236,8 +236,7 @@ function factory(dependencies) {
async _initMentionPartnerSuggestions(mentionPartnerSuggestionsData) {
return executeGracefully(mentionPartnerSuggestionsData.map(suggestions => () => {
return executeGracefully(suggestions.map(suggestion => () => {
- const { email, id, name } = suggestion;
- this.env.models['mail.partner'].insert({ email, id, name });
+ this.env.models['mail.partner'].insert(this.env.models['mail.partner'].convertData(suggestion));
}));
}));
}
diff --git a/addons/mail/static/src/models/partner/partner.js b/addons/mail/static/src/models/partner/partner.js
index 3de505426ec..4d007fb13f6 100644
--- a/addons/mail/static/src/models/partner/partner.js
+++ b/addons/mail/static/src/models/partner/partner.js
@@ -3,8 +3,7 @@ odoo.define('mail/static/src/models/partner/partner.js', function (require) {
const { registerNewModel } = require('mail/static/src/model/model_core.js');
const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js');
-
-const utils = require('web.utils');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
function factory(dependencies) {
@@ -67,6 +66,7 @@ function factory(dependencies) {
id: data.user_id,
};
}
+ user.isInternalUser = data.is_internal_user;
data2.user = [['insert', user]];
}
}
@@ -75,6 +75,42 @@ function factory(dependencies) {
}
/**
+ * Fetches partners matching the given search term to extend the
+ * JS knowledge and to update the suggestion list accordingly.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static async fetchSuggestions(searchTerm, { thread } = {}) {
+ const kwargs = { search: searchTerm };
+ const isNonPublicChannel = thread && thread.model === 'mail.channel' && thread.public !== 'public';
+ if (isNonPublicChannel) {
+ kwargs.channel_id = thread.id;
+ }
+ const [
+ mainSuggestedPartners,
+ extraSuggestedPartners,
+ ] = await this.env.services.rpc(
+ {
+ model: 'res.partner',
+ method: 'get_mention_suggestions',
+ kwargs,
+ },
+ { shadow: true },
+ );
+ const partnersData = mainSuggestedPartners.concat(extraSuggestedPartners);
+ const partners = this.env.models['mail.partner'].insert(partnersData.map(data =>
+ this.env.models['mail.partner'].convertData(data)
+ ));
+ if (isNonPublicChannel) {
+ thread.update({ members: [['link', partners]] });
+ }
+ }
+
+ /**
* Search for partners matching `keyword`.
*
* @static
@@ -86,17 +122,15 @@ function factory(dependencies) {
static async imSearch({ callback, keyword, limit = 10 }) {
// prefetched partners
let partners = [];
- const searchRegexp = new RegExp(
- _.str.escapeRegExp(utils.unaccent(keyword)),
- 'i'
- );
+ const cleanedSearchTerm = cleanSearchTerm(keyword);
const currentPartner = this.env.messaging.currentPartner;
for (const partner of this.all(partner => partner.active)) {
if (partners.length < limit) {
if (
partner !== currentPartner &&
- searchRegexp.test(partner.name) &&
- partner.user
+ partner.name &&
+ partner.user &&
+ cleanSearchTerm(partner.name).includes(cleanedSearchTerm)
) {
partners.push(partner);
}
@@ -120,6 +154,56 @@ function factory(dependencies) {
}
/**
+ * Returns partners that match the given search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.partner[], mail.partner[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ let partners;
+ const isNonPublicChannel = thread && thread.model === 'mail.channel' && thread.public !== 'public';
+ if (isNonPublicChannel) {
+ // Only return the channel members when in the context of a
+ // non-public channel. Indeed, the message with the mention
+ // would be notified to the mentioned partner, so this prevents
+ // from inadvertently leaking the private message to the
+ // mentioned partner.
+ partners = thread.members;
+ } else {
+ partners = this.env.models['mail.partner'].all();
+ }
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ const mainSuggestionList = [];
+ const extraSuggestionList = [];
+ for (const partner of partners) {
+ if (
+ (!partner.active && partner !== this.env.messaging.partnerRoot) ||
+ partner.id <= 0 ||
+ this.env.messaging.publicPartners.includes(partner)
+ ) {
+ // ignore archived partners (except OdooBot), temporary
+ // partners (livechat guests), public partners (technical)
+ continue;
+ }
+ if (
+ (partner.nameOrDisplayName && cleanSearchTerm(partner.nameOrDisplayName).includes(cleanedSearchTerm)) ||
+ (partner.email && cleanSearchTerm(partner.email).includes(cleanedSearchTerm))
+ ) {
+ if (partner.user) {
+ mainSuggestionList.push(partner);
+ } else {
+ extraSuggestionList.push(partner);
+ }
+ }
+ }
+ return [mainSuggestionList, extraSuggestionList];
+ }
+
+ /**
* @static
*/
static async startLoopFetchImStatus() {
@@ -169,6 +253,89 @@ function factory(dependencies) {
}
/**
+ * Returns the text that identifies this partner in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.name;
+ }
+
+ /**
+ * Returns a sort function to determine the order of display of partners
+ * in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const isAInternalUser = a.user && a.user.isInternalUser;
+ const isBInternalUser = b.user && b.user.isInternalUser;
+ if (isAInternalUser && !isBInternalUser) {
+ return -1;
+ }
+ if (!isAInternalUser && isBInternalUser) {
+ return 1;
+ }
+ if (thread && thread.model === 'mail.channel') {
+ const isAMember = thread.members.includes(a);
+ const isBMember = thread.members.includes(b);
+ if (isAMember && !isBMember) {
+ return -1;
+ }
+ if (!isAMember && isBMember) {
+ return 1;
+ }
+ }
+ if (thread) {
+ const isAFollower = thread.followersPartner.includes(a);
+ const isBFollower = thread.followersPartner.includes(b);
+ if (isAFollower && !isBFollower) {
+ return -1;
+ }
+ if (!isAFollower && isBFollower) {
+ return 1;
+ }
+ }
+ const cleanedAName = cleanSearchTerm(a.nameOrDisplayName || '');
+ const cleanedBName = cleanSearchTerm(b.nameOrDisplayName || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ const cleanedAEmail = cleanSearchTerm(a.email || '');
+ const cleanedBEmail = cleanSearchTerm(b.email || '');
+ if (cleanedAEmail.startsWith(cleanedSearchTerm) && !cleanedAEmail.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedBEmail.startsWith(cleanedSearchTerm) && cleanedBEmail.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAEmail < cleanedBEmail) {
+ return -1;
+ }
+ if (cleanedAEmail > cleanedBEmail) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /**
* Opens a chat between the user of this partner and the current user
* and returns it.
*
diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js
index 4739e59f91e..1011eec4453 100644
--- a/addons/mail/static/src/models/thread/thread.js
+++ b/addons/mail/static/src/models/thread/thread.js
@@ -6,6 +6,7 @@ const { attr, many2many, many2one, one2many, one2one } = require('mail/static/sr
const { clear } = require('mail/static/src/model/model_field_command.js');
const throttle = require('mail/static/src/utils/throttle/throttle.js');
const Timer = require('mail/static/src/utils/timer/timer.js');
+const { cleanSearchTerm } = require('mail/static/src/utils/utils.js');
const mailUtils = require('mail.utils');
function factory(dependencies) {
@@ -274,6 +275,82 @@ function factory(dependencies) {
}
/**
+ * Fetches threads matching the given composer search state to extend
+ * the JS knowledge and to update the suggestion list accordingly.
+ * More specifically only thread of model 'mail.channel' are fetched.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ */
+ static async fetchSuggestions(searchTerm, { thread } = {}) {
+ const channelsData = await this.env.services.rpc(
+ {
+ model: 'mail.channel',
+ method: 'get_mention_suggestions',
+ kwargs: { search: searchTerm },
+ },
+ { shadow: true },
+ );
+ this.env.models['mail.thread'].insert(channelsData.map(channelData =>
+ Object.assign(
+ { model: 'mail.channel' },
+ this.env.models['mail.thread'].convertData(channelData),
+ )
+ ));
+ }
+
+ /**
+ * Returns a sort function to determine the order of display of threads
+ * in the suggestion list.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize result in the
+ * context of given thread
+ * @returns {function}
+ */
+ static getSuggestionSortFunction(searchTerm, { thread } = {}) {
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return (a, b) => {
+ const isAPublic = a.model === 'mail.channel' && a.public === 'public';
+ const isBPublic = b.model === 'mail.channel' && b.public === 'public';
+ if (isAPublic && !isBPublic) {
+ return -1;
+ }
+ if (!isAPublic && isBPublic) {
+ return 1;
+ }
+ const isMemberOfA = a.model === 'mail.channel' && a.members.includes(this.env.messaging.currentPartner);
+ const isMemberOfB = b.model === 'mail.channel' && b.members.includes(this.env.messaging.currentPartner);
+ if (isMemberOfA && !isMemberOfB) {
+ return -1;
+ }
+ if (!isMemberOfA && isMemberOfB) {
+ return 1;
+ }
+ const cleanedAName = cleanSearchTerm(a.name || '');
+ const cleanedBName = cleanSearchTerm(b.name || '');
+ if (cleanedAName.startsWith(cleanedSearchTerm) && !cleanedBName.startsWith(cleanedSearchTerm)) {
+ return -1;
+ }
+ if (!cleanedAName.startsWith(cleanedSearchTerm) && cleanedBName.startsWith(cleanedSearchTerm)) {
+ return 1;
+ }
+ if (cleanedAName < cleanedBName) {
+ return -1;
+ }
+ if (cleanedAName > cleanedBName) {
+ return 1;
+ }
+ return a.id - b.id;
+ };
+ }
+
+ /**
* Load the previews of the specified threads. Basically, it fetches the
* last messages, since they are used to display inline content of them.
*
@@ -542,6 +619,41 @@ function factory(dependencies) {
}
}
+ /*
+ * Returns threads that match the given search term. More specially only
+ * threads of model 'mail.channel' are suggested, and if the context
+ * thread is a private channel, only itself is returned if it matches
+ * the search term.
+ *
+ * @static
+ * @param {string} searchTerm
+ * @param {Object} [options={}]
+ * @param {mail.thread} [options.thread] prioritize and/or restrict
+ * result in the context of given thread
+ * @returns {[mail.threads[], mail.threads[]]}
+ */
+ static searchSuggestions(searchTerm, { thread } = {}) {
+ let threads;
+ if (thread && thread.model === 'mail.channel' && thread.public !== 'public') {
+ // Only return the current channel when in the context of a
+ // non-public channel. Indeed, the message with the mention
+ // would appear in the target channel, so this prevents from
+ // inadvertently leaking the private message into the mentioned
+ // channel.
+ threads = [thread];
+ } else {
+ threads = this.env.models['mail.thread'].all();
+ }
+ const cleanedSearchTerm = cleanSearchTerm(searchTerm);
+ return [threads.filter(thread =>
+ !thread.isTemporary &&
+ thread.model === 'mail.channel' &&
+ thread.channel_type === 'channel' &&
+ thread.name &&
+ cleanSearchTerm(thread.name).includes(cleanedSearchTerm)
+ )];
+ }
+
/**
* @param {string} [stringifiedDomain='[]']
* @returns {mail.thread_cache}
@@ -609,6 +721,15 @@ function factory(dependencies) {
}
/**
+ * Returns the text that identifies this thread in a mention.
+ *
+ * @returns {string}
+ */
+ getMentionText() {
+ return this.name;
+ }
+
+ /**
* Load new messages on the main cache of this thread.
*/
loadNewMessages() {
diff --git a/addons/mail/static/src/models/user/user.js b/addons/mail/static/src/models/user/user.js
index 172b8c5b780..721b586fda4 100644
--- a/addons/mail/static/src/models/user/user.js
+++ b/addons/mail/static/src/models/user/user.js
@@ -204,6 +204,12 @@ function factory(dependencies) {
User.fields = {
id: attr(),
+ /**
+ * Determines whether this user is an internal user. An internal user is
+ * a member of the group `base.group_user`. This is the inverse of the
+ * `share` field in python.
+ */
+ isInternalUser: attr(),
display_name: attr({
compute: '_computeDisplayName',
dependencies: [
diff --git a/addons/mail/static/src/utils/utils.js b/addons/mail/static/src/utils/utils.js
index f502fc8f326..2cfaa531f77 100644
--- a/addons/mail/static/src/utils/utils.js
+++ b/addons/mail/static/src/utils/utils.js
@@ -4,6 +4,7 @@ odoo.define('mail/static/src/utils/utils.js', function (require) {
const { delay } = require('web.concurrency');
const {
patch: webUtilsPatch,
+ unaccent,
unpatch: webUtilsUnpatch,
} = require('web.utils');
@@ -15,6 +16,18 @@ const classPatchMap = new WeakMap();
const eventHandledWeakMap = new WeakMap();
/**
+ * Returns the given string after cleaning it. The goal of the clean is to give
+ * more convenient results when comparing it to potential search results, on
+ * which the clean should also be called before comparing them.
+ *
+ * @param {string} searchTerm
+ * @returns {string}
+ */
+function cleanSearchTerm(searchTerm) {
+ return unaccent(searchTerm.toLowerCase());
+}
+
+/**
* Executes the provided functions in order, but with a potential delay between
* them if they take too much time. This is done in order to avoid blocking the
* main thread for too long.
@@ -166,6 +179,7 @@ function unpatchInstanceMethods(Class, patchName) {
//------------------------------------------------------------------------------
return {
+ cleanSearchTerm,
executeGracefully,
isEventHandled,
markEventHandled,