odoo.define('mail/static/src/models/composer/composer.js', function (require) {
'use strict';
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 mailUtils = require('mail.utils');
const {
addLink,
escapeAndCompactTextContent,
parseAndTransform,
} = require('mail.utils');
function factory(dependencies) {
class Composer extends dependencies['mail.model'] {
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
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() {
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._updateSuggestedPartners(mentionKeyword);
break;
case ':':
this.update({
activeSuggestedRecordName: "activeSuggestedCannedResponse",
mainSuggestedRecordsListName: "suggestedCannedResponses",
suggestionModelName: "mail.canned_response",
});
this._updateSuggestedCannedResponses(mentionKeyword);
break;
case '/':
this.update({
activeSuggestedRecordName: "activeSuggestedChannelCommand",
mainSuggestedRecordsListName: "suggestedChannelCommands",
suggestionModelName: "mail.channel_command",
});
this._updateSuggestedChannelCommands(mentionKeyword);
break;
case '#':
this.update({
activeSuggestedRecordName: "activeSuggestedChannel",
mainSuggestedRecordsListName: "suggestedChannels",
suggestionModelName: "mail.thread",
});
this._updateSuggestedChannels(mentionKeyword);
break;
}
} else {
this.closeSuggestions();
}
}
/**
* Hides the composer, which only makes sense if the composer is
* currently used as a Discuss Inbox reply composer.
*/
discard() {
if (this.discussAsReplying) {
this.discussAsReplying.clearReplyingToMessage();
}
}
/**
* Inserts text content in text input based on selection.
*
* @param {string} content
*/
insertIntoTextInput(content) {
const partA = this.textInputContent.slice(0, this.textInputCursorStart);
const partB = this.textInputContent.slice(
this.textInputCursorEnd,
this.textInputContent.length
);
this.update({
textInputContent: partA + content + partB,
textInputCursorStart: this.textInputCursorStart + content.length,
textInputCursorEnd: this.textInputCursorStart + content.length,
});
}
insertSuggestion() {
const cursorPosition = this.textInputCursorStart;
let textLeft = this.textInputContent.substring(
0,
this.textInputContent.substring(0, cursorPosition).lastIndexOf(this.suggestionDelimiter) + 1
);
let textRight = this.textInputContent.substring(
cursorPosition,
this.textInputContent.length
);
if (this.suggestionDelimiter === ':') {
textLeft = this.textInputContent.substring(
0,
this.textInputContent.substring(0, cursorPosition).lastIndexOf(this.suggestionDelimiter)
);
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.replace(/ /g, '\u00a0');
this.update({
mentionedPartners: [['link', this[this.activeSuggestedRecordName]]],
});
break;
}
this.update({
textInputContent: textLeft + recordReplacement + ' ' + textRight,
textInputCursorEnd: textLeft.length + recordReplacement.length + 1,
textInputCursorStart: textLeft.length + recordReplacement.length + 1,
});
}
/**
* @private
* @returns {mail.partner[]}
*/
_computeRecipients() {
const recipients = [...this.mentionedPartners];
if (this.thread) {
for (const recipient of this.thread.suggestedRecipientInfoList) {
if (recipient.partner && recipient.isSelected) {
recipients.push(recipient.partner);
}
}
}
return [['replace', recipients]];
}
/**
* Open the full composer modal.
*/
async openFullComposer() {
const attachmentIds = this.attachments.map(attachment => attachment.id);
const context = {
default_attachment_ids: attachmentIds,
default_body: mailUtils.escapeAndCompactTextContent(this.textInputContent),
default_is_log: this.isLog,
default_model: this.thread.model,
default_partner_ids: this.recipients.map(partner => partner.id),
default_res_id: this.thread.id,
mail_post_autofollow: true,
};
const action = {
type: 'ir.actions.act_window',
res_model: 'mail.compose.message',
view_mode: 'form',
views: [[false, 'form']],
target: 'new',
context: context,
};
const options = {
on_close: () => {
if (!this.exists()) {
return;
}
this._reset();
this.thread.loadNewMessages();
},
};
await this.env.bus.trigger('do-action', { action, options });
}
/**
* Post a message in provided composer's thread based on current composer fields values.
*/
async postMessage() {
const thread = this.thread;
this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true });
const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent);
let body = escapedAndCompactContent.replace(/ /g, ' ').trim();
// This message will be received from the mail composer as html content
// subtype but the urls will not be linkified. If the mail composer
// takes the responsibility to linkify the urls we end up with double
// linkification a bit everywhere. Ideally we want to keep the content
// as text internally and only make html enrichment at display time but
// the current design makes this quite hard to do.
body = this._generateMentionsLinks(body);
body = parseAndTransform(body, addLink);
body = this._generateEmojisOnHtml(body);
let postData = {
attachment_ids: this.attachments.map(attachment => attachment.id),
body,
channel_ids: this.mentionedChannels.map(channel => channel.id),
context: {
mail_post_autofollow: true,
},
message_type: 'comment',
partner_ids: this.recipients.map(partner => partner.id),
};
if (this.subjectContent) {
postData.subject = this.subjectContent;
}
let messageId;
if (thread.model === 'mail.channel') {
const command = this._getCommandFromText(body);
Object.assign(postData, {
command: command ? command.name : undefined,
subtype_xmlid: 'mail.mt_comment'
});
messageId = await this.async(() => this.env.services.rpc({
model: 'mail.channel',
method: command ? 'execute_command' : 'message_post',
args: [thread.id],
kwargs: postData,
}));
} else {
Object.assign(postData, {
subtype_xmlid: this.isLog ? 'mail.mt_note' : 'mail.mt_comment',
});
messageId = await this.async(() => this.env.services.rpc({
model: thread.model,
method: 'message_post',
args: [thread.id],
kwargs: postData,
}));
const [messageData] = await this.async(() => this.env.services.rpc({
model: 'mail.message',
method: 'message_format',
args: [[messageId]],
}, { shadow: true }));
this.env.models['mail.message'].insert(Object.assign(
{},
this.env.models['mail.message'].convertData(messageData),
{
originThread: [['insert', {
id: thread.id,
model: thread.model,
}]],
})
);
thread.loadNewMessages();
}
for (const threadView of this.thread.threadViews) {
// Reset auto scroll to be able to see the newly posted message.
threadView.update({ hasAutoScrollOnMessageReceived: true });
}
thread.refreshFollowers();
thread.fetchAndUpdateSuggestedRecipients();
this._reset();
}
/**
* Called when current partner is inserting some input in composer.
* Useful to notify current partner is currently typing something in the
* composer of this thread to all other members.
*/
handleCurrentPartnerIsTyping() {
if (!this.thread) {
return;
}
if (this.thread.typingMembers.includes(this.env.messaging.currentPartner)) {
this.thread.refreshCurrentPartnerIsTyping();
} else {
this.thread.registerCurrentPartnerIsTyping();
}
}
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]]],
});
}
}
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]
]],
});
}
this.update({
[this.activeSuggestedRecordName]: [[
'link',
this[this.extraSuggestedRecordsListName][this[this.extraSuggestedRecordsListName].length - 1]
]],
});
}
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]
);
if (activeElementIndex !== fullList.length - 1) {
this.update({
[this.activeSuggestedRecordName]: [[
'link',
fullList[activeElementIndex + 1]
]],
});
} else {
this.update({
[this.activeSuggestedRecordName]: [['link', fullList[0]]],
});
}
}
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]
);
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]
]],
});
}
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* @private
* @returns {mail.model}
*/
_computeActiveSuggestedRecord() {
return this[this.activeSuggestedRecordName];
}
/**
* @private
* @returns {boolean}
*/
_computeCanPostMessage() {
if (!this.textInputContent && this.attachments.length === 0) {
return false;
}
return !this.hasUploadingAttachment;
}
/**
* Ensure extraSuggestedPartners does not contain any partner already
* present in mainSuggestedPartners. This is necessary for the
* consistency of suggestion list.
*
* @private
* @returns {mail.partner[]}
*/
_computeExtraSuggestedPartners() {
return [['unlink', this.mainSuggestedPartners]];
}
/**
* @private
* @returns {mail.model[]}
*/
_computeExtraSuggestedRecordsList() {
return this.extraSuggestedRecordsListName
? this[this.extraSuggestedRecordsListName]
: [];
}
/**
* @private
* @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;
}
/**
* @private
* @returns {boolean}
*/
_computeHasUploadingAttachment() {
return this.attachments.some(attachment => attachment.isTemporary);
}
/**
* @private
* @returns {mail.model[]}
*/
_computeMainSuggestedRecordsList() {
return this.mainSuggestedRecordsListName
? this[this.mainSuggestedRecordsListName]
: [];
}
/**
* Detects if mentioned partners are still in the composer text input content
* and removes them if not.
*
* @private
* @returns {mail.partner[]}
*/
_computeMentionedPartners() {
const inputMentions = this.textInputContent.replace('\n', "\n ").match(
new RegExp("@[^ ]+(?= | |$)", 'g')
) || [];
const unmentionedPartners = [];
for (const partner of this.mentionedPartners) {
let inputMention = inputMentions.find(item => {
return item === ("@" + partner.name).replace(/ /g, '\u00a0');
});
if (!inputMention) {
unmentionedPartners.push(partner);
}
}
return [['unlink', unmentionedPartners]];
}
/**
* Detects if mentioned channels are still in the composer text input content
* and removes them if not.
*
* @private
* @returns {mail.partner[]}
*/
_computeMentionedChannels() {
const inputMentions = this.textInputContent.match(
new RegExp("#[^ ]+(?= | |$)", 'g')
) || [];
const unmentionedChannels = [];
for (const channel of this.mentionedChannels) {
let inputMention = inputMentions.find(item => {
return item === ("#" + channel.name).replace(/ /g, '\u00a0');
});
if (!inputMention) {
unmentionedChannels.push(channel);
}
}
return [['unlink', unmentionedChannels]];
}
/**
* @private
* @param {string} htmlString
* @returns {string}
*/
_generateEmojisOnHtml(htmlString) {
for (const emoji of emojis) {
for (const source of emoji.sources) {
const escapedSource = String(source).replace(
/([.*+?=^!:${}()|[\]/\\])/g,
'\\$1');
const regexp = new RegExp(
'(\\s|^)(' + escapedSource + ')(?=\\s|$)',
'g');
htmlString = htmlString.replace(regexp, '$1' + emoji.unicode);
}
}
return htmlString;
}
/**
*
* Generates the html link related to the mentioned partner
*
* @private
* @param {string} body
* @returns {string}
*/
_generateMentionsLinks(body) {
if (this.mentionedPartners.length === 0 && this.mentionedChannels.length === 0) {
return body;
}
const inputMentions = body.replace("
", "
")
.match(new RegExp("(@|#)" + '[^ ]+(?= | |$)', 'g'))
.filter(match => !match.endsWith("
"));
const substrings = [];
let startIndex = 0;
for (const match of inputMentions) {
const suggestionDelimiter = match[0];
const matchName = owl.utils.escape(match.substring(1).replace(new RegExp('\u00a0', 'g'), ' '));
const endIndex = body.indexOf(match, startIndex) + match.length;
let field = "mentionedPartners";
let model = "res.partner";
let cssClass = "o_mail_redirect";
if (suggestionDelimiter === "#") {
field = "mentionedChannels";
model = "mail.channel";
cssClass = "o_channel_redirect";
}
const mention = this[field].find(mention =>
mention.name === matchName
);
let mentionLink = suggestionDelimiter + matchName;
if (mention) {
const baseHREF = this.env.session.url('/web');
const href = `href='${baseHREF}#model=${model}&id=${mention.id}'`;
const attClass = `class='${cssClass}'`;
const dataOeId = `data-oe-id='${mention.id}'`;
const dataOeModel = `data-oe-model='${model}'`;
const target = `target='_blank'`;
mentionLink = `${suggestionDelimiter}${matchName}`;
}
substrings.push(body.substring(startIndex, body.indexOf(match, startIndex)));
substrings.push(mentionLink);
startIndex = endIndex;
}
substrings.push(body.substring(startIndex, body.length));
return substrings.join('');
}
/**
* @private
* @param {string} content html content
* @returns {mail.channel_command|undefined} command, if any in the content
*/
_getCommandFromText(content) {
if (content.startsWith('/')) {
const firstWord = content.substring(1).split(/\s/)[0];
return this.env.messaging.commands.find(command => {
if (command.name !== firstWord) {
return false;
}
if (command.channel_types) {
return command.channel_types.includes(this.thread.channel_type);
}
return true;
});
}
return undefined;
}
/**
* @private
*/
_reset() {
this.closeSuggestions();
this.update({
attachments: [['unlink-all']],
mentionedChannels: [['unlink-all']],
mentionedPartners: [['unlink-all']],
subjectContent: "",
textInputContent: '',
textInputCursorEnd: 0,
textInputCursorStart: 0,
});
}
/**
* @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]]],
});
} 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]]],
});
} 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]]],
});
} 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]]],
});
} else if (this.extraSuggestedPartners[0]) {
this.update({
activeSuggestedPartner: [['link', this.extraSuggestedPartners[0]]],
});
} 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.
*
* @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;
}
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;
}
}
Composer.fields = {
activeSuggestedCannedResponse: many2one('mail.canned_response'),
activeSuggestedChannel: many2one('mail.thread'),
activeSuggestedChannelCommand: many2one('mail.channel_command'),
activeSuggestedPartner: many2one('mail.partner'),
activeSuggestedRecord: attr({
compute: '_computeActiveSuggestedRecord',
dependencies: [
'activeSuggestedCannedResponse',
'activeSuggestedChannel',
'activeSuggestedChannelCommand',
'activeSuggestedPartner',
'activeSuggestedRecordName',
],
}),
activeSuggestedRecordName: attr({
default: "",
}),
attachments: many2many('mail.attachment', {
inverse: 'composers',
}),
/**
* This field watches the uploading (= temporary) status of attachments
* linked to this composer.
*
* Useful to determine whether there are some attachments that are being
* uploaded.
*/
attachmentsAreTemporary: attr({
related: 'attachments.isTemporary',
}),
canPostMessage: attr({
compute: '_computeCanPostMessage',
dependencies: [
'attachments',
'hasUploadingAttachment',
'textInputContent',
],
default: false,
}),
/**
* Instance of discuss if this composer is used as the reply composer
* from Inbox. This field is computed from the inverse relation and
* should be considered read-only.
*/
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"
*/
extraSuggestedRecordsListName: attr({
default: "",
}),
/**
* This field determines whether some attachments linked to this
* composer are being uploaded.
*/
hasUploadingAttachment: attr({
compute: '_computeHasUploadingAttachment',
dependencies: [
'attachments',
'attachmentsAreTemporary',
],
}),
hasFocus: attr({
default: false,
}),
hasSuggestions: attr({
compute: '_computeHasSuggestions',
dependencies: [
'extraSuggestedRecordsListName',
'extraSuggestedPartners',
'mainSuggestedRecordsListName',
'mainSuggestedPartners',
'suggestedCannedResponses',
'suggestedChannelCommands',
'suggestedChannels',
],
default: false,
}),
/**
* If true composer will log a note, else a comment will be posted.
*/
isLog: attr({
default: false,
}),
mainSuggestedRecordsList: attr({
compute: '_computeMainSuggestedRecordsList',
dependencies: [
'mainSuggestedPartners',
'mainSuggestedRecordsListName',
'suggestedCannedResponses',
'suggestedChannelCommands',
'suggestedChannels',
],
}),
/**
* 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'],
}),
/**
* Determines the extra `mail.partner` (on top of existing followers)
* that will receive the message being composed by `this`, and that will
* also be added as follower of `this.thread`.
*/
recipients: many2many('mail.partner', {
compute: '_computeRecipients',
dependencies: [
'mentionedPartners',
'threadSuggestedRecipientInfoListIsSelected',
// FIXME thread.suggestedRecipientInfoList.partner should be a
// dependency, but it is currently impossible to have a related
// m2o through a m2m. task-2261221
]
}),
/**
* Serves as compute dependency.
*/
threadSuggestedRecipientInfoList: many2many('mail.suggested_recipient_info', {
related: 'thread.suggestedRecipientInfoList',
}),
/**
* Serves as compute dependency.
*/
threadSuggestedRecipientInfoListIsSelected: attr({
related: 'threadSuggestedRecipientInfoList.isSelected',
}),
/**
* Composer subject input content.
*/
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 (@)
*/
suggestionDelimiter: attr({
default: "",
}),
suggestionModelName: attr({
default: "",
}),
textInputContent: attr({
default: "",
}),
textInputCursorEnd: attr({
default: 0,
}),
textInputCursorStart: attr({
default: 0,
}),
thread: one2one('mail.thread', {
inverse: 'composer',
}),
};
Composer.modelName = 'mail.composer';
return Composer;
}
registerNewModel('mail.composer', factory);
});