diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/models/message/message.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/models/message/message.js')
| -rw-r--r-- | addons/mail/static/src/models/message/message.js | 817 |
1 files changed, 817 insertions, 0 deletions
diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js new file mode 100644 index 00000000..f5c45bfa --- /dev/null +++ b/addons/mail/static/src/models/message/message.js @@ -0,0 +1,817 @@ +odoo.define('mail/static/src/models/message/message.js', function (require) { +'use strict'; + +const emojis = require('mail.emojis'); +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js'); +const { clear } = require('mail/static/src/model/model_field_command.js'); +const { addLink, htmlToTextContentInline, parseAndTransform, timeFromNow } = require('mail.utils'); + +const { str_to_datetime } = require('web.time'); + +function factory(dependencies) { + + class Message extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + */ + static checkAll(thread, threadStringifiedDomain) { + const threadCache = thread.cache(threadStringifiedDomain); + threadCache.update({ checkedMessages: [['link', threadCache.messages]] }); + } + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('attachment_ids' in data) { + if (!data.attachment_ids) { + data2.attachments = [['unlink-all']]; + } else { + data2.attachments = [ + ['insert-and-replace', data.attachment_ids.map(attachmentData => + this.env.models['mail.attachment'].convertData(attachmentData) + )], + ]; + } + } + if ('author_id' in data) { + if (!data.author_id) { + data2.author = [['unlink-all']]; + } else if (data.author_id[0] !== 0) { + // partner id 0 is a hack of message_format to refer to an + // author non-related to a partner. display_name equals + // email_from, so this is omitted due to being redundant. + data2.author = [ + ['insert', { + display_name: data.author_id[1], + id: data.author_id[0], + }], + ]; + } + } + if ('body' in data) { + data2.body = data.body; + } + if ('channel_ids' in data && data.channel_ids) { + const channels = data.channel_ids + .map(channelId => + this.env.models['mail.thread'].findFromIdentifyingData({ + id: channelId, + model: 'mail.channel', + }) + ).filter(channel => !!channel); + data2.serverChannels = [['replace', channels]]; + } + if ('date' in data && data.date) { + data2.date = moment(str_to_datetime(data.date)); + } + if ('email_from' in data) { + data2.email_from = data.email_from; + } + if ('history_partner_ids' in data) { + data2.isHistory = data.history_partner_ids.includes(this.env.messaging.currentPartner.id); + } + if ('id' in data) { + data2.id = data.id; + } + if ('is_discussion' in data) { + data2.is_discussion = data.is_discussion; + } + if ('is_note' in data) { + data2.is_note = data.is_note; + } + if ('is_notification' in data) { + data2.is_notification = data.is_notification; + } + if ('message_type' in data) { + data2.message_type = data.message_type; + } + if ('model' in data && 'res_id' in data && data.model && data.res_id) { + const originThreadData = { + id: data.res_id, + model: data.model, + }; + if ('record_name' in data && data.record_name) { + originThreadData.name = data.record_name; + } + if ('res_model_name' in data && data.res_model_name) { + originThreadData.model_name = data.res_model_name; + } + if ('module_icon' in data) { + originThreadData.moduleIcon = data.module_icon; + } + data2.originThread = [['insert', originThreadData]]; + } + if ('moderation_status' in data) { + data2.moderation_status = data.moderation_status; + } + if ('needaction_partner_ids' in data) { + data2.isNeedaction = data.needaction_partner_ids.includes(this.env.messaging.currentPartner.id); + } + if ('notifications' in data) { + data2.notifications = [['insert', data.notifications.map(notificationData => + this.env.models['mail.notification'].convertData(notificationData) + )]]; + } + if ('starred_partner_ids' in data) { + data2.isStarred = data.starred_partner_ids.includes(this.env.messaging.currentPartner.id); + } + if ('subject' in data) { + data2.subject = data.subject; + } + if ('subtype_description' in data) { + data2.subtype_description = data.subtype_description; + } + if ('subtype_id' in data) { + data2.subtype_id = data.subtype_id; + } + if ('tracking_value_ids' in data) { + data2.tracking_value_ids = data.tracking_value_ids; + } + + return data2; + } + + /** + * Mark all messages of current user with given domain as read. + * + * @static + * @param {Array[]} domain + */ + static async markAllAsRead(domain) { + await this.env.services.rpc({ + model: 'mail.message', + method: 'mark_all_as_read', + kwargs: { domain }, + }); + } + + /** + * Mark provided messages as read. Messages that have been marked as + * read are acknowledged by server with response as longpolling + * notification of following format: + * + * [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }] + * + * @see mail.messaging_notification_handler:_handleNotificationPartnerMarkAsRead() + * + * @static + * @param {mail.message[]} messages + */ + static async markAsRead(messages) { + await this.env.services.rpc({ + model: 'mail.message', + method: 'set_message_done', + args: [messages.map(message => message.id)] + }); + } + + /** + * Applies the moderation `decision` on the provided messages. + * + * @static + * @param {mail.message[]} messages + * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject' + * @param {Object|undefined} [kwargs] optional data to pass on + * message moderation. This is provided when rejecting the messages + * for which title and comment give reason(s) for reject. + * @param {string} [kwargs.title] + * @param {string} [kwargs.comment] + */ + static async moderate(messages, decision, kwargs) { + const messageIds = messages.map(message => message.id); + await this.env.services.rpc({ + model: 'mail.message', + method: 'moderate', + args: [messageIds, decision], + kwargs: kwargs, + }); + } + /** + * Performs the `message_fetch` RPC on `mail.message`. + * + * @static + * @param {Array[]} domain + * @param {integer} [limit] + * @param {integer[]} [moderated_channel_ids] + * @param {Object} [context] + * @returns {mail.message[]} + */ + static async performRpcMessageFetch(domain, limit, moderated_channel_ids, context) { + const messagesData = await this.env.services.rpc({ + model: 'mail.message', + method: 'message_fetch', + kwargs: { + context, + domain, + limit, + moderated_channel_ids, + }, + }, { shadow: true }); + const messages = this.env.models['mail.message'].insert(messagesData.map( + messageData => this.env.models['mail.message'].convertData(messageData) + )); + // compute seen indicators (if applicable) + for (const message of messages) { + for (const thread of message.threads) { + if (thread.model !== 'mail.channel' || thread.channel_type === 'channel') { + // disabled on non-channel threads and + // on `channel` channels for performance reasons + continue; + } + this.env.models['mail.message_seen_indicator'].insert({ + channelId: thread.id, + messageId: message.id, + }); + } + } + return messages; + } + + /** + * @static + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + */ + static uncheckAll(thread, threadStringifiedDomain) { + const threadCache = thread.cache(threadStringifiedDomain); + threadCache.update({ checkedMessages: [['unlink', threadCache.messages]] }); + } + + /** + * Unstar all starred messages of current user. + */ + static async unstarAll() { + await this.env.services.rpc({ + model: 'mail.message', + method: 'unstar_all', + }); + } + + /** + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + * @returns {boolean} + */ + isChecked(thread, threadStringifiedDomain) { + // aku todo + const relatedCheckedThreadCache = this.checkedThreadCaches.find( + threadCache => ( + threadCache.thread === thread && + threadCache.stringifiedDomain === threadStringifiedDomain + ) + ); + return !!relatedCheckedThreadCache; + } + + /** + * Mark this message as read, so that it no longer appears in current + * partner Inbox. + */ + async markAsRead() { + await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'set_message_done', + args: [[this.id]] + })); + } + + /** + * Applies the moderation `decision` on this message. + * + * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject' + * @param {Object|undefined} [kwargs] optional data to pass on + * message moderation. This is provided when rejecting the messages + * for which title and comment give reason(s) for reject. + * @param {string} [kwargs.title] + * @param {string} [kwargs.comment] + */ + async moderate(decision, kwargs) { + await this.async(() => this.constructor.moderate([this], decision, kwargs)); + } + + /** + * Opens the view that allows to resend the message in case of failure. + */ + openResendAction() { + this.env.bus.trigger('do-action', { + action: 'mail.mail_resend_message_action', + options: { + additional_context: { + mail_message_to_resend: this.id, + }, + }, + }); + } + + /** + * Refreshes the value of `dateFromNow` field to the "current now". + */ + refreshDateFromNow() { + this.update({ dateFromNow: this._computeDateFromNow() }); + } + + /** + * Action to initiate reply to current message in Discuss Inbox. Assumes + * that Discuss and Inbox are already opened. + */ + replyTo() { + this.env.messaging.discuss.replyToMessage(this); + } + + /** + * Toggle check state of this message in the context of the provided + * thread and its stringifiedDomain. + * + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + */ + toggleCheck(thread, threadStringifiedDomain) { + const threadCache = thread.cache(threadStringifiedDomain); + if (threadCache.checkedMessages.includes(this)) { + threadCache.update({ checkedMessages: [['unlink', this]] }); + } else { + threadCache.update({ checkedMessages: [['link', this]] }); + } + } + + /** + * Toggle the starred status of the provided message. + */ + async toggleStar() { + await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'toggle_message_starred', + args: [[this.id]] + })); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + return `${this.modelName}_${data.id}`; + } + + /** + * @returns {string} + */ + _computeDateFromNow() { + if (!this.date) { + return clear(); + } + return timeFromNow(this.date); + } + + /** + * @returns {boolean} + */ + _computeFailureNotifications() { + return [['replace', this.notifications.filter(notifications => + ['exception', 'bounce'].includes(notifications.notification_status) + )]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasCheckbox() { + return this.isModeratedByCurrentPartner; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsCurrentPartnerAuthor() { + return !!( + this.author && + this.messagingCurrentPartner && + this.messagingCurrentPartner === this.author + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsBodyEqualSubtypeDescription() { + if (!this.body || !this.subtype_description) { + return false; + } + const inlineBody = htmlToTextContentInline(this.body); + return inlineBody.toLowerCase() === this.subtype_description.toLowerCase(); + } + + /** + * The method does not attempt to cover all possible cases of empty + * messages, but mostly those that happen with a standard flow. Indeed + * it is preferable to be defensive and show an empty message sometimes + * instead of hiding a non-empty message. + * + * The main use case for when a message should become empty is for a + * message posted with only an attachment (no body) and then the + * attachment is deleted. + * + * The main use case for being defensive with the check is when + * receiving a message that has no textual content but has other + * meaningful HTML tags (eg. just an <img/>). + * + * @private + * @returns {boolean} + */ + _computeIsEmpty() { + const isBodyEmpty = ( + !this.body || + [ + '', + '<p></p>', + '<p><br></p>', + '<p><br/></p>', + ].includes(this.body.replace(/\s/g, '')) + ); + return ( + isBodyEmpty && + this.attachments.length === 0 && + this.tracking_value_ids.length === 0 && + !this.subtype_description + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsModeratedByCurrentPartner() { + return ( + this.moderation_status === 'pending_moderation' && + this.originThread && + this.originThread.isModeratedByCurrentPartner + ); + } + /** + * @private + * @returns {boolean} + */ + _computeIsSubjectSimilarToOriginThreadName() { + if ( + !this.subject || + !this.originThread || + !this.originThread.name + ) { + return false; + } + const threadName = this.originThread.name.toLowerCase().trim(); + const prefixList = ['re:', 'fw:', 'fwd:']; + let cleanedSubject = this.subject.toLowerCase(); + let wasSubjectCleaned = true; + while (wasSubjectCleaned) { + wasSubjectCleaned = false; + if (threadName === cleanedSubject) { + return true; + } + for (const prefix of prefixList) { + if (cleanedSubject.startsWith(prefix)) { + cleanedSubject = cleanedSubject.replace(prefix, '').trim(); + wasSubjectCleaned = true; + break; + } + } + } + return false; + } + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * This value is meant to be based on field body which is + * returned by the server (and has been sanitized before stored into db). + * Do not use this value in a 't-raw' if the message has been created + * directly from user input and not from server data as it's not escaped. + * + * @private + * @returns {string} + */ + _computePrettyBody() { + let prettyBody; + for (const emoji of emojis) { + const { unicode } = emoji; + const regexp = new RegExp( + `(?:^|\\s|<[a-z]*>)(${unicode})(?=\\s|$|</[a-z]*>)`, + "g" + ); + const originalBody = this.body; + prettyBody = this.body.replace( + regexp, + ` <span class="o_mail_emoji">${unicode}</span> ` + ); + // Idiot-proof limit. If the user had the amazing idea of + // copy-pasting thousands of emojis, the image rendering can lead + // to memory overflow errors on some browsers (e.g. Chrome). Set an + // arbitrary limit to 200 from which we simply don't replace them + // (anyway, they are already replaced by the unicode counterpart). + if (_.str.count(prettyBody, "o_mail_emoji") > 200) { + prettyBody = originalBody; + } + } + // add anchor tags to urls + return parseAndTransform(prettyBody, addLink); + } + + /** + * @private + * @returns {mail.thread[]} + */ + _computeThreads() { + const threads = [...this.serverChannels]; + if (this.isHistory) { + threads.push(this.env.messaging.history); + } + if (this.isNeedaction) { + threads.push(this.env.messaging.inbox); + } + if (this.isStarred) { + threads.push(this.env.messaging.starred); + } + if (this.env.messaging.moderation && this.isModeratedByCurrentPartner) { + threads.push(this.env.messaging.moderation); + } + if (this.originThread) { + threads.push(this.originThread); + } + return [['replace', threads]]; + } + + } + + Message.fields = { + attachments: many2many('mail.attachment', { + inverse: 'messages', + }), + author: many2one('mail.partner', { + inverse: 'messagesAsAuthor', + }), + /** + * This value is meant to be returned by the server + * (and has been sanitized before stored into db). + * Do not use this value in a 't-raw' if the message has been created + * directly from user input and not from server data as it's not escaped. + */ + body: attr({ + default: "", + }), + checkedThreadCaches: many2many('mail.thread_cache', { + inverse: 'checkedMessages', + }), + date: attr({ + default: moment(), + }), + /** + * States the time elapsed since date up to now. + */ + dateFromNow: attr({ + compute: '_computeDateFromNow', + dependencies: [ + 'date', + ], + }), + email_from: attr(), + failureNotifications: one2many('mail.notification', { + compute: '_computeFailureNotifications', + dependencies: ['notificationsStatus'], + }), + hasCheckbox: attr({ + compute: '_computeHasCheckbox', + default: false, + dependencies: ['isModeratedByCurrentPartner'], + }), + id: attr(), + isCurrentPartnerAuthor: attr({ + compute: '_computeIsCurrentPartnerAuthor', + default: false, + dependencies: [ + 'author', + 'messagingCurrentPartner', + ], + }), + /** + * States whether `body` and `subtype_description` contain similar + * values. + * + * This is necessary to avoid displaying both of them together when they + * contain duplicate information. This will especially happen with + * messages that are posted automatically at the creation of a record + * (messages that serve as tracking messages). They do have hard-coded + * "record created" body while being assigned a subtype with a + * description that states the same information. + * + * Fixing newer messages is possible by not assigning them a duplicate + * body content, but the check here is still necessary to handle + * existing messages. + * + * Limitations: + * - A translated subtype description might not match a non-translatable + * body created by a user with a different language. + * - Their content might be mostly but not exactly the same. + */ + isBodyEqualSubtypeDescription: attr({ + compute: '_computeIsBodyEqualSubtypeDescription', + default: false, + dependencies: [ + 'body', + 'subtype_description', + ], + }), + /** + * Determine whether the message has to be considered empty or not. + * + * An empty message has no text, no attachment and no tracking value. + */ + isEmpty: attr({ + compute: '_computeIsEmpty', + dependencies: [ + 'attachments', + 'body', + 'subtype_description', + 'tracking_value_ids', + ], + }), + isModeratedByCurrentPartner: attr({ + compute: '_computeIsModeratedByCurrentPartner', + default: false, + dependencies: [ + 'moderation_status', + 'originThread', + 'originThreadIsModeratedByCurrentPartner', + ], + }), + /** + * States whether `originThread.name` and `subject` contain similar + * values except it contains the extra prefix at the start + * of the subject. + * + * This is necessary to avoid displaying the subject, if + * the subject is same as threadname. + */ + isSubjectSimilarToOriginThreadName: attr({ + compute: '_computeIsSubjectSimilarToOriginThreadName', + dependencies: [ + 'originThread', + 'originThreadName', + 'subject', + ], + }), + isTemporary: attr({ + default: false, + }), + isTransient: attr({ + default: false, + }), + is_discussion: attr({ + default: false, + }), + /** + * Determine whether the message was a needaction. Useful to make it + * present in history mailbox. + */ + isHistory: attr({ + default: false, + }), + /** + * Determine whether the message is needaction. Useful to make it + * present in inbox mailbox and messaging menu. + */ + isNeedaction: attr({ + default: false, + }), + is_note: attr({ + default: false, + }), + is_notification: attr({ + default: false, + }), + /** + * Determine whether the message is starred. Useful to make it present + * in starred mailbox. + */ + isStarred: attr({ + default: false, + }), + message_type: attr(), + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + messagingCurrentPartner: many2one('mail.partner', { + related: 'messaging.currentPartner', + }), + messagingHistory: many2one('mail.thread', { + related: 'messaging.history', + }), + messagingInbox: many2one('mail.thread', { + related: 'messaging.inbox', + }), + messagingModeration: many2one('mail.thread', { + related: 'messaging.moderation', + }), + messagingStarred: many2one('mail.thread', { + related: 'messaging.starred', + }), + moderation_status: attr(), + notifications: one2many('mail.notification', { + inverse: 'message', + isCausal: true, + }), + notificationsStatus: attr({ + default: [], + related: 'notifications.notification_status', + }), + /** + * Origin thread of this message (if any). + */ + originThread: many2one('mail.thread', { + inverse: 'messagesAsOriginThread', + }), + originThreadIsModeratedByCurrentPartner: attr({ + default: false, + related: 'originThread.isModeratedByCurrentPartner', + }), + /** + * Serves as compute dependency for isSubjectSimilarToOriginThreadName + */ + originThreadName: attr({ + related: 'originThread.name', + }), + /** + * This value is meant to be based on field body which is + * returned by the server (and has been sanitized before stored into db). + * Do not use this value in a 't-raw' if the message has been created + * directly from user input and not from server data as it's not escaped. + */ + prettyBody: attr({ + compute: '_computePrettyBody', + dependencies: ['body'], + }), + subject: attr(), + subtype_description: attr(), + subtype_id: attr(), + /** + * All threads that this message is linked to. This field is read-only. + */ + threads: many2many('mail.thread', { + compute: '_computeThreads', + dependencies: [ + 'isHistory', + 'isModeratedByCurrentPartner', + 'isNeedaction', + 'isStarred', + 'messagingHistory', + 'messagingInbox', + 'messagingModeration', + 'messagingStarred', + 'originThread', + 'serverChannels', + ], + inverse: 'messages', + }), + tracking_value_ids: attr({ + default: [], + }), + /** + * All channels containing this message on the server. + * Equivalent of python field `channel_ids`. + */ + serverChannels: many2many('mail.thread', { + inverse: 'messagesAsServerChannel', + }), + }; + + Message.modelName = 'mail.message'; + + return Message; +} + +registerNewModel('mail.message', factory); + +}); |
