/** @odoo-module **/ import { registerModel } from '@mail/model/model_core'; import { attr, many, one } from '@mail/model/model_field'; import { clear, insert } from '@mail/model/model_field_command'; import { addLink, htmlToTextContentInline, parseAndTransform } from '@mail/js/utils'; import { session } from '@web/session'; import { getLangDatetimeFormat, str_to_datetime } from 'web.time'; const { markup } = owl; registerModel({ name: 'Message', modelMethods: { /** * @param {Object} data * @return {Object} */ convertData(data) { const data2 = {}; data2.attachments = data.attachment_ids; if ('author' in data) { data2.author = data.author; } if ('body' in data) { data2.body = data.body; } 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 ('guestAuthor' in data) { data2.guestAuthor = data.guestAuthor; } if ('history_partner_ids' in data && this.messaging.currentPartner) { data2.isHistory = data.history_partner_ids.includes(this.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; } data2.linkPreviews = data.linkPreviews; if ('messageReactionGroups' in data) { data2.messageReactionGroups = data.messageReactionGroups; } 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 = originThreadData; } if ('needaction_partner_ids' in data && this.messaging.currentPartner) { data2.isNeedaction = data.needaction_partner_ids.includes(this.messaging.currentPartner.id); } if ('notifications' in data) { data2.notifications = insert(data.notifications.map(notificationData => this.messaging.models['Notification'].convertData(notificationData) )); } if ('parentMessage' in data) { if (!data.parentMessage) { data2.parentMessage = clear(); } else { data2.parentMessage = this.convertData(data.parentMessage); } } if ('recipients' in data) { data2.recipients = data.recipients; } if ('starred_partner_ids' in data && this.messaging.currentPartner) { data2.isStarred = data.starred_partner_ids.includes(this.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 ('trackingValues' in data) { data2.trackingValues = data.trackingValues; } return data2; }, /** * Mark all messages of current user with given domain as read. * * @param {Array[]} domain */ async markAllAsRead(domain) { await this.messaging.rpc({ model: 'mail.message', method: 'mark_all_as_read', kwargs: { domain }, }, { shadow: true }); }, /** * Mark provided messages as read. Messages that have been marked as * read are acknowledged by server with response as bus. * notification of following format: * * [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }] * * @see MessagingNotificationHandler:_handleNotificationPartnerMarkAsRead() * * @param {Message[]} messages */ async markAsRead(messages) { await this.messaging.rpc({ model: 'mail.message', method: 'set_message_done', args: [messages.map(message => message.id)] }); }, /** * Performs the given `route` RPC to fetch messages. * * @param {string} route * @param {Object} params * @returns {Message[]} */ async performRpcMessageFetch(route, params) { const messagesData = await this.messaging.rpc({ route, params }, { shadow: true }); if (!this.messaging) { return; } const messages = this.messaging.models['Message'].insert(messagesData.map( messageData => this.messaging.models['Message'].convertData(messageData) )); // compute seen indicators (if applicable) for (const message of messages) { for (const thread of message.threads) { if (!thread.channel || thread.channel.channel_type === 'channel') { // disabled on non-channel threads and // on `channel` channels for performance reasons continue; } this.messaging.models['MessageSeenIndicator'].insert({ thread, message, }); } } return messages; }, /** * Unstar all starred messages of current user. */ async unstarAll() { await this.messaging.rpc({ model: 'mail.message', method: 'unstar_all', }); }, }, recordMethods: { /** * Adds the given reaction on this message. * * @param {string} content */ async addReaction(content) { const messageData = await this.messaging.rpc({ route: '/mail/message/add_reaction', params: { content, message_id: this.id }, }); if (!this.exists()) { return; } this.update(messageData); }, /** * Mark this message as read, so that it no longer appears in current * partner Inbox. */ async markAsRead() { await this.messaging.rpc({ model: 'mail.message', method: 'set_message_done', args: [[this.id]] }); }, /** * Opens the view that allows to resend the message in case of failure. */ openResendAction() { this.env.services.action.doAction( 'mail.mail_resend_message_action', { additionalContext: { mail_message_to_resend: this.id, }, } ); }, /** * Removes the given reaction from this message. * * @param {string} content */ async removeReaction(content) { const messageData = await this.messaging.rpc({ route: '/mail/message/remove_reaction', params: { content, message_id: this.id }, }); if (!this.exists()) { return; } this.update(messageData); }, /** * Toggle the starred status of the provided message. */ async toggleStar() { await this.messaging.rpc({ model: 'mail.message', method: 'toggle_message_starred', args: [[this.id]] }); }, /** * Updates the message's content. * * @param {Object} param0 * @param {string} param0.body the new body of the message * @param {number[]} param0.attachment_ids * @param {string[]} param0.attachment_tokens */ async updateContent({ body, attachment_ids, attachment_tokens }) { const messageData = await this.messaging.rpc({ route: '/mail/message/update_content', params: { body, attachment_ids, attachment_tokens, message_id: this.id, }, }); if (!this.messaging) { return; } this.messaging.models['Message'].insert(messageData); }, }, fields: { authorName: attr({ compute() { if (this.author) { return this.author.nameOrDisplayName; } if (this.guestAuthor) { return this.guestAuthor.name; } if (this.email_from) { return this.email_from; } return this.env._t("Anonymous"); }, }), attachments: many('Attachment', { inverse: 'messages', }), author: one('Partner'), avatarUrl: attr({ compute() { if (this.author && (!this.originThread || this.originThread.model !== 'mail.channel')) { // TODO FIXME for public user this might not be accessible. task-2223236 // we should probably use the correspondig attachment id + access token // or create a dedicated route to get message image, checking the access right of the message return this.author.avatarUrl; } else if (this.author && this.originThread && this.originThread.model === 'mail.channel') { return `/mail/channel/${this.originThread.id}/partner/${this.author.id}/avatar_128`; } else if (this.guestAuthor && (!this.originThread || this.originThread.model !== 'mail.channel')) { return this.guestAuthor.avatarUrl; } else if (this.guestAuthor && this.originThread && this.originThread.model === 'mail.channel') { return `/mail/channel/${this.originThread.id}/guest/${this.guestAuthor.id}/avatar_128?unique=${this.guestAuthor.name}`; } else if (this.message_type === 'email') { return '/mail/static/src/img/email_icon.png'; } return '/mail/static/src/img/smiley/avatar.jpg'; }, }), /** * 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: "", }), /** * Whether this message can be deleted. */ canBeDeleted: attr({ compute() { if (!session.is_admin && !this.isCurrentUserOrGuestAuthor) { return false; } if (!this.originThread) { return false; } if (this.trackingValues.length > 0) { return false; } if (this.message_type !== 'comment') { return false; } if (this.originThread.model === 'mail.channel') { return true; } return this.is_note; }, }), /** * Whether this message can be starred/unstarred. */ canStarBeToggled: attr({ compute() { return !this.messaging.isCurrentUserGuest && !this.isTemporary && !this.isTransient; }, }), /** * Determines the date of the message as a moment object. */ date: attr(), /** * States the date of this message as a string (either a relative period * in the near past or an actual date for older dates). */ dateDay: attr({ compute() { if (!this.date) { // Without a date, we assume that it's a today message. This is // mainly done to avoid flicker inside the UI. return this.env._t("Today"); } const date = this.date.format('YYYY-MM-DD'); if (date === moment().format('YYYY-MM-DD')) { return this.env._t("Today"); } else if ( date === moment() .subtract(1, 'days') .format('YYYY-MM-DD') ) { return this.env._t("Yesterday"); } return this.date.format('LL'); }, }), /** * The date time of the message at current user locale time. */ datetime: attr({ compute() { if (!this.date) { return clear(); } return this.date.format(getLangDatetimeFormat()); }, }), email_from: attr(), failureNotifications: many('Notification', { compute() { return this.notifications.filter(notifications => notifications.isFailure); }, }), guestAuthor: one('Guest', { inverse: 'authoredMessages', }), /** * States whether the message has some attachments. */ hasAttachments: attr({ compute() { return this.attachments.length > 0; }, }), /** * Determines whether the message has a reaction icon. */ hasReactionIcon: attr({ compute() { return !this.isTemporary && !this.isTransient; }, }), id: attr({ identifying: true, }), isCurrentUserOrGuestAuthor: attr({ compute() { return !!( this.author && this.messaging.currentPartner && this.messaging.currentPartner === this.author ) || !!( this.guestAuthor && this.messaging.currentGuest && this.messaging.currentGuest === this.guestAuthor ); }, default: false, }), /** * States if the body field is empty, regardless of editor default * html content. To determine if a message is fully empty, use * `isEmpty`. */ isBodyEmpty: attr({ compute() { return ( !this.body || [ '', '

', '


', '


', ].includes(this.body.replace(/\s/g, '')) ); }, }), /** * 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() { if (!this.body || !this.subtype_description) { return false; } const inlineBody = htmlToTextContentInline(this.body); return inlineBody.toLowerCase() === this.subtype_description.toLowerCase(); }, default: false, }), isDiscussionOrNotification: attr({ compute() { if (this.is_discussion || this.is_notification || this.message_type === "auto_comment") { return true; } return clear(); }, default: false, }), /** * 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({ /** * 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 ). */ compute() { return ( this.isBodyEmpty && !this.hasAttachments && this.trackingValues.length === 0 && !this.subtype_description ); }, }), /** * 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() { 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; }, }), 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 current partner is mentioned. */ isCurrentPartnerMentioned: attr({ compute() { return this.recipients.includes(this.messaging.currentPartner); }, default: false, }), /** * Determine whether the message is highlighted. */ isHighlighted: attr({ compute() { return ( this.isCurrentPartnerMentioned && this.originThread && this.originThread.model === 'mail.channel' ); }, }), /** * Determine whether the message is starred. Useful to make it present * in starred mailbox. */ isStarred: attr({ default: false, }), /** * Last tracking value of the message. */ lastTrackingValue: one('TrackingValue', { compute() { const { length: l, [l - 1]: lastTrackingValue, } = this.trackingValues; if (lastTrackingValue) { return lastTrackingValue; } return clear(); }, }), linkPreviews: many('LinkPreview', { inverse: 'message', }), /** * Groups of reactions per content allowing to know the number of * reactions for each. */ messageReactionGroups: many('MessageReactionGroup', { inverse: 'message', }), messageTypeText: attr({ compute() { if (this.message_type === 'notification') { return this.env._t("System notification"); } if (this.message_type === "auto_comment") { return this.env._t("Automated message"); } if (!this.is_discussion && !this.is_notification) { return this.env._t("Note"); } return this.env._t("Message"); }, }), message_type: attr(), notificationMessageViews: many('NotificationMessageView', { inverse: 'message', isCausal: true, }), /** * States the views that are displaying this message. */ messageViews: many('MessageView', { inverse: 'message', isCausal: true, }), messageListViewItems: many('MessageListViewItem', { inverse: 'message', }), notifications: many('Notification', { inverse: 'message', isCausal: true, }), /** * Origin thread of this message (if any). */ originThread: one('Thread', { inverse: 'messagesAsOriginThread', }), /** * States the message that this message replies to (if any). Only makes * sense on channels. Other types of threads might have a parent message * (parent_id in python) that should be ignored for the purpose of this * feature. */ parentMessage: one('Message'), /** * 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({ /** * 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. */ compute() { if (!this.body) { // body null in db, body will be false instead of empty string return clear(); } // add anchor tags to urls return parseAndTransform(this.body, addLink); }, default: "", }), prettyBodyAsMarkup: attr({ compute() { return markup(this.prettyBody); }, }), recipients: many('Partner'), shortTime: attr({ compute() { if (!this.date) { return clear(); } return this.date.format('hh:mm'); }, }), subject: attr(), subtype_description: attr(), subtype_id: attr(), /** * All threads that this message is linked to. This field is read-only. */ threads: many('Thread', { compute() { const threads = []; if (this.isHistory && this.messaging.history) { threads.push(this.messaging.history.thread); } if (this.isNeedaction && this.messaging.inbox) { threads.push(this.messaging.inbox.thread); } if (this.isStarred && this.messaging.starred) { threads.push(this.messaging.starred.thread); } if (this.originThread) { threads.push(this.originThread); } return threads; }, inverse: 'messages', }), trackingValues: many('TrackingValue', { inverse: 'messageOwner', isCausal: true, sort: [['smaller-first', 'id']], }), }, });