From 3751379f1e9a4c215fb6eb898b4ccc67659b9ace Mon Sep 17 00:00:00 2001 From: stephanchrst Date: Tue, 10 May 2022 21:51:50 +0700 Subject: initial commit 2 --- addons/mail/static/src/models/thread/thread.js | 2324 ++++++++++++++++++++++++ 1 file changed, 2324 insertions(+) create mode 100644 addons/mail/static/src/models/thread/thread.js (limited to 'addons/mail/static/src/models/thread/thread.js') diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js new file mode 100644 index 00000000..1011eec4 --- /dev/null +++ b/addons/mail/static/src/models/thread/thread.js @@ -0,0 +1,2324 @@ +odoo.define('mail/static/src/models/thread/thread.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 { 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) { + + class Thread extends dependencies['mail.model'] { + + /** + * @override + */ + _willCreate() { + const res = super._willCreate(...arguments); + /** + * Timer of current partner that was currently typing something, but + * there is no change on the input for 5 seconds. This is used + * in order to automatically notify other members that current + * partner has stopped typing something, due to making no changes + * on the composer for some time. + */ + this._currentPartnerInactiveTypingTimer = new Timer( + this.env, + () => this.async(() => this._onCurrentPartnerInactiveTypingTimeout()), + 5 * 1000 + ); + /** + * Last 'is_typing' status of current partner that has been notified + * to other members. Useful to prevent spamming typing notifications + * to other members if it hasn't changed. An exception is the + * current partner long typing scenario where current partner has + * to re-send the same typing notification from time to time, so + * that other members do not assume he/she is no longer typing + * something from not receiving any typing notifications for a + * very long time. + * + * Supported values: true/false/undefined. + * undefined makes only sense initially and during current partner + * long typing timeout flow. + */ + this._currentPartnerLastNotifiedIsTyping = undefined; + /** + * Timer of current partner that is typing a very long text. When + * the other members do not receive any typing notification for a + * long time, they must assume that the related partner is no longer + * typing something (e.g. they have closed the browser tab). + * This is a timer to let other members know that current partner + * is still typing something, so that they should not assume he/she + * has stopped typing something. + */ + this._currentPartnerLongTypingTimer = new Timer( + this.env, + () => this.async(() => this._onCurrentPartnerLongTypingTimeout()), + 50 * 1000 + ); + /** + * Determines whether the next request to notify current partner + * typing status should always result to making RPC, regardless of + * whether last notified current partner typing status is the same. + * Most of the time we do not want to notify if value hasn't + * changed, exception being the long typing scenario of current + * partner. + */ + this._forceNotifyNextCurrentPartnerTypingStatus = false; + /** + * Registry of timers of partners currently typing in the thread, + * excluding current partner. This is useful in order to + * automatically unregister typing members when not receive any + * typing notification after a long time. Timers are internally + * indexed by partner records as key. The current partner is + * ignored in this registry of timers. + * + * @see registerOtherMemberTypingMember + * @see unregisterOtherMemberTypingMember + */ + this._otherMembersLongTypingTimers = new Map(); + + /** + * Clearable and cancellable throttled version of the + * `_notifyCurrentPartnerTypingStatus` method. + * This is useful when the current partner posts a message and + * types something else afterwards: it must notify immediately that + * he/she is typing something, instead of waiting for the throttle + * internal timer. + * + * @see _notifyCurrentPartnerTypingStatus + */ + this._throttleNotifyCurrentPartnerTypingStatus = throttle( + this.env, + ({ isTyping }) => this.async(() => this._notifyCurrentPartnerTypingStatus({ isTyping })), + 2.5 * 1000 + ); + return res; + } + + /** + * @override + */ + _willDelete() { + this._currentPartnerInactiveTypingTimer.clear(); + this._currentPartnerLongTypingTimer.clear(); + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + for (const timer of this._otherMembersLongTypingTimers.values()) { + timer.clear(); + } + if (this.isTemporary) { + for (const message of this.messages) { + message.delete(); + } + } + return super._willDelete(...arguments); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {mail.thread} [thread] the concerned thread + */ + static computeLastCurrentPartnerMessageSeenByEveryone(thread = undefined) { + const threads = thread ? [thread] : this.env.models['mail.thread'].all(); + threads.map(localThread => { + localThread.update({ + lastCurrentPartnerMessageSeenByEveryone: localThread._computeLastCurrentPartnerMessageSeenByEveryone(), + }); + }); + } + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = { + messagesAsServerChannel: [], + }; + if ('model' in data) { + data2.model = data.model; + } + if ('channel_type' in data) { + data2.channel_type = data.channel_type; + data2.model = 'mail.channel'; + } + if ('create_uid' in data) { + data2.creator = [['insert', { id: data.create_uid }]]; + } + if ('custom_channel_name' in data) { + data2.custom_channel_name = data.custom_channel_name; + } + if ('group_based_subscription' in data) { + data2.group_based_subscription = data.group_based_subscription; + } + if ('id' in data) { + data2.id = data.id; + } + if ('is_minimized' in data && 'state' in data) { + data2.serverFoldState = data.is_minimized ? data.state : 'closed'; + } + if ('is_moderator' in data) { + data2.is_moderator = data.is_moderator; + } + if ('is_pinned' in data) { + data2.isServerPinned = data.is_pinned; + } + if ('last_message' in data && data.last_message) { + data2.messagesAsServerChannel.push(['insert', { id: data.last_message.id }]); + data2.serverLastMessageId = data.last_message.id; + } + if ('last_message_id' in data && data.last_message_id) { + data2.messagesAsServerChannel.push(['insert', { id: data.last_message_id }]); + data2.serverLastMessageId = data.last_message_id; + } + if ('mass_mailing' in data) { + data2.mass_mailing = data.mass_mailing; + } + if ('moderation' in data) { + data2.moderation = data.moderation; + } + if ('message_needaction_counter' in data) { + data2.message_needaction_counter = data.message_needaction_counter; + } + if ('message_unread_counter' in data) { + data2.serverMessageUnreadCounter = data.message_unread_counter; + } + if ('name' in data) { + data2.name = data.name; + } + if ('public' in data) { + data2.public = data.public; + } + if ('seen_message_id' in data) { + data2.lastSeenByCurrentPartnerMessageId = data.seen_message_id || 0; + } + if ('uuid' in data) { + data2.uuid = data.uuid; + } + + // relations + if ('members' in data) { + if (!data.members) { + data2.members = [['unlink-all']]; + } else { + data2.members = [ + ['insert-and-replace', data.members.map(memberData => + this.env.models['mail.partner'].convertData(memberData) + )], + ]; + } + } + if ('seen_partners_info' in data) { + if (!data.seen_partners_info) { + data2.partnerSeenInfos = [['unlink-all']]; + } else { + /* + * FIXME: not optimal to write on relation given the fact that the relation + * will be (re)computed based on given fields. + * (here channelId will compute partnerSeenInfo.thread)) + * task-2336946 + */ + data2.partnerSeenInfos = [ + ['insert-and-replace', + data.seen_partners_info.map( + ({ fetched_message_id, partner_id, seen_message_id }) => { + return { + channelId: data2.id, + lastFetchedMessage: [fetched_message_id ? ['insert', { id: fetched_message_id }] : ['unlink-all']], + lastSeenMessage: [seen_message_id ? ['insert', { id: seen_message_id }] : ['unlink-all']], + partnerId: partner_id, + }; + }) + ] + ]; + if (data.id || this.id) { + const messageIds = data.seen_partners_info.reduce((currentSet, { fetched_message_id, seen_message_id }) => { + if (fetched_message_id) { + currentSet.add(fetched_message_id); + } + if (seen_message_id) { + currentSet.add(seen_message_id); + } + return currentSet; + }, new Set()); + if (messageIds.size > 0) { + /* + * FIXME: not optimal to write on relation given the fact that the relation + * will be (re)computed based on given fields. + * (here channelId will compute messageSeenIndicator.thread)) + * task-2336946 + */ + data2.messageSeenIndicators = [ + ['insert', + [...messageIds].map(messageId => { + return { + channelId: data.id || this.id, + messageId, + }; + }) + ] + ]; + } + } + } + } + + 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. + * + * @static + * @param {mail.thread[]} threads + */ + static async loadPreviews(threads) { + const channelIds = threads.reduce((list, thread) => { + if (thread.model === 'mail.channel') { + return list.concat(thread.id); + } + return list; + }, []); + if (channelIds.length === 0) { + return; + } + const channelPreviews = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fetch_preview', + args: [channelIds], + }, { shadow: true }); + this.env.models['mail.message'].insert(channelPreviews.filter(p => p.last_message).map( + channelPreview => this.env.models['mail.message'].convertData(channelPreview.last_message) + )); + } + + + /** + * Performs the `channel_fold` RPC on `mail.channel`. + * + * @static + * @param {string} uuid + * @param {string} state + */ + static async performRpcChannelFold(uuid, state) { + return this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fold', + kwargs: { + state, + uuid, + } + }, { shadow: true }); + } + + /** + * Performs the `channel_info` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {integer[]} param0.ids list of id of channels + * @returns {mail.thread[]} + */ + static async performRpcChannelInfo({ ids }) { + const channelInfos = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_info', + args: [ids], + }, { shadow: true }); + const channels = this.env.models['mail.thread'].insert( + channelInfos.map(channelInfo => this.env.models['mail.thread'].convertData(channelInfo)) + ); + // manually force recompute of counter + this.env.messaging.messagingMenu.update(); + return channels; + } + + /** + * Performs the `channel_seen` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {integer[]} param0.ids list of id of channels + * @param {integer[]} param0.lastMessageId + */ + static async performRpcChannelSeen({ ids, lastMessageId }) { + return this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_seen', + args: [ids], + kwargs: { + last_message_id: lastMessageId, + }, + }, { shadow: true }); + } + + /** + * Performs the `channel_pin` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {boolean} [param0.pinned=false] + * @param {string} param0.uuid + */ + static async performRpcChannelPin({ pinned = false, uuid }) { + return this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_pin', + kwargs: { + uuid, + pinned, + }, + }, { shadow: true }); + } + + /** + * Performs the `channel_create` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {string} param0.name + * @param {string} [param0.privacy] + * @returns {mail.thread} the created channel + */ + static async performRpcCreateChannel({ name, privacy }) { + const device = this.env.messaging.device; + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_create', + args: [name, privacy], + kwargs: { + context: Object.assign({}, this.env.session.user_content, { + // optimize the return value by avoiding useless queries + // in non-mobile devices + isMobile: device.isMobile, + }), + }, + }); + return this.env.models['mail.thread'].insert( + this.env.models['mail.thread'].convertData(data) + ); + } + + /** + * Performs the `channel_get` RPC on `mail.channel`. + * + * `openChat` is preferable in business code because it will avoid the + * RPC if the chat already exists. + * + * @static + * @param {Object} param0 + * @param {integer[]} param0.partnerIds + * @param {boolean} [param0.pinForCurrentPartner] + * @returns {mail.thread|undefined} the created or existing chat + */ + static async performRpcCreateChat({ partnerIds, pinForCurrentPartner }) { + const device = this.env.messaging.device; + // TODO FIX: potential duplicate chat task-2276490 + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_get', + kwargs: { + context: Object.assign({}, this.env.session.user_content, { + // optimize the return value by avoiding useless queries + // in non-mobile devices + isMobile: device.isMobile, + }), + partners_to: partnerIds, + pin: pinForCurrentPartner, + }, + }); + if (!data) { + return; + } + return this.env.models['mail.thread'].insert( + this.env.models['mail.thread'].convertData(data) + ); + } + + /** + * Performs the `channel_join_and_get_info` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {integer} param0.channelId + * @returns {mail.thread} the channel that was joined + */ + static async performRpcJoinChannel({ channelId }) { + const device = this.env.messaging.device; + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_join_and_get_info', + args: [[channelId]], + kwargs: { + context: Object.assign({}, this.env.session.user_content, { + // optimize the return value by avoiding useless queries + // in non-mobile devices + isMobile: device.isMobile, + }), + }, + }); + return this.env.models['mail.thread'].insert( + this.env.models['mail.thread'].convertData(data) + ); + } + + /** + * Performs the `execute_command` RPC on `mail.channel`. + * + * @static + * @param {Object} param0 + * @param {integer} param0.channelId + * @param {string} param0.command + * @param {Object} [param0.postData={}] + */ + static async performRpcExecuteCommand({ channelId, command, postData = {} }) { + return this.env.services.rpc({ + model: 'mail.channel', + method: 'execute_command', + args: [[channelId]], + kwargs: Object.assign({ command }, postData), + }); + } + + /** + * Performs the `message_post` RPC on given threadModel. + * + * @static + * @param {Object} param0 + * @param {Object} param0.postData + * @param {integer} param0.threadId + * @param {string} param0.threadModel + * @return {integer} the posted message id + */ + static async performRpcMessagePost({ postData, threadId, threadModel }) { + return this.env.services.rpc({ + model: threadModel, + method: 'message_post', + args: [threadId], + kwargs: postData, + }); + } + + /** + * Performs RPC on the route `/mail/get_suggested_recipients`. + * + * @static + * @param {Object} param0 + * @param {string} param0.model + * @param {integer[]} param0.res_id + */ + static async performRpcMailGetSuggestedRecipients({ model, res_ids }) { + const data = await this.env.services.rpc({ + route: '/mail/get_suggested_recipients', + params: { + model, + res_ids, + }, + }, { shadow: true }); + for (const id in data) { + const recipientInfoList = data[id].map(recipientInfoData => { + const [partner_id, emailInfo, reason] = recipientInfoData; + const [name, email] = emailInfo && mailUtils.parseEmail(emailInfo); + return { + email, + name, + partner: [partner_id ? ['insert', { id: partner_id }] : ['unlink']], + reason, + }; + }); + this.insert({ + id: parseInt(id), + model, + suggestedRecipientInfoList: [['insert-and-replace', recipientInfoList]], + }); + } + } + + /* + * 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} + */ + cache(stringifiedDomain = '[]') { + return this.env.models['mail.thread_cache'].insert({ + stringifiedDomain, + thread: [['link', this]], + }); + } + + /** + * Fetch attachments linked to a record. Useful for populating the store + * with these attachments, which are used by attachment box in the chatter. + */ + async fetchAttachments() { + const attachmentsData = await this.async(() => this.env.services.rpc({ + model: 'ir.attachment', + method: 'search_read', + domain: [ + ['res_id', '=', this.id], + ['res_model', '=', this.model], + ], + fields: ['id', 'name', 'mimetype'], + orderBy: [{ name: 'id', asc: false }], + }, { shadow: true })); + this.update({ + originThreadAttachments: [['insert-and-replace', + attachmentsData.map(data => + this.env.models['mail.attachment'].convertData(data) + ) + ]], + }); + this.update({ areAttachmentsLoaded: true }); + } + + /** + * Fetches suggested recipients. + */ + async fetchAndUpdateSuggestedRecipients() { + if (this.isTemporary) { + return; + } + return this.env.models['mail.thread'].performRpcMailGetSuggestedRecipients({ + model: this.model, + res_ids: [this.id], + }); + } + + /** + * Add current user to provided thread's followers. + */ + async follow() { + await this.async(() => this.env.services.rpc({ + model: this.model, + method: 'message_subscribe', + args: [[this.id]], + kwargs: { + partner_ids: [this.env.messaging.currentPartner.id], + context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187 + }, + })); + this.refreshFollowers(); + 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. + */ + loadNewMessages() { + this.mainCache.loadNewMessages(); + } + + /** + * Mark the specified conversation as fetched. + */ + async markAsFetched() { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fetched', + args: [[this.id]], + }, { shadow: true })); + } + + /** + * Mark the specified conversation as read/seen. + * + * @param {mail.message} message the message to be considered as last seen. + */ + async markAsSeen(message) { + if (this.model !== 'mail.channel') { + return; + } + if (this.pendingSeenMessageId && message.id <= this.pendingSeenMessageId) { + return; + } + if ( + this.lastSeenByCurrentPartnerMessageId && + message.id <= this.lastSeenByCurrentPartnerMessageId + ) { + return; + } + this.update({ pendingSeenMessageId: message.id }); + return this.env.models['mail.thread'].performRpcChannelSeen({ + ids: [this.id], + lastMessageId: message.id, + }); + } + + /** + * Marks as read all needaction messages with this thread as origin. + */ + async markNeedactionMessagesAsOriginThreadAsRead() { + await this.async(() => + this.env.models['mail.message'].markAsRead(this.needactionMessagesAsOriginThread) + ); + } + + /** + * Mark as read all needaction messages of this thread. + */ + async markNeedactionMessagesAsRead() { + await this.async(() => + this.env.models['mail.message'].markAsRead(this.needactionMessages) + ); + } + + /** + * Notifies the server of new fold state. Useful for initial, + * cross-tab, and cross-device chat window state synchronization. + * + * @param {string} state + */ + async notifyFoldStateToServer(state) { + if (this.model !== 'mail.channel') { + // Server sync of fold state is only supported for channels. + return; + } + if (!this.uuid) { + return; + } + return this.env.models['mail.thread'].performRpcChannelFold(this.uuid, state); + } + + /** + * Notify server to leave the current channel. Useful for cross-tab + * and cross-device chat window state synchronization. + * + * Only makes sense if isPendingPinned is set to the desired value. + */ + async notifyPinStateToServer() { + if (this.isPendingPinned) { + await this.env.models['mail.thread'].performRpcChannelPin({ + pinned: true, + uuid: this.uuid, + }); + } else { + this.env.models['mail.thread'].performRpcExecuteCommand({ + channelId: this.id, + command: 'leave', + }); + } + } + + /** + * Opens this thread either as form view, in discuss app, or as a chat + * window. The thread will be opened in an "active" matter, which will + * interrupt current user flow. + * + * @param {Object} [param0] + * @param {boolean} [param0.expanded=false] + */ + async open({ expanded = false } = {}) { + const discuss = this.env.messaging.discuss; + // check if thread must be opened in form view + if (!['mail.box', 'mail.channel'].includes(this.model)) { + if (expanded || discuss.isOpen) { + // Close chat window because having the same thread opened + // both in chat window and as main document does not look + // good. + this.env.messaging.chatWindowManager.closeThread(this); + return this.env.messaging.openDocument({ + id: this.id, + model: this.model, + }); + } + } + // check if thread must be opened in discuss + const device = this.env.messaging.device; + if ( + (!device.isMobile && (discuss.isOpen || expanded)) || + this.model === 'mail.box' + ) { + return discuss.openThread(this); + } + // thread must be opened in chat window + return this.env.messaging.chatWindowManager.openThread(this, { + makeActive: true, + }); + } + + /** + * Opens the most appropriate view that is a profile for this thread. + */ + async openProfile() { + return this.env.messaging.openDocument({ + id: this.id, + model: this.model, + }); + } + + /** + * Pin this thread and notify server of the change. + */ + async pin() { + this.update({ isPendingPinned: true }); + await this.notifyPinStateToServer(); + } + + /** + * Open a dialog to add channels as followers. + */ + promptAddChannelFollower() { + this._promptAddFollower({ mail_invite_follower_channel_only: true }); + } + + /** + * Open a dialog to add partners as followers. + */ + promptAddPartnerFollower() { + this._promptAddFollower({ mail_invite_follower_channel_only: false }); + } + + async refresh() { + if (this.isTemporary) { + return; + } + this.loadNewMessages(); + this.update({ isLoadingAttachments: true }); + await this.async(() => this.fetchAttachments()); + this.update({ isLoadingAttachments: false }); + } + + async refreshActivities() { + if (!this.hasActivities) { + return; + } + if (this.isTemporary) { + return; + } + // A bit "extreme", may be improved + const [{ activity_ids: newActivityIds }] = await this.async(() => this.env.services.rpc({ + model: this.model, + method: 'read', + args: [this.id, ['activity_ids']] + }, { shadow: true })); + const activitiesData = await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'activity_format', + args: [newActivityIds] + }, { shadow: true })); + const activities = this.env.models['mail.activity'].insert(activitiesData.map( + activityData => this.env.models['mail.activity'].convertData(activityData) + )); + this.update({ activities: [['replace', activities]] }); + } + + /** + * Refresh followers information from server. + */ + async refreshFollowers() { + if (this.isTemporary) { + this.update({ followers: [['unlink-all']] }); + return; + } + const { followers } = await this.async(() => this.env.services.rpc({ + route: '/mail/read_followers', + params: { + res_id: this.id, + res_model: this.model, + }, + }, { shadow: true })); + this.update({ areFollowersLoaded: true }); + if (followers.length > 0) { + this.update({ + followers: [['insert-and-replace', followers.map(data => + this.env.models['mail.follower'].convertData(data)) + ]], + }); + } else { + this.update({ + followers: [['unlink-all']], + }); + } + } + + /** + * Refresh the typing status of the current partner. + */ + refreshCurrentPartnerIsTyping() { + this._currentPartnerInactiveTypingTimer.reset(); + } + + /** + * Called to refresh a registered other member partner that is typing + * something. + * + * @param {mail.partner} partner + */ + refreshOtherMemberTypingMember(partner) { + this._otherMembersLongTypingTimers.get(partner).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. + */ + async registerCurrentPartnerIsTyping() { + // Handling of typing timers. + this._currentPartnerInactiveTypingTimer.start(); + this._currentPartnerLongTypingTimer.start(); + // Manage typing member relation. + const currentPartner = this.env.messaging.currentPartner; + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== currentPartner.localId); + newOrderedTypingMemberLocalIds.push(currentPartner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['link', currentPartner]], + }); + // Notify typing status to other members. + await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true }); + } + + /** + * Called to register a new other member partner that is typing + * something. + * + * @param {mail.partner} partner + */ + registerOtherMemberTypingMember(partner) { + const timer = new Timer( + this.env, + () => this.async(() => this._onOtherMemberLongTypingTimeout(partner)), + 60 * 1000 + ); + this._otherMembersLongTypingTimers.set(partner, timer); + timer.start(); + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== partner.localId); + newOrderedTypingMemberLocalIds.push(partner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['link', partner]], + }); + } + + /** + * Rename the given thread with provided new name. + * + * @param {string} newName + */ + async rename(newName) { + if (this.channel_type === 'chat') { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_set_custom_name', + args: [this.id], + kwargs: { + name: newName, + }, + })); + } + this.update({ custom_channel_name: newName }); + } + + /** + * Unfollow current partner from this thread. + */ + async unfollow() { + const currentPartnerFollower = this.followers.find( + follower => follower.partner === this.env.messaging.currentPartner + ); + await this.async(() => currentPartnerFollower.remove()); + } + + /** + * Unpin this thread and notify server of the change. + */ + async unpin() { + this.update({ isPendingPinned: false }); + await this.notifyPinStateToServer(); + } + + /** + * Called when current partner has explicitly stopped inserting some + * input in composer. Useful to notify current partner has currently + * stopped typing something in the composer of this thread to all other + * members. + * + * @param {Object} [param0={}] + * @param {boolean} [param0.immediateNotify=false] if set, is typing + * status of current partner is immediately notified and doesn't + * consume throttling at all. + */ + async unregisterCurrentPartnerIsTyping({ immediateNotify = false } = {}) { + // Handling of typing timers. + this._currentPartnerInactiveTypingTimer.clear(); + this._currentPartnerLongTypingTimer.clear(); + // Manage typing member relation. + const currentPartner = this.env.messaging.currentPartner; + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== currentPartner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['unlink', currentPartner]], + }); + // Notify typing status to other members. + if (immediateNotify) { + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + } + await this.async( + () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: false }) + ); + } + + /** + * Called to unregister an other member partner that is no longer typing + * something. + * + * @param {mail.partner} partner + */ + unregisterOtherMemberTypingMember(partner) { + this._otherMembersLongTypingTimers.get(partner).clear(); + this._otherMembersLongTypingTimers.delete(partner); + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== partner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['unlink', partner]], + }); + } + + /** + * Unsubscribe current user from provided channel. + */ + unsubscribe() { + this.env.messaging.chatWindowManager.closeThread(this); + this.unpin(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _createRecordLocalId(data) { + const { channel_type, id, model } = data; + let threadModel = model; + if (!threadModel && channel_type) { + threadModel = 'mail.channel'; + } + return `${this.modelName}_${threadModel}_${id}`; + } + + /** + * @private + * @returns {mail.attachment[]} + */ + _computeAllAttachments() { + const allAttachments = [...new Set(this.originThreadAttachments.concat(this.attachments))] + .sort((a1, a2) => { + // "uploading" before "uploaded" attachments. + if (!a1.isTemporary && a2.isTemporary) { + return 1; + } + if (a1.isTemporary && !a2.isTemporary) { + return -1; + } + // "most-recent" before "oldest" attachments. + return Math.abs(a2.id) - Math.abs(a1.id); + }); + return [['replace', allAttachments]]; + } + + /** + * @private + * @returns {mail.partner} + */ + _computeCorrespondent() { + if (this.channel_type === 'channel') { + return [['unlink']]; + } + const correspondents = this.members.filter(partner => + partner !== this.env.messaging.currentPartner + ); + if (correspondents.length === 1) { + // 2 members chat + return [['link', correspondents[0]]]; + } + if (this.members.length === 1) { + // chat with oneself + return [['link', this.members[0]]]; + } + return [['unlink']]; + } + + /** + * @private + * @returns {string} + */ + _computeDisplayName() { + if (this.channel_type === 'chat' && this.correspondent) { + return this.custom_channel_name || this.correspondent.nameOrDisplayName; + } + return this.name; + } + + /** + * @private + * @returns {mail.activity[]} + */ + _computeFutureActivities() { + return [['replace', this.activities.filter(activity => activity.state === 'planned')]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasSeenIndicators() { + if (this.model !== 'mail.channel') { + return false; + } + if (this.mass_mailing) { + return false; + } + return ['chat', 'livechat'].includes(this.channel_type); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsChatChannel() { + return this.channel_type === 'chat'; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsCurrentPartnerFollowing() { + return this.followers.some(follower => + follower.partner && follower.partner === this.env.messaging.currentPartner + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsModeratedByCurrentPartner() { + if (!this.messaging) { + return false; + } + if (!this.messaging.currentPartner) { + return false; + } + return this.moderators.includes(this.env.messaging.currentPartner); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsPinned() { + return this.isPendingPinned !== undefined ? this.isPendingPinned : this.isServerPinned; + } + + /** + * @private + * @returns {mail.message} + */ + _computeLastCurrentPartnerMessageSeenByEveryone() { + const otherPartnerSeenInfos = + this.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.messagingCurrentPartner); + if (otherPartnerSeenInfos.length === 0) { + return [['unlink-all']]; + } + + const otherPartnersLastSeenMessageIds = + otherPartnerSeenInfos.map(partnerSeenInfo => + partnerSeenInfo.lastSeenMessage ? partnerSeenInfo.lastSeenMessage.id : 0 + ); + if (otherPartnersLastSeenMessageIds.length === 0) { + return [['unlink-all']]; + } + const lastMessageSeenByAllId = Math.min( + ...otherPartnersLastSeenMessageIds + ); + const currentPartnerOrderedSeenMessages = + this.orderedNonTransientMessages.filter(message => + message.author === this.messagingCurrentPartner && + message.id <= lastMessageSeenByAllId); + + if ( + !currentPartnerOrderedSeenMessages || + currentPartnerOrderedSeenMessages.length === 0 + ) { + return [['unlink-all']]; + } + return [['link', currentPartnerOrderedSeenMessages.slice().pop()]]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastMessage() { + const { + length: l, + [l - 1]: lastMessage, + } = this.orderedMessages; + if (lastMessage) { + return [['link', lastMessage]]; + } + return [['unlink']]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastNonTransientMessage() { + const { + length: l, + [l - 1]: lastMessage, + } = this.orderedNonTransientMessages; + if (lastMessage) { + return [['link', lastMessage]]; + } + return [['unlink']]; + } + + /** + * Adjusts the last seen message received from the server to consider + * the following messages also as read if they are either transient + * messages or messages from the current partner. + * + * @private + * @returns {integer} + */ + _computeLastSeenByCurrentPartnerMessageId() { + const firstMessage = this.orderedMessages[0]; + if ( + firstMessage && + this.lastSeenByCurrentPartnerMessageId && + this.lastSeenByCurrentPartnerMessageId < firstMessage.id + ) { + // no deduction can be made if there is a gap + return this.lastSeenByCurrentPartnerMessageId; + } + let lastSeenByCurrentPartnerMessageId = this.lastSeenByCurrentPartnerMessageId; + for (const message of this.orderedMessages) { + if (message.id <= this.lastSeenByCurrentPartnerMessageId) { + continue; + } + if ( + message.author === this.env.messaging.currentPartner || + message.isTransient + ) { + lastSeenByCurrentPartnerMessageId = message.id; + continue; + } + return lastSeenByCurrentPartnerMessageId; + } + return lastSeenByCurrentPartnerMessageId; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastNeedactionMessage() { + const orderedNeedactionMessages = this.needactionMessages.sort( + (m1, m2) => m1.id < m2.id ? -1 : 1 + ); + const { + length: l, + [l - 1]: lastNeedactionMessage, + } = orderedNeedactionMessages; + if (lastNeedactionMessage) { + return [['link', lastNeedactionMessage]]; + } + return [['unlink']]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastNeedactionMessageAsOriginThread() { + const orderedNeedactionMessagesAsOriginThread = this.needactionMessagesAsOriginThread.sort( + (m1, m2) => m1.id < m2.id ? -1 : 1 + ); + const { + length: l, + [l - 1]: lastNeedactionMessageAsOriginThread, + } = orderedNeedactionMessagesAsOriginThread; + if (lastNeedactionMessageAsOriginThread) { + return [['link', lastNeedactionMessageAsOriginThread]]; + } + return [['unlink']]; + } + + /** + * @private + * @returns {mail.thread_cache} + */ + _computeMainCache() { + return [['link', this.cache()]]; + } + + /** + * @private + * @returns {integer} + */ + _computeLocalMessageUnreadCounter() { + if (this.model !== 'mail.channel') { + // unread counter only makes sense on channels + return clear(); + } + // By default trust the server up to the last message it used + // because it's not possible to do better. + let baseCounter = this.serverMessageUnreadCounter; + let countFromId = this.serverLastMessageId; + // But if the client knows the last seen message that the server + // returned (and by assumption all the messages that come after), + // the counter can be computed fully locally, ignoring potentially + // obsolete values from the server. + const firstMessage = this.orderedMessages[0]; + if ( + firstMessage && + this.lastSeenByCurrentPartnerMessageId && + this.lastSeenByCurrentPartnerMessageId >= firstMessage.id + ) { + baseCounter = 0; + countFromId = this.lastSeenByCurrentPartnerMessageId; + } + // Include all the messages that are known locally but the server + // didn't take into account. + return this.orderedMessages.reduce((total, message) => { + if (message.id <= countFromId) { + return total; + } + return total + 1; + }, baseCounter); + } + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeNeedactionMessages() { + return [['replace', this.messages.filter(message => message.isNeedaction)]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeNeedactionMessagesAsOriginThread() { + return [['replace', this.messagesAsOriginThread.filter(message => message.isNeedaction)]]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeMessageAfterNewMessageSeparator() { + if (this.model !== 'mail.channel') { + return [['unlink']]; + } + if (this.localMessageUnreadCounter === 0) { + return [['unlink']]; + } + const index = this.orderedMessages.findIndex(message => + message.id === this.lastSeenByCurrentPartnerMessageId + ); + if (index === -1) { + return [['unlink']]; + } + const message = this.orderedMessages[index + 1]; + if (!message) { + return [['unlink']]; + } + return [['link', message]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeOrderedMessages() { + return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeOrderedNonTransientMessages() { + return [['replace', this.orderedMessages.filter(m => !m.isTransient)]]; + } + + /** + * @private + * @returns {mail.partner[]} + */ + _computeOrderedOtherTypingMembers() { + return [[ + 'replace', + this.orderedTypingMembers.filter( + member => member !== this.env.messaging.currentPartner + ), + ]]; + } + + /** + * @private + * @returns {mail.partner[]} + */ + _computeOrderedTypingMembers() { + return [[ + 'replace', + this.orderedTypingMemberLocalIds + .map(localId => this.env.models['mail.partner'].get(localId)) + .filter(member => !!member), + ]]; + } + + /** + * @private + * @returns {mail.activity[]} + */ + _computeOverdueActivities() { + return [['replace', this.activities.filter(activity => activity.state === 'overdue')]]; + } + + /** + * @private + * @returns {mail.activity[]} + */ + _computeTodayActivities() { + return [['replace', this.activities.filter(activity => activity.state === 'today')]]; + } + + /** + * @private + * @returns {string} + */ + _computeTypingStatusText() { + if (this.orderedOtherTypingMembers.length === 0) { + return this.constructor.fields.typingStatusText.default; + } + if (this.orderedOtherTypingMembers.length === 1) { + return _.str.sprintf( + this.env._t("%s is typing..."), + this.orderedOtherTypingMembers[0].nameOrDisplayName + ); + } + if (this.orderedOtherTypingMembers.length === 2) { + return _.str.sprintf( + this.env._t("%s and %s are typing..."), + this.orderedOtherTypingMembers[0].nameOrDisplayName, + this.orderedOtherTypingMembers[1].nameOrDisplayName + ); + } + return _.str.sprintf( + this.env._t("%s, %s and more are typing..."), + this.orderedOtherTypingMembers[0].nameOrDisplayName, + this.orderedOtherTypingMembers[1].nameOrDisplayName + ); + } + + /** + * Compute an url string that can be used inside a href attribute + * + * @private + * @returns {string} + */ + _computeUrl() { + const baseHref = this.env.session.url('/web'); + if (this.model === 'mail.channel') { + return `${baseHref}#action=mail.action_discuss&active_id=${this.model}_${this.id}`; + } + return `${baseHref}#model=${this.model}&id=${this.id}`; + } + + /** + * @private + * @param {Object} param0 + * @param {boolean} param0.isTyping + */ + async _notifyCurrentPartnerTypingStatus({ isTyping }) { + if ( + this._forceNotifyNextCurrentPartnerTypingStatus || + isTyping !== this._currentPartnerLastNotifiedIsTyping + ) { + if (this.model === 'mail.channel') { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'notify_typing', + args: [this.id], + kwargs: { is_typing: isTyping }, + }, { shadow: true })); + } + if (isTyping && this._currentPartnerLongTypingTimer.isRunning) { + this._currentPartnerLongTypingTimer.reset(); + } + } + this._forceNotifyNextCurrentPartnerTypingStatus = false; + this._currentPartnerLastNotifiedIsTyping = isTyping; + } + + /** + * Cleans followers of current thread. In particular, chats are supposed + * to work with "members", not with "followers". This clean up is only + * necessary to remove illegitimate followers in stable version, it can + * be removed in master after proper migration to clean the database. + * + * @private + */ + _onChangeFollowersPartner() { + if (this.channel_type !== 'chat') { + return; + } + for (const follower of this.followers) { + if (follower.partner) { + follower.remove(); + } + } + } + + /** + * @private + */ + _onChangeLastSeenByCurrentPartnerMessageId() { + this.env.messagingBus.trigger('o-thread-last-seen-by-current-partner-message-id-changed', { + thread: this, + }); + } + + /** + * @private + */ + _onChangeThreadViews() { + if (this.threadViews.length === 0) { + return; + } + /** + * Fetches followers of chats when they are displayed for the first + * time. This is necessary to clean the followers. + * @see `_onChangeFollowersPartner` for more information. + */ + if (this.channel_type === 'chat' && !this.areFollowersLoaded) { + this.refreshFollowers(); + } + } + + /** + * Handles change of pinned state coming from the server. Useful to + * clear pending state once server acknowledged the change. + * + * @private + * @see isPendingPinned + */ + _onIsServerPinnedChanged() { + if (this.isServerPinned === this.isPendingPinned) { + this.update({ isPendingPinned: clear() }); + } + } + + /** + * Handles change of fold state coming from the server. Useful to + * synchronize corresponding chat window. + * + * @private + */ + _onServerFoldStateChanged() { + if (!this.env.messaging.chatWindowManager) { + // avoid crash during destroy + return; + } + if (this.env.messaging.device.isMobile) { + return; + } + if (this.serverFoldState === 'closed') { + this.env.messaging.chatWindowManager.closeThread(this, { + notifyServer: false, + }); + } else { + this.env.messaging.chatWindowManager.openThread(this, { + isFolded: this.serverFoldState === 'folded', + notifyServer: false, + }); + } + } + + /** + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.mail_invite_follower_channel_only=false] + */ + _promptAddFollower({ mail_invite_follower_channel_only = false } = {}) { + const self = this; + const action = { + type: 'ir.actions.act_window', + res_model: 'mail.wizard.invite', + view_mode: 'form', + views: [[false, 'form']], + name: this.env._t("Invite Follower"), + target: 'new', + context: { + default_res_model: this.model, + default_res_id: this.id, + mail_invite_follower_channel_only, + }, + }; + this.env.bus.trigger('do-action', { + action, + options: { + on_close: async () => { + await this.async(() => this.refreshFollowers()); + this.env.bus.trigger('mail.thread:promptAddFollower-closed'); + }, + }, + }); + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * @private + */ + async _onCurrentPartnerInactiveTypingTimeout() { + await this.async(() => this.unregisterCurrentPartnerIsTyping()); + } + + /** + * Called when current partner has been typing for a very long time. + * Immediately notify other members that he/she is still typing. + * + * @private + */ + async _onCurrentPartnerLongTypingTimeout() { + this._forceNotifyNextCurrentPartnerTypingStatus = true; + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + await this.async( + () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true }) + ); + } + + /** + * @private + * @param {mail.partner} partner + */ + async _onOtherMemberLongTypingTimeout(partner) { + if (!this.typingMembers.includes(partner)) { + this._otherMembersLongTypingTimers.delete(partner); + return; + } + this.unregisterOtherMemberTypingMember(partner); + } + + } + + Thread.fields = { + /** + * Determines the `mail.activity` that belong to `this`, assuming `this` + * has activities (@see hasActivities). + */ + activities: one2many('mail.activity', { + inverse: 'thread', + }), + /** + * Serves as compute dependency. + */ + activitiesState: attr({ + related: 'activities.state', + }), + allAttachments: many2many('mail.attachment', { + compute: '_computeAllAttachments', + dependencies: [ + 'attachments', + 'originThreadAttachments', + ], + }), + areAttachmentsLoaded: attr({ + default: false, + }), + /** + * States whether followers have been loaded at least once for this + * thread. + */ + areFollowersLoaded: attr({ + default: false, + }), + attachments: many2many('mail.attachment', { + inverse: 'threads', + }), + caches: one2many('mail.thread_cache', { + inverse: 'thread', + isCausal: true, + }), + channel_type: attr(), + /** + * States the `mail.chat_window` related to `this`. Serves as compute + * dependency. It is computed from the inverse relation and it should + * otherwise be considered read-only. + */ + chatWindow: one2one('mail.chat_window', { + inverse: 'thread', + }), + /** + * Serves as compute dependency. + */ + chatWindowIsFolded: attr({ + related: 'chatWindow.isFolded', + }), + composer: one2one('mail.composer', { + default: [['create']], + inverse: 'thread', + isCausal: true, + }), + correspondent: many2one('mail.partner', { + compute: '_computeCorrespondent', + dependencies: [ + 'channel_type', + 'members', + 'messagingCurrentPartner', + ], + inverse: 'correspondentThreads', + }), + correspondentNameOrDisplayName: attr({ + related: 'correspondent.nameOrDisplayName', + }), + counter: attr({ + default: 0, + }), + creator: many2one('mail.user'), + custom_channel_name: attr(), + displayName: attr({ + compute: '_computeDisplayName', + dependencies: [ + 'channel_type', + 'correspondent', + 'correspondentNameOrDisplayName', + 'custom_channel_name', + 'name', + ], + }), + followersPartner: many2many('mail.partner', { + related: 'followers.partner', + }), + followers: one2many('mail.follower', { + inverse: 'followedThread', + }), + /** + * States the `mail.activity` that belongs to `this` and that are + * planned in the future (due later than today). + */ + futureActivities: one2many('mail.activity', { + compute: '_computeFutureActivities', + dependencies: ['activitiesState'], + }), + group_based_subscription: attr({ + default: false, + }), + /** + * States whether `this` has activities (`mail.activity.mixin` server side). + */ + hasActivities: attr({ + default: false, + }), + /** + * Determine whether this thread has the seen indicators (V and VV) + * enabled or not. + */ + hasSeenIndicators: attr({ + compute: '_computeHasSeenIndicators', + default: false, + dependencies: [ + 'channel_type', + 'mass_mailing', + 'model', + ], + }), + id: attr(), + /** + * States whether this thread is a `mail.channel` qualified as chat. + * + * Useful to list chat channels, like in messaging menu with the filter + * 'chat'. + */ + isChatChannel: attr({ + compute: '_computeIsChatChannel', + dependencies: [ + 'channel_type', + ], + default: false, + }), + isCurrentPartnerFollowing: attr({ + compute: '_computeIsCurrentPartnerFollowing', + default: false, + dependencies: [ + 'followersPartner', + 'messagingCurrentPartner', + ], + }), + /** + * States whether `this` is currently loading attachments. + */ + isLoadingAttachments: attr({ + default: false, + }), + isModeratedByCurrentPartner: attr({ + compute: '_computeIsModeratedByCurrentPartner', + dependencies: [ + 'messagingCurrentPartner', + 'moderators', + ], + }), + /** + * Determine if there is a pending pin state change, which is a change + * of pin state requested by the client but not yet confirmed by the + * server. + * + * This field can be updated to immediately change the pin state on the + * interface and to notify the server of the new state. + */ + isPendingPinned: attr(), + /** + * Boolean that determines whether this thread is pinned + * in discuss and present in the messaging menu. + */ + isPinned: attr({ + compute: '_computeIsPinned', + dependencies: [ + 'isPendingPinned', + 'isServerPinned', + ], + }), + /** + * Determine the last pin state known by the server, which is the pin + * state displayed after initialization or when the last pending + * pin state change was confirmed by the server. + * + * This field should be considered read only in most situations. Only + * the code handling pin state change from the server should typically + * update it. + */ + isServerPinned: attr({ + default: false, + }), + isTemporary: attr({ + default: false, + }), + is_moderator: attr({ + default: false, + }), + lastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', { + compute: '_computeLastCurrentPartnerMessageSeenByEveryone', + dependencies: [ + 'messagingCurrentPartner', + 'orderedNonTransientMessages', + 'partnerSeenInfos', + ], + }), + /** + * Last message of the thread, could be a transient one. + */ + lastMessage: many2one('mail.message', { + compute: '_computeLastMessage', + dependencies: ['orderedMessages'], + }), + lastNeedactionMessage: many2one('mail.message', { + compute: '_computeLastNeedactionMessage', + dependencies: ['needactionMessages'], + }), + /** + * States the last known needaction message having this thread as origin. + */ + lastNeedactionMessageAsOriginThread: many2one('mail.message', { + compute: '_computeLastNeedactionMessageAsOriginThread', + dependencies: [ + 'needactionMessagesAsOriginThread', + ], + }), + /** + * Last non-transient message. + */ + lastNonTransientMessage: many2one('mail.message', { + compute: '_computeLastNonTransientMessage', + dependencies: ['orderedNonTransientMessages'], + }), + /** + * Last seen message id of the channel by current partner. + * + * Also, it needs to be kept as an id because it's considered like a "date" and could stay + * even if corresponding message is deleted. It is basically used to know which + * messages are before or after it. + */ + lastSeenByCurrentPartnerMessageId: attr({ + compute: '_computeLastSeenByCurrentPartnerMessageId', + default: 0, + dependencies: [ + 'lastSeenByCurrentPartnerMessageId', + 'messagingCurrentPartner', + 'orderedMessages', + 'orderedMessagesIsTransient', + // FIXME missing dependency 'orderedMessages.author', (task-2261221) + ], + }), + /** + * Local value of message unread counter, that means it is based on initial server value and + * updated with interface updates. + */ + localMessageUnreadCounter: attr({ + compute: '_computeLocalMessageUnreadCounter', + dependencies: [ + 'lastSeenByCurrentPartnerMessageId', + 'messagingCurrentPartner', + 'orderedMessages', + 'serverLastMessageId', + 'serverMessageUnreadCounter', + ], + }), + mainCache: one2one('mail.thread_cache', { + compute: '_computeMainCache', + }), + mass_mailing: attr({ + default: false, + }), + members: many2many('mail.partner', { + inverse: 'memberThreads', + }), + /** + * Determines the message before which the "new message" separator must + * be positioned, if any. + */ + messageAfterNewMessageSeparator: many2one('mail.message', { + compute: '_computeMessageAfterNewMessageSeparator', + dependencies: [ + 'lastSeenByCurrentPartnerMessageId', + 'localMessageUnreadCounter', + 'model', + 'orderedMessages', + ], + }), + message_needaction_counter: attr({ + default: 0, + }), + /** + * All messages that this thread is linked to. + * Note that this field is automatically computed by inverse + * computed field. This field is readonly. + */ + messages: many2many('mail.message', { + inverse: 'threads', + }), + /** + * All messages that have been originally posted in this thread. + */ + messagesAsOriginThread: one2many('mail.message', { + inverse: 'originThread', + }), + /** + * Serves as compute dependency. + */ + messagesAsOriginThreadIsNeedaction: attr({ + related: 'messagesAsOriginThread.isNeedaction', + }), + /** + * All messages that are contained on this channel on the server. + * Equivalent to the inverse of python field `channel_ids`. + */ + messagesAsServerChannel: many2many('mail.message', { + inverse: 'serverChannels', + }), + /** + * Serves as compute dependency. + */ + messagesIsNeedaction: attr({ + related: 'messages.isNeedaction', + }), + messageSeenIndicators: one2many('mail.message_seen_indicator', { + inverse: 'thread', + isCausal: true, + }), + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + messagingCurrentPartner: many2one('mail.partner', { + related: 'messaging.currentPartner', + }), + model: attr(), + model_name: attr(), + moderation: attr({ + default: false, + }), + /** + * Partners that are moderating this thread (only applies to channels). + */ + moderators: many2many('mail.partner', { + inverse: 'moderatedChannels', + }), + moduleIcon: attr(), + name: attr(), + needactionMessages: many2many('mail.message', { + compute: '_computeNeedactionMessages', + dependencies: [ + 'messages', + 'messagesIsNeedaction', + ], + }), + /** + * States all known needaction messages having this thread as origin. + */ + needactionMessagesAsOriginThread: many2many('mail.message', { + compute: '_computeNeedactionMessagesAsOriginThread', + dependencies: [ + 'messagesAsOriginThread', + 'messagesAsOriginThreadIsNeedaction', + ], + }), + /** + * Not a real field, used to trigger `_onChangeFollowersPartner` when one of + * the dependencies changes. + */ + onChangeFollowersPartner: attr({ + compute: '_onChangeFollowersPartner', + dependencies: [ + 'followersPartner', + ], + }), + /** + * Not a real field, used to trigger `_onChangeLastSeenByCurrentPartnerMessageId` when one of + * the dependencies changes. + */ + onChangeLastSeenByCurrentPartnerMessageId: attr({ + compute: '_onChangeLastSeenByCurrentPartnerMessageId', + dependencies: [ + 'lastSeenByCurrentPartnerMessageId', + ], + }), + /** + * Not a real field, used to trigger `_onChangeThreadViews` when one of + * the dependencies changes. + */ + onChangeThreadView: attr({ + compute: '_onChangeThreadViews', + dependencies: [ + 'threadViews', + ], + }), + /** + * Not a real field, used to trigger `_onIsServerPinnedChanged` when one of + * the dependencies changes. + */ + onIsServerPinnedChanged: attr({ + compute: '_onIsServerPinnedChanged', + dependencies: [ + 'isServerPinned', + ], + }), + /** + * Not a real field, used to trigger `_onServerFoldStateChanged` when one of + * the dependencies changes. + */ + onServerFoldStateChanged: attr({ + compute: '_onServerFoldStateChanged', + dependencies: [ + 'serverFoldState', + ], + }), + /** + * All messages ordered like they are displayed. + */ + orderedMessages: many2many('mail.message', { + compute: '_computeOrderedMessages', + dependencies: ['messages'], + }), + /** + * Serves as compute dependency. (task-2261221) + */ + orderedMessagesIsTransient: attr({ + related: 'orderedMessages.isTransient', + }), + /** + * All messages ordered like they are displayed. This field does not + * contain transient messages which are not "real" records. + */ + orderedNonTransientMessages: many2many('mail.message', { + compute: '_computeOrderedNonTransientMessages', + dependencies: [ + 'orderedMessages', + 'orderedMessagesIsTransient', + ], + }), + /** + * Ordered typing members on this thread, excluding the current partner. + */ + orderedOtherTypingMembers: many2many('mail.partner', { + compute: '_computeOrderedOtherTypingMembers', + dependencies: ['orderedTypingMembers'], + }), + /** + * Ordered typing members on this thread. Lower index means this member + * is currently typing for the longest time. This list includes current + * partner as typer. + */ + orderedTypingMembers: many2many('mail.partner', { + compute: '_computeOrderedTypingMembers', + dependencies: [ + 'orderedTypingMemberLocalIds', + 'typingMembers', + ], + }), + /** + * Technical attribute to manage ordered list of typing members. + */ + orderedTypingMemberLocalIds: attr({ + default: [], + }), + originThreadAttachments: one2many('mail.attachment', { + inverse: 'originThread', + }), + /** + * States the `mail.activity` that belongs to `this` and that are + * overdue (due earlier than today). + */ + overdueActivities: one2many('mail.activity', { + compute: '_computeOverdueActivities', + dependencies: ['activitiesState'], + }), + partnerSeenInfos: one2many('mail.thread_partner_seen_info', { + inverse: 'thread', + isCausal: true, + }), + /** + * Determine if there is a pending seen message change, which is a change + * of seen message requested by the client but not yet confirmed by the + * server. + */ + pendingSeenMessageId: attr(), + public: attr(), + /** + * Determine the last fold state known by the server, which is the fold + * state displayed after initialization or when the last pending + * fold state change was confirmed by the server. + * + * This field should be considered read only in most situations. Only + * the code handling fold state change from the server should typically + * update it. + */ + serverFoldState: attr({ + default: 'closed', + }), + /** + * Last message id considered by the server. + * + * Useful to compute localMessageUnreadCounter field. + * + * @see localMessageUnreadCounter + */ + serverLastMessageId: attr({ + default: 0, + }), + /** + * Message unread counter coming from server. + * + * Value of this field is unreliable, due to dynamic nature of + * messaging. So likely outdated/unsync with server. Should use + * localMessageUnreadCounter instead, which smartly guess the actual + * message unread counter at all time. + * + * @see localMessageUnreadCounter + */ + serverMessageUnreadCounter: attr({ + default: 0, + }), + /** + * Determines the `mail.suggested_recipient_info` concerning `this`. + */ + suggestedRecipientInfoList: one2many('mail.suggested_recipient_info', { + inverse: 'thread', + }), + threadViews: one2many('mail.thread_view', { + inverse: 'thread', + }), + /** + * States the `mail.activity` that belongs to `this` and that are due + * specifically today. + */ + todayActivities: one2many('mail.activity', { + compute: '_computeTodayActivities', + dependencies: ['activitiesState'], + }), + /** + * Members that are currently typing something in the composer of this + * thread, including current partner. + */ + typingMembers: many2many('mail.partner'), + /** + * Text that represents the status on this thread about typing members. + */ + typingStatusText: attr({ + compute: '_computeTypingStatusText', + default: '', + dependencies: ['orderedOtherTypingMembers'], + }), + /** + * URL to access to the conversation. + */ + url: attr({ + compute: '_computeUrl', + default: '', + dependencies: [ + 'id', + 'model', + ] + }), + uuid: attr(), + }; + + Thread.modelName = 'mail.thread'; + + return Thread; +} + +registerNewModel('mail.thread', factory); + +}); -- cgit v1.2.3