[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#68045

X-original-commit: 930e090ced8134c3819a5a87b9052467ba23e8e4
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Co-authored-by: Sébastien Theys <seb@odoo.com>
This commit is contained in:
Xavier Dubuc
2021-03-18 10:32:27 +00:00
committed by Sébastien Theys
co-authored by Sébastien Theys
parent 9fd700c82c
commit e3ecccd641
20 changed files with 929 additions and 591 deletions
+1 -14
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo import models
from odoo.exceptions import AccessError
@@ -9,19 +9,6 @@ class Partner(models.Model):
_inherit = ['res.partner']
@api.model
def get_static_mention_suggestions(self):
""" Extend the mail's static mention suggestions by adding the employees. """
suggestions = super(Partner, self).get_static_mention_suggestions()
try:
employee_group = self.env.ref('base.group_user')
hr_suggestions = [{'id': user.partner_id.id, 'name': user.name, 'email': user.email} for user in employee_group.users]
suggestions.append(hr_suggestions)
return suggestions
except AccessError:
return suggestions
def name_get(self):
""" Override to allow an employee to see its private address in his profile.
This avoids to relax access rules on `res.parter` and to add an `ir.rule`.
+9 -2
View File
@@ -604,7 +604,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.
@@ -1165,7 +1172,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):
+36 -25
View File
@@ -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__)
@@ -71,13 +72,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) > 0 else self.user_ids[0] if len(self.user_ids) > 0 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):
@@ -106,36 +114,39 @@ 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. All the internal users are returned.
The return format is a list of partner data (as per returned by `mail_partner_format()`).
"""
partners = self.env['res.partner']
try:
partners = self.env.ref('base.group_user').users.partner_id
except AccessError:
pass
return [partner.mail_partner_format() for partner in partners]
@api.model
def get_mention_suggestions(self, search, limit=8):
""" 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. """
def get_mention_suggestions(self, search, limit=8, channel_id=None):
""" Return 'limit'-first partners' such that the name or email matches a 'search' string.
Prioritize partners that are also users, and then extend the research to all partners.
If channel_id is given, only members of this channel are returned.
The return format is a list of partner data (as per returned by `mail_partner_format()`).
"""
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
# Search partners that are also users
domain = expression.AND([[('user_ids.id', '!=', False), ('user_ids.active', '=', True)], search_dom])
users = self.search_read(domain, fields, limit=limit)
partners = self.search(domain, limit=limit)
# Search partners if less than 'limit' users found
partners = []
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']])]
# Search partners that are not users if less than 'limit' partner that are users found
remaining_limit = limit - len(partners)
if remaining_limit > 0:
partners |= self.search(expression.AND([[('id', 'not in', partners.ids)], search_dom]), limit=remaining_limit)
# 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])
return [users, partners]
return [partner.mail_partner_format() for partner in partners]
@api.model
def im_search(self, name, limit=20):
@@ -402,8 +402,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`)
@@ -462,10 +460,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`)
@@ -518,8 +514,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`)
@@ -619,8 +613,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`)
@@ -675,10 +667,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`)
@@ -727,8 +717,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`)
@@ -833,8 +821,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`)
@@ -922,10 +908,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`)
@@ -977,8 +961,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`)
@@ -2220,61 +2202,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
@@ -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');
@@ -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,
},
});
}
@@ -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"
@@ -203,6 +203,14 @@ class ComposerTextInput extends Component {
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onClickTextarea() {
// clicking might change the cursor position
this.saveStateInStore();
}
/**
* @private
*/
@@ -383,7 +391,6 @@ class ComposerTextInput extends Component {
// Otherwise, check if a mention is typed
default:
this.saveStateInStore();
this.composer.detectSuggestionDelimiter();
}
}
@@ -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
@@ -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"
@@ -1481,6 +1481,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,
@@ -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(),
@@ -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 = {
/**
+363 -431
View File
@@ -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,79 +49,11 @@ 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: "",
});
}
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();
}
this.update({ suggestionDelimiterPosition: clear() });
}
/**
@@ -159,11 +92,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 +112,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 +121,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 +154,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) {
@@ -382,95 +311,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) {
const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
const activeElementIndex = suggestedRecords.findIndex(
suggestion => suggestion === this.activeSuggestedRecord
);
if (activeElementIndex === suggestedRecords.length - 1) {
// loop when reaching the end of the list
this.setFirstSuggestionActive();
return;
}
const activeElementIndex = fullList.findIndex(
suggestion => suggestion === this[this.activeSuggestedRecordName]
);
if (activeElementIndex !== fullList.length - 1) {
this.update({
[this.activeSuggestedRecordName]: [[
'link',
fullList[activeElementIndex + 1]
]],
});
} else {
this.update({
[this.activeSuggestedRecordName]: [['link', fullList[0]]],
});
}
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) {
const suggestedRecords = this.mainSuggestedRecords.concat(this.extraSuggestedRecords);
const activeElementIndex = suggestedRecords.findIndex(
suggestion => suggestion === this.activeSuggestedRecord
);
if (activeElementIndex === 0) {
// loop when reaching the start of the list
this.setLastSuggestionActive();
return;
}
const activeElementIndex = fullList.findIndex(
suggestion => suggestion === this[this.activeSuggestedRecordName]
);
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]
]],
});
}
const previousRecord = suggestedRecords[activeElementIndex - 1];
this.update({ activeSuggestedRecord: [['link', previousRecord]] });
}
//----------------------------------------------------------------------
@@ -478,11 +372,28 @@ function factory(dependencies) {
//----------------------------------------------------------------------
/**
* 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]];
}
/**
@@ -497,25 +408,18 @@ function factory(dependencies) {
}
/**
* Ensure extraSuggestedPartners does not contain any partner already
* present in mainSuggestedPartners. This is necessary for the
* consistency of suggestion list.
* 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.partner[]}
*/
_computeExtraSuggestedPartners() {
return [['unlink', this.mainSuggestedPartners]];
}
/**
* @private
* @returns {mail.model[]}
*/
_computeExtraSuggestedRecordsList() {
return this.extraSuggestedRecordsListName
? this[this.extraSuggestedRecordsListName]
: [];
_computeExtraSuggestedRecords() {
if (this.suggestionDelimiterPosition === undefined) {
return [['unlink-all']];
}
return [['unlink', this.mainSuggestedRecords]];
}
/**
@@ -523,9 +427,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;
}
/**
@@ -537,13 +439,15 @@ function factory(dependencies) {
}
/**
* Clears the main suggested record on closing mentions.
*
* @private
* @returns {mail.model[]}
*/
_computeMainSuggestedRecordsList() {
return this.mainSuggestedRecordsListName
? this[this.mainSuggestedRecordsListName]
: [];
_computeMainSuggestedRecords() {
if (this.suggestionDelimiterPosition === undefined) {
return [['unlink-all']];
}
}
/**
@@ -598,6 +502,96 @@ function factory(dependencies) {
return [['unlink', unmentionedChannels]];
}
/**
* @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();
}
/**
* @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
@@ -716,11 +710,45 @@ function factory(dependencies) {
return undefined;
}
/**
* 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,
@@ -734,200 +762,58 @@ function factory(dependencies) {
}
/**
* @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']],
});
}
}
/**
* @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];
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))
]],
});
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']],
});
}
}
/**
* Validates user's current typing as a correct mention keyword in order
* to trigger mentions suggestions display.
* Returns the mention keyword without the suggestion delimiter if it
* has been validated and false if not.
* 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 {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;
if (beginningOnly && beforeSuggestionDelimiterPosition > 0) {
return false;
_updateSuggestionList() {
if (
this.suggestionDelimiterPosition === undefined ||
this.suggestionSearchTerm === undefined ||
!this.suggestionModelName
) {
return;
}
let searchStr = this.textInputContent.substring(
beforeSuggestionDelimiterPosition,
this.textInputCursorStart
);
// regex string start with suggestion delimiter or whitespace then suggestion delimiter
const pattern = "^" + this.suggestionDelimiter + "|^\\s" + this.suggestionDelimiter;
const 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.includes(' ') && !/[\r\n]/.test(searchStr)
? searchStr.replace(this.suggestionDelimiter, '')
: false;
}
return false;
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({
extraSuggestedRecords: [['replace', extraSuggestedRecords]],
hasToScrollToActiveSuggestion: true,
mainSuggestedRecords: [['replace', mainSuggestedRecords]],
});
}
}
Composer.fields = {
activeSuggestedCannedResponse: many2one('mail.canned_response'),
activeSuggestedChannel: many2one('mail.thread'),
activeSuggestedChannelCommand: many2one('mail.channel_command'),
activeSuggestedPartner: many2one('mail.partner'),
activeSuggestedRecord: attr({
/**
* 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',
],
}),
activeSuggestedRecordName: attr({
default: "",
}),
attachments: many2many('mail.attachment', {
inverse: 'composers',
}),
@@ -958,26 +844,19 @@ function factory(dependencies) {
discussAsReplying: one2one('mail.discuss', {
inverse: 'replyingToMessageOriginThreadComposer',
}),
extraSuggestedPartners: many2many('mail.partner', {
compute: '_computeExtraSuggestedPartners',
dependencies: [
'extraSuggestedPartners',
'mainSuggestedPartners',
],
}),
extraSuggestedRecordsList: attr({
compute: '_computeExtraSuggestedRecordsList',
dependencies: [
'extraSuggestedPartners',
'extraSuggestedRecordsListName',
],
}),
/**
* Allows to have different model types of mentions through a dynamic process
* RPC can provide 2 lists and the second is defined as "extra"
* 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".
*/
extraSuggestedRecordsListName: attr({
default: "",
extraSuggestedRecords: many2many('mail.model', {
compute: '_computeExtraSuggestedRecords',
dependencies: [
'extraSuggestedRecords',
'mainSuggestedRecords',
'suggestionDelimiterPosition',
],
}),
/**
* This field determines whether some attachments linked to this
@@ -993,16 +872,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,
}),
@@ -1033,31 +911,49 @@ function factory(dependencies) {
* Determines whether a post_message request is currently pending.
*/
isPostingMessage: attr(),
mainSuggestedRecordsList: attr({
compute: '_computeMainSuggestedRecordsList',
/**
* 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: [
'mainSuggestedPartners',
'mainSuggestedRecordsListName',
'suggestedCannedResponses',
'suggestedChannelCommands',
'suggestedChannels',
'mainSuggestedRecords',
'suggestionDelimiterPosition',
],
}),
/**
* Allows to have different model types of mentions through a dynamic process
* RPC can provide 2 lists and the first is defined as "main"
*/
mainSuggestedRecordsListName: attr({
default: "",
}),
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)
@@ -1093,18 +989,54 @@ 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 (@)
* 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: "",
@@ -231,11 +231,8 @@ function factory(dependencies) {
* @param {Object[]} mentionPartnerSuggestionsData
*/
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 });
}));
return executeGracefully(mentionPartnerSuggestionsData.map(partnerData => () => {
this.env.models['mail.partner'].insert(this.env.models['mail.partner'].convertData(partnerData));
}));
}
@@ -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]];
}
}
@@ -74,6 +74,38 @@ function factory(dependencies) {
return data2;
}
/**
* 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 suggestedPartners = await this.env.services.rpc(
{
model: 'res.partner',
method: 'get_mention_suggestions',
kwargs,
},
{ shadow: true },
);
const partners = this.env.models['mail.partner'].insert(suggestedPartners.map(data =>
this.env.models['mail.partner'].convertData(data)
));
if (isNonPublicChannel) {
thread.update({ members: [['link', partners]] });
}
}
/**
* Search for partners matching `keyword`.
*
@@ -86,17 +118,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);
}
@@ -119,6 +149,56 @@ function factory(dependencies) {
callback(partners);
}
/**
* 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
*/
@@ -168,6 +248,89 @@ function factory(dependencies) {
return this.user.getChat();
}
/**
* 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.
@@ -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) {
@@ -279,6 +280,82 @@ function factory(dependencies) {
return data2;
}
/**
* 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.
@@ -548,6 +625,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}
@@ -613,6 +725,15 @@ function factory(dependencies) {
this.fetchAndUpdateSuggestedRecipients();
}
/**
* 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.
*/
@@ -206,6 +206,12 @@ function factory(dependencies) {
id: attr({
required: true,
}),
/**
* 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: [
+14
View File
@@ -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');
@@ -14,6 +15,18 @@ const {
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
@@ -166,6 +179,7 @@ function unpatchInstanceMethods(Class, patchName) {
//------------------------------------------------------------------------------
return {
cleanSearchTerm,
executeGracefully,
isEventHandled,
markEventHandled,
@@ -1690,7 +1690,7 @@ MockServer.include({
* @param {integer} limit
* @returns {Object[]}
*/
const mentionSuggestionsFilter = function (partners, search, limit) {
const mentionSuggestionsFilter = (partners, search, limit) => {
const matchingPartners = partners
.filter(partner => {
// no search term is considered as return all
@@ -1705,14 +1705,7 @@ MockServer.include({
return true;
}
return false;
}).map(partner => {
// expected format
return {
email: partner.email,
id: partner.id,
name: partner.name,
};
});
}).map(partner => this._mockResPartnerMailPartnerFormat(partner.id));
// reduce results to max limit
matchingPartners.length = Math.min(matchingPartners.length, limit);
return matchingPartners;
@@ -1726,11 +1719,12 @@ MockServer.include({
let extraMatchingPartners = [];
// if not enough results add extra suggestions based on partners
const remainingLimit = limit - mainMatchingPartners.length;
if (mainMatchingPartners.length < limit) {
const partners = this._getRecords('res.partner', [['id', 'not in', mainMatchingPartners.map(partner => partner.id)]]);
extraMatchingPartners = mentionSuggestionsFilter(partners, search, limit);
extraMatchingPartners = mentionSuggestionsFilter(partners, search, remainingLimit);
}
return [mainMatchingPartners, extraMatchingPartners];
return mainMatchingPartners.concat(extraMatchingPartners);
},
/**
* Simulates `get_needaction_count` on `res.partner`.
@@ -1798,9 +1792,12 @@ MockServer.include({
[['id', '=', id]],
{ active_test: false }
)[0];
// Servers is also returning `user_id` and `is_internal_user` but not
// done here for simplification.
return {
"active": partner.active,
"display_name": partner.display_name,
"email": partner.email,
"id": partner.id,
"im_status": partner.im_status,
"name": partner.name,