odoo.define('mail/static/src/models/partner/partner.js', function (require) { 'use strict'; 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 { cleanSearchTerm } = require('mail/static/src/utils/utils.js'); function factory(dependencies) { class Partner extends dependencies['mail.model'] { //---------------------------------------------------------------------- // Public //---------------------------------------------------------------------- /** * @static * @private * @param {Object} data * @return {Object} */ static convertData(data) { const data2 = {}; if ('active' in data) { data2.active = data.active; } if ('country' in data) { if (!data.country) { data2.country = [['unlink-all']]; } else { data2.country = [['insert', { id: data.country[0], name: data.country[1], }]]; } } if ('display_name' in data) { data2.display_name = data.display_name; } if ('email' in data) { data2.email = data.email; } if ('id' in data) { data2.id = data.id; } if ('im_status' in data) { data2.im_status = data.im_status; } if ('name' in data) { data2.name = data.name; } // relation if ('user_id' in data) { if (!data.user_id) { data2.user = [['unlink-all']]; } else { let user = {}; if (Array.isArray(data.user_id)) { user = { id: data.user_id[0], display_name: data.user_id[1], }; } else { user = { id: data.user_id, }; } user.isInternalUser = data.is_internal_user; data2.user = [['insert', user]]; } } 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 [ mainSuggestedPartners, extraSuggestedPartners, ] = await this.env.services.rpc( { model: 'res.partner', method: 'get_mention_suggestions', kwargs, }, { shadow: true }, ); const partnersData = mainSuggestedPartners.concat(extraSuggestedPartners); const partners = this.env.models['mail.partner'].insert(partnersData.map(data => this.env.models['mail.partner'].convertData(data) )); if (isNonPublicChannel) { thread.update({ members: [['link', partners]] }); } } /** * Search for partners matching `keyword`. * * @static * @param {Object} param0 * @param {function} param0.callback * @param {string} param0.keyword * @param {integer} [param0.limit=10] */ static async imSearch({ callback, keyword, limit = 10 }) { // prefetched partners let partners = []; 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 && partner.name && partner.user && cleanSearchTerm(partner.name).includes(cleanedSearchTerm) ) { partners.push(partner); } } } if (!partners.length) { const partnersData = await this.env.services.rpc( { model: 'res.partner', method: 'im_search', args: [keyword, limit] }, { shadow: true } ); const newPartners = this.insert(partnersData.map( partnerData => this.convertData(partnerData) )); partners.push(...newPartners); } 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 */ static async startLoopFetchImStatus() { await this._fetchImStatus(); this._loopFetchImStatus(); } /** * Checks whether this partner has a related user and links them if * applicable. */ async checkIsUser() { const userIds = await this.async(() => this.env.services.rpc({ model: 'res.users', method: 'search', args: [[['partner_id', '=', this.id]]], kwargs: { context: { active_test: false }, }, }, { shadow: true })); this.update({ hasCheckedUser: true }); if (userIds.length > 0) { this.update({ user: [['insert', { id: userIds[0] }]] }); } } /** * Gets the chat between the user of this partner and the current user. * * If a chat is not appropriate, a notification is displayed instead. * * @returns {mail.thread|undefined} */ async getChat() { if (!this.user && !this.hasCheckedUser) { await this.async(() => this.checkIsUser()); } // prevent chatting with non-users if (!this.user) { this.env.services['notification'].notify({ message: this.env._t("You can only chat with partners that have a dedicated user."), type: 'info', }); return; } 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. * * If a chat is not appropriate, a notification is displayed instead. * * @param {Object} [options] forwarded to @see `mail.thread:open()` * @returns {mail.thread|undefined} */ async openChat(options) { const chat = await this.async(() => this.getChat()); if (!chat) { return; } await this.async(() => chat.open(options)); return chat; } /** * Opens the most appropriate view that is a profile for this partner. */ async openProfile() { return this.env.messaging.openDocument({ id: this.id, model: 'res.partner', }); } //---------------------------------------------------------------------- // Private //---------------------------------------------------------------------- /** * @private * @returns {string} */ _computeAvatarUrl() { return `/web/image/res.partner/${this.id}/image_128`; } /** * @override */ static _createRecordLocalId(data) { return `${this.modelName}_${data.id}`; } /** * @static * @private */ static async _fetchImStatus() { const partnerIds = []; for (const partner of this.all()) { if (partner.im_status !== 'im_partner' && partner.id > 0) { partnerIds.push(partner.id); } } if (partnerIds.length === 0) { return; } const dataList = await this.env.services.rpc({ route: '/longpolling/im_status', params: { partner_ids: partnerIds, }, }, { shadow: true }); this.insert(dataList); } /** * @static * @private */ static _loopFetchImStatus() { setTimeout(async () => { await this._fetchImStatus(); this._loopFetchImStatus(); }, 50 * 1000); } /** * @private * @returns {string|undefined} */ _computeDisplayName() { return this.display_name || this.user && this.user.display_name; } /** * @private * @returns {mail.messaging} */ _computeMessaging() { return [['link', this.env.messaging]]; } /** * @private * @returns {string|undefined} */ _computeNameOrDisplayName() { return this.name || this.display_name; } } Partner.fields = { active: attr({ default: true, }), avatarUrl: attr({ compute: '_computeAvatarUrl', dependencies: [ 'id', ], }), correspondentThreads: one2many('mail.thread', { inverse: 'correspondent', }), country: many2one('mail.country'), display_name: attr({ compute: '_computeDisplayName', default: "", dependencies: [ 'display_name', 'userDisplayName', ], }), email: attr(), failureNotifications: one2many('mail.notification', { related: 'messagesAsAuthor.failureNotifications', }), /** * Whether an attempt was already made to fetch the user corresponding * to this partner. This prevents doing the same RPC multiple times. */ hasCheckedUser: attr({ default: false, }), id: attr(), im_status: attr(), memberThreads: many2many('mail.thread', { inverse: 'members', }), messagesAsAuthor: one2many('mail.message', { inverse: 'author', }), /** * Serves as compute dependency. */ messaging: many2one('mail.messaging', { compute: '_computeMessaging', }), model: attr({ default: 'res.partner', }), /** * Channels that are moderated by this partner. */ moderatedChannels: many2many('mail.thread', { inverse: 'moderators', }), name: attr(), nameOrDisplayName: attr({ compute: '_computeNameOrDisplayName', dependencies: [ 'display_name', 'name', ], }), user: one2one('mail.user', { inverse: 'partner', }), /** * Serves as compute dependency. */ userDisplayName: attr({ related: 'user.display_name', }), }; Partner.modelName = 'mail.partner'; return Partner; } registerNewModel('mail.partner', factory); });