summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/models/thread/thread.js
diff options
context:
space:
mode:
authorSébastien Theys <seb@odoo.com>2020-06-15 18:12:21 +0000
committerSébastien Theys <seb@odoo.com>2020-06-15 18:12:21 +0000
commit3fea5b2136627c1d7e83b2a5e111952fe3eb10a1 (patch)
tree5eefad07972a87ce4689b83260f981bc87aadad4 /addons/mail/static/src/models/thread/thread.js
parent0a82848606151ef60eb6523e9c4c161171f214e7 (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.js1370
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);
+
+});