diff options
| author | Sébastien Theys <seb@odoo.com> | 2020-06-15 18:12:21 +0000 |
|---|---|---|
| committer | Sébastien Theys <seb@odoo.com> | 2020-06-15 18:12:21 +0000 |
| commit | 3fea5b2136627c1d7e83b2a5e111952fe3eb10a1 (patch) | |
| tree | 5eefad07972a87ce4689b83260f981bc87aadad4 /addons/mail/static/src/models/thread/thread.js | |
| parent | 0a82848606151ef60eb6523e9c4c161171f214e7 (diff) | |
[REF] mail, *: refactor messaging with OWL
This commit is a significant rewriting of client-side discuss, chatter,
chat window, and messaging menu using OWL. The behavior should be broadly
the same, with some slight functional changes here and there.
From a technical standpoint, the code of messaging is mainly organized in 2
main groups of modules:
- models, which are logical entities that depict the client-side state of
messaging as a whole.
- components, which are in charge of displaying information from models.
This refactoring also introduces new JS guidelines regarding folder structure
(/static) and naming rules for JS modules.
Community PR: https://github.com/odoo/odoo/pull/39023
Enterprise PR: https://github.com/odoo/enterprise/pull/6249
Task-1914207
This PR is a collaborative work by Alexandre, Julien, Sébastien and Xavier,
with the precious help of Lucas to speed it up towards the end.
closes odoo/odoo#39023
Related: odoo/enterprise#6249
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Co-authored-by: Alexandre Kühn <aku@odoo.com>
Co-authored-by: Julien Giannone <jgi@odoo.com>
Co-authored-by: Lucas Perais <lpe@odoo.com>
Co-authored-by: Sébastien Theys <seb@odoo.com>
Co-authored-by: Xavier Dubuc <xdu@odoo.com>
Diffstat (limited to 'addons/mail/static/src/models/thread/thread.js')
| -rw-r--r-- | addons/mail/static/src/models/thread/thread.js | 1370 |
1 files changed, 1370 insertions, 0 deletions
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 00000000000..16f52ab861f --- /dev/null +++ b/addons/mail/static/src/models/thread/thread.js @@ -0,0 +1,1370 @@ +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 throttle = require('mail/static/src/utils/throttle/throttle.js'); +const Timer = require('mail/static/src/utils/timer/timer.js'); + +function factory(dependencies) { + + class Thread extends dependencies['mail.model'] { + + /** + * @override + */ + init(...args) { + super.init(...args); + /** + * 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 + ); + } + + /** + * @override + */ + delete(...args) { + this._currentPartnerInactiveTypingTimer.clear(); + this._currentPartnerLongTypingTimer.clear(); + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + for (const timer of this._otherMembersLongTypingTimers.values()) { + timer.clear(); + } + super.delete(...args); + } + + //---------------------------------------------------------------------- + // 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 = {}; + 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 ('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.message_unread_counter = 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.seen_message_id = data.seen_message_id; + } + if ('uuid' in data) { + data2.uuid = data.uuid; + } + + // relation + if ('direct_partner' in data) { + if (!data.direct_partner) { + data2.correspondent = [['unlink-all']]; + } else { + data2.correspondent = [ + ['insert', this.env.models['mail.partner'].convertData(data.direct_partner[0])], + ]; + } + } + 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 { + data2.partnerSeenInfos = [ + ['insert-and-replace', + data.seen_partners_info.map( + ({ fetched_message_id, id, partner_id, seen_message_id}) => { + return { + id, + lastFetchedMessage: [seen_message_id ? ['insert', {id: seen_message_id}] : ['unlink-all']], + lastSeenMessage: [fetched_message_id ? ['insert', {id: fetched_message_id}] : ['unlink-all']], + partner: [['insert', {id: 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) { + data2.messageSeenIndicators = [ + ['insert', + [...messageIds].map(messageId => { + return { + id: this.env.models['mail.message_seen_indicator'].computeId(messageId, data.id || this.id), + message: [['insert', {id: messageId}]], + }; + }) + ] + ]; + } + } + } + } + + return data2; + } + + /** + * Create a channel, which is a special kind of thread on model + * 'mail.channel' with multiple members. + * + * @static + * @param {Object} param0 + * @param {boolean} [param0.autoselect=false] if set, when channel + * has been created, it auto-open the channel. This opens in discuss + * or chat window, depending on whether discuss is open or not. + * @param {string} [param0.autoselectChatWindowMode] + * @param {string} param0.name + * @param {integer} [param0.partnerId] + * @param {string} [param0.public] + * @param {string} param0.type + */ + static async createChannel({ + autoselect = false, + autoselectChatWindowMode, + name, + partnerId, + public: publicStatus, + type, + }) { + const device = this.env.messaging.device; + // TODO FIX: potential duplicate chat task-2276490 + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: type === 'chat' ? 'channel_get' : 'channel_create', + args: type === 'chat' ? [[partnerId]] : [name, publicStatus], + kwargs: { + context: Object.assign({}, this.env.session.user_content, { + isMobile: device.isMobile, + }), + } + }); + const thread = this.insert(Object.assign( + { isPinned: true }, + this.convertData(data) + )); + if (autoselect) { + thread.open({ chatWindowMode: autoselectChatWindowMode }); + } + } + + /** + * Join a channel. This channel may not yet exists in the store. + * + * @static + * @param {integer} channelId + * @param {Object} [param1={}] + * @param {boolean} [param1.autoselect=false] + */ + static async joinChannel(channelId, { autoselect = false } = {}) { + const channel = this.find(thread => + thread.id === channelId && + thread.model === 'mail.channel' + ); + if (channel && channel.isPinned) { + return; + } + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_join_and_get_info', + args: [[channelId]] + }); + const thread = this.insert(Object.assign( + { isPinned: true }, + this.convertData(data) + )); + if (autoselect) { + thread.open({ resetDiscussDomain: true }); + } + } + + /** + * 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; + }, []); + const messagePreviews = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fetch_preview', + args: [channelIds], + }, { shadow: true }); + for (const preview of messagePreviews) { + const messageData = preview.last_message; + if (messageData) { + this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(messageData) + ); + } + } + } + + /** + * @static + */ + static openNewMessage() { + const discuss = this.env.messaging.discuss; + if (discuss.isOpen) { + discuss.openNewMessage(); + } else { + this.env.messaging.chatWindowManager.openNewMessage(); + } + } + + /** + * @param {string} [stringifiedDomain='[]'] + * @returns {mail.thread_cache} + */ + cache(stringifiedDomain = '[]') { + let cache = this.caches.find(cache => cache.stringifiedDomain === stringifiedDomain); + if (!cache) { + cache = this.env.models['mail.thread_cache'].create({ + stringifiedDomain, + thread: [['link', this]], + }); + } + return cache; + } + + /** + * 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 }], + })); + for (const attachmentData of attachmentsData) { + this.env.models['mail.attachment'].insert(Object.assign({ + originThread: [['link', this]], + }, this.env.models['mail.attachment'].convertData(attachmentData))); + } + this.update({ areAttachmentsLoaded: true }); + } + + /** + * 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(); + } + + /** + * 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. + */ + async markAsSeen() { + if (this.message_unread_counter === 0) { + return; + } + if (this.model === 'mail.channel') { + const seen_message_id = await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_seen', + args: [[this.id]] + }, { shadow: true })); + this.update({ seen_message_id }); + } + this.update({ message_unread_counter: 0 }); + } + + /** + * Mark all needaction messages of this thread as read. + */ + async markNeedactionMessagesAsRead() { + await this.async(() => + this.env.models['mail.message'].markAsRead(this.needactionMessages) + ); + } + + /** + * Notify server the fold state of this thread. Useful for cross-tab + * and cross-device chat window state synchronization. + * + * Only makes sense if pendingFoldState is set to the desired value. + */ + async notifyFoldStateToServer() { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fold', + kwargs: { + uuid: this.uuid, + state: this.pendingFoldState, + } + }, { shadow: true })); + } + + /** + * Open provided thread, either in discuss app or as a chat window. + * + * @param {Object} param0 + * @param {string} [param0.chatWindowMode='last_visible'] + * @param {boolean} [param0.resetDiscussDomain=false] + */ + open({ chatWindowMode = 'last_visible', resetDiscussDomain = false } = {}) { + const device = this.env.messaging.device; + const discuss = this.env.messaging.discuss; + const messagingMenu = this.env.messaging.messagingMenu; + if (!['mail.box', 'mail.channel'].includes(this.model)) { + this.env.messaging.openDocument({ + id: this.id, + model: this.model, + }); + } + if ( + (!device.isMobile && discuss.isOpen) || + (device.isMobile && this.model === 'mail.box') + ) { + if (resetDiscussDomain) { + discuss.threadViewer.update({ stringifiedDomain: '[]' }); + } + discuss.threadViewer.update({ thread: [['link', this]] }); + } else { + this.env.messaging.chatWindowManager.openThread(this, { mode: chatWindowMode }); + } + if (!device.isMobile) { + messagingMenu.close(); + } + } + + /** + * Open this thread in an expanded way, that is not in a chat window. + */ + openExpanded() { + const discuss = this.env.messaging.discuss; + if (['mail.channel', 'mail.box'].includes(this.model)) { + this.env.bus.trigger('do-action', { + action: 'mail.action_new_discuss', + options: { + clear_breadcrumbs: false, + active_id: discuss.threadToActiveId(this), + on_reverse_breadcrumb: () => discuss.close(), + }, + }); + } else { + this.env.bus.trigger('do-action', { + action: { + type: 'ir.actions.act_window', + res_model: this.model, + views: [[false, 'form']], + res_id: this.id, + }, + }); + } + } + + /** + * 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 }); + } + + /** + * Refresh followers information from server. + */ + async refreshFollowers() { + // FIXME Do that with only one RPC (see task-2243180) + const [{ message_follower_ids: followerIds }] = await this.async(() => this.env.services.rpc({ + model: this.model, + method: 'read', + args: [this.id, ['message_follower_ids']], + })); + if (followerIds && followerIds.length > 0) { + const { followers } = await this.async(() => this.env.services.rpc({ + route: '/mail/read_followers', + params: { + follower_ids: followerIds, + context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187 + } + })); + 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()); + } + + /** + * 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. + */ + async unsubscribe() { + if (this.channel_type === 'mail.channel') { + return this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'action_unfollow', + args: [[this.id]] + })); + } + return this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_pin', + args: [this.uuid, false] + })); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _findFunctionFromData(data) { + return record => record.id === data.id && record.model === data.model; + } + + /** + * @private + * @returns {mail.attachment[]} + */ + _computeAllAttachments() { + const allAttachments = [...new Set(this.originThreadAttachments.concat(this.attachments))] + .sort((a1, a2) => a1.id < a2.id ? 1 : -1); + return [['replace', allAttachments]]; + } + + /** + * @private + * @returns {mail.chat_window[]} + */ + _computeChatWindows() { + const chatWindowViewers = this.viewers.filter(viewer => !!viewer.chatWindow); + return [['replace', chatWindowViewers.map(viewer => viewer.chatWindow)]]; + } + + /** + * @private + * @returns {string} + */ + _computeDisplayName() { + if (this.channel_type === 'chat' && this.correspondent) { + return this.custom_channel_name || this.correspondent.nameOrDisplayName; + } + return this.name; + } + + /** + * @private + * @returns {string} + */ + _computeFoldState() { + return this.pendingFoldState || this.serverFoldState; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsCurrentPartnerFollowing() { + return this.followers.some(follower => + follower.partner && follower.partner === this.env.messaging.currentPartner + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsModeratedByCurrentPartner() { + if (this.model !== 'mail.channel') { + return false; + } + if (!this.messaging) { + return false; + } + if (!this.messaging.currentPartner) { + return false; + } + return this.messaging.currentPartner.moderatedChannelIds.includes(this.id); + } + + /** + * @private + * @returns {mail.message} + */ + _computeLastCurrentPartnerMessageSeenByEveryone() { + if (!this.partnerSeenInfos || !this.mainCacheOrderedMessages) { + return [['unlink-all']]; + } + 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.mainCacheOrderedMessages.filter(message => + message.author === this.messagingCurrentPartner && + message.id <= lastMessageSeenByAllId); + + if ( + !currentPartnerOrderedSeenMessages || + currentPartnerOrderedSeenMessages.length === 0 + ) { + return [['unlink-all']]; + } + return [['replace', currentPartnerOrderedSeenMessages.slice().pop()]]; + } + + /** + * @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; + return [['replace', lastNeedactionMessage]]; + } + + /** + * @private + * @returns {mail.thread_cache} + */ + _computeMainCache() { + return [['replace', this.cache('[]')]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeMessages() { + const allMessages = this.mainCacheMessages.concat(this.originThreadMessages); + return [['replace', allMessages]]; + } + + /** + * @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.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 {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 + ); + } + + /** + * @override + */ + _createRecordLocalId(data) { + const { channel_type, id, isTemporary = false, model } = data; + let threadModel = model; + if (!threadModel && channel_type) { + threadModel = 'mail.channel'; + } + const Thread = this.env.models['mail.thread']; + if (isTemporary) { + return `${Thread.modelName}_${id}`; + } + return `${Thread.modelName}_${threadModel}_${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; + } + + /** + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.mail_invite_follower_channel_only=false] + */ + _promptAddFollower({ mail_invite_follower_channel_only = false } = {}) { + 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: () => this.refreshFollowers(), + }, + }); + } + + /** + * @override + */ + _updateAfter(previous) { + if (this.model !== 'mail.channel') { + // fold state only makes sense on channels + return; + } + if ( + this.pendingFoldState && + previous.pendingFoldState !== this.pendingFoldState + ) { + this.notifyFoldStateToServer(); + } + if ( + this.serverFoldState === this.pendingFoldState + ) { + this.update({ pendingFoldState: undefined }); + } + + // TODO FIXME prevent to open/close a channel on mobile when you + // open/close it on desktop (task-2267593) + + // chat window + if (previous.foldState === this.foldState) { + // avoid updating chatWindows when not changing foldState + // important to avoid issues when thread is in progress of being + // opened, because the foldState is updated only at the end of + // the process + return; + } + if (this.foldState !== 'closed' && this.chatWindows.length === 0) { + // condition to avoid crash during destroy + if (this.env.messaging.chatWindowManager) { + this.env.messaging.chatWindowManager.openThread(this); + } + } + if (this.foldState === 'closed' && this.chatWindows.length > 0) { + for (const chatWindow of this.chatWindows) { + chatWindow.close(); + } + } + } + + /** + * @override + */ + _updateBefore() { + return { + foldState: this.foldState, + pendingFoldState: this.pendingFoldState, + }; + } + + //---------------------------------------------------------------------- + // 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 = { + allAttachments: many2many('mail.attachment', { + compute: '_computeAllAttachments', + dependencies: [ + 'attachments', + 'originThreadAttachments', + ], + }), + areAttachmentsLoaded: attr({ + default: false, + }), + attachments: many2many('mail.attachment', { + inverse: 'threads', + }), + caches: one2many('mail.thread_cache', { + inverse: 'thread', + isCausal: true, + }), + channel_type: attr(), + chatWindows: one2many('mail.chat_window', { + compute: '_computeChatWindows', + dependencies: ['viewersChatWindow'], + }), + composer: one2one('mail.composer', { + autocreate: true, + inverse: 'thread', + isCausal: true, + }), + correspondent: many2one('mail.partner', { + 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', + ], + }), + /** + * Determine the fold state of the channel on the web client. + * + * If there is a pending fold state change, it is immediately applied on + * the interface to avoid a feeling of unresponsiveness. Otherwise the + * last known fold state of the server is used. + * + * This field must be considered read only. + */ + foldState: attr({ + compute: '_computeFoldState', + dependencies: [ + 'pendingFoldState', + 'serverFoldState', + ], + }), + followersPartner: many2many('mail.partner', { + related: 'followers.partner', + }), + followers: one2many('mail.follower', { + inverse: 'followedThread', + }), + group_based_subscription: attr({ + default: false, + }), + id: attr(), + isCurrentPartnerFollowing: attr({ + compute: '_computeIsCurrentPartnerFollowing', + default: false, + dependencies: [ + 'followersPartner', + 'messagingCurrentPartner', + ], + }), + isModeratedByCurrentPartner: attr({ + compute: '_computeIsModeratedByCurrentPartner', + dependencies: [ + 'model', + 'messagingCurrentPartner', + ], + }), + isPinned: attr({ + default: false, + }), + isTemporary: attr({ + default: false, + }), + is_moderator: attr({ + default: false, + }), + lastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', { + compute: '_computeLastCurrentPartnerMessageSeenByEveryone', + dependencies: [ + 'partnerSeenInfos', + 'mainCacheOrderedMessages', + 'messagingCurrentPartner', + ], + }), + lastMessage: many2one('mail.message', { + related: 'mainCache.lastMessage', + }), + lastNeedactionMessage: many2one('mail.message', { + compute: '_computeLastNeedactionMessage', + dependencies: ['needactionMessages'], + }), + mainCache: one2one('mail.thread_cache', { + compute: '_computeMainCache', + dependencies: ['caches'], + }), + mainCacheMessages: many2many('mail.message', { + related: 'mainCache.messages', + }), + mainCacheOrderedMessages: many2many('mail.message', { + related: 'mainCache.orderedMessages', + }), + mass_mailing: attr({ + default: false, + }), + members: many2many('mail.partner', { + inverse: 'memberThreads', + }), + message_needaction_counter: attr({ + default: 0, + }), + message_unread_counter: attr({ + default: 0, + }), + messages: many2many('mail.message', { + compute: '_computeMessages', + dependencies: [ + 'mainCacheMessages', + 'originThreadMessages', + ], + }), + 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, + }), + moduleIcon: attr(), + name: attr(), + needactionMessages: many2many('mail.message', { + compute: '_computeNeedactionMessages', + dependencies: ['messages'], + }), + /** + * 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', + }), + originThreadMessages: one2many('mail.message', { + inverse: 'originThread', + }), + partnerSeenInfos: one2many('mail.thread_partner_seen_info', { + inverse: 'thread', + isCausal: true, + }), + /** + * Determine if there is a pending fold state change, which is a change + * of fold state requested by the client but not yet confirmed by the + * server. + * + * This field can be updated to immediately change the fold state on the + * interface and to notify the server of the new state. + */ + pendingFoldState: attr(), + public: attr(), + seen_message_id: 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', + }), + /** + * 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'], + }), + uuid: attr(), + viewers: one2many('mail.thread_viewer', { + inverse: 'thread', + }), + viewersChatWindow: many2many('mail.chat_window', { + related: 'viewers.chatWindow', + }), + }; + + Thread.modelName = 'mail.thread'; + + return Thread; +} + +registerNewModel('mail.thread', factory); + +}); |
