/** @odoo-module **/ import { attr, clear, insert, insertAndUnlink, link, many, one, Model, unlink } from "@mail/model"; import { cleanSearchTerm } from "@mail/utils/utils"; import * as mailUtils from "@mail/js/utils"; import { sprintf } from "@web/core/utils/strings"; import { url } from "@web/core/utils/urls"; import { str_to_datetime } from "web.time"; const getSuggestedRecipientInfoNextTemporaryId = (function () { let tmpId = 0; return () => { tmpId += 1; return tmpId; }; })(); Model({ name: "Thread", lifecycleHooks: { _willDelete() { if (this.isTemporary) { for (const message of this.messages) { message.delete(); } } }, }, modelMethods: { /** * @param {Object} data * @return {Object} */ convertData(data) { const data2 = {}; if ("authorizedGroupFullName" in data) { data2.authorizedGroupFullName = data.authorizedGroupFullName; } if ("channel" in data) { data2.channel = data.channel; data2.model = "mail.channel"; } if ("defaultDisplayMode" in data) { data2.defaultDisplayMode = data.defaultDisplayMode; } if ("description" in data) { data2.description = data.description; } if ("model" in data) { data2.model = data.model; } if ("create_uid" in data) { data2.creator = insert({ id: data.create_uid }); } if ("group_based_subscription" in data) { data2.group_based_subscription = data.group_based_subscription; } if ("id" in data) { data2.id = data.id; } if ("invitedMembers" in data) { data2.invitedMembers = data.invitedMembers; } if ("is_minimized" in data && "state" in data) { data2.serverFoldState = data.is_minimized ? data.state : "closed"; } if ("is_pinned" in data) { data2.isServerPinned = data.is_pinned; } if ("last_interest_dt" in data && data.last_interest_dt) { data2.lastInterestDateTime = str_to_datetime(data.last_interest_dt); } if ("last_message" in data && data.last_message) { const messageData = this.messaging.models["Message"].convertData({ id: data.last_message.id, model: data2.model, res_id: data2.id, }); data2.serverLastMessage = insert(messageData); } if ("last_message_id" in data && data.last_message_id) { const messageData = this.messaging.models["Message"].convertData({ id: data.last_message_id, model: data2.model, res_id: data2.id, }); data2.serverLastMessage = insert(messageData); } if ("message_needaction_counter" in data) { data2.message_needaction_counter = data.message_needaction_counter; } if ("name" in data) { data2.name = data.name; } if ("seen_message_id" in data) { data2.rawLastSeenByCurrentPartnerMessageId = data.seen_message_id; } if ("uuid" in data) { data2.uuid = data.uuid; } // relations if ("rtc_inviting_session" in data) { data2.rtcInvitingSession = insert(data.rtc_inviting_session); } if ("rtcSessions" in data) { data2.rtcSessions = data.rtcSessions; } if ("seen_partners_info" in data) { if (!data.seen_partners_info) { data2.partnerSeenInfos = clear(); } else { data2.partnerSeenInfos = data.seen_partners_info.map( ({ fetched_message_id, partner_id, seen_message_id }) => { return { lastFetchedMessage: fetched_message_id ? insert({ id: fetched_message_id }) : clear(), lastSeenMessage: seen_message_id ? insert({ id: seen_message_id }) : clear(), partner: { 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 = [...messageIds].map((messageId) => { return { message: { id: messageId }, }; }); } } } } return data2; }, /** * Creates a new group chat with the provided partners. * * @param {Object} param0 * @param {number[]} param0.partners_to Ids of the partners to add as channel * members. * @param {boolean|string} param0.default_display_mode * @returns {Thread} The newly created group chat. */ async createGroupChat({ default_display_mode, partners_to }) { const channelData = await this.messaging.rpc({ model: "mail.channel", method: "create_group", kwargs: { default_display_mode, partners_to, }, }); return this.messaging.models["Thread"].insert( this.messaging.models["Thread"].convertData(channelData) ); }, /** * 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. * * @param {string} searchTerm * @param {Object} [options={}] * @param {Thread} [options.thread] prioritize and/or restrict * result in the context of given thread */ async fetchSuggestions(searchTerm, { thread } = {}) { const channelsData = await this.messaging.rpc( { model: "mail.channel", method: "get_mention_suggestions", kwargs: { search: searchTerm }, }, { shadow: true } ); this.messaging.models["Thread"].insert( channelsData.map((channelData) => this.messaging.models["Thread"].convertData(channelData) ) ); }, /** * Returns a sort function to determine the order of display of threads * in the suggestion list. * * @param {string} searchTerm * @param {Object} [options={}] * @param {Thread} [options.thread] prioritize result in the * context of given thread * @returns {function} */ getSuggestionSortFunction(searchTerm, { thread } = {}) { const cleanedSearchTerm = cleanSearchTerm(searchTerm); return (a, b) => { const isAPublicChannel = a.model === "mail.channel" && a.channel.channel_type === "channel" && !a.authorizedGroupFullName; const isBPublicChannel = b.model === "mail.channel" && b.channel.channel_type === "channel" && !b.authorizedGroupFullName; if (isAPublicChannel && !isBPublicChannel) { return -1; } if (!isAPublicChannel && isBPublicChannel) { return 1; } const isMemberOfA = a.channel && a.channel.memberOfCurrentUser; const isMemberOfB = b.channel && b.channel.memberOfCurrentUser; 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. * * @param {Thread[]} threads */ 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.messaging.rpc( { model: "mail.channel", method: "channel_fetch_preview", args: [channelIds], }, { shadow: true } ); this.messaging.models["Message"].insert( channelPreviews .filter((p) => p.last_message) .map((channelPreview) => this.messaging.models["Message"].convertData(channelPreview.last_message) ) ); }, /** * Performs the `channel_fold` RPC on `mail.channel`. * * @param {number} channelId * @param {string} state */ async performRpcChannelFold(channelId, state) { return this.messaging.rpc( { model: "mail.channel", method: "channel_fold", args: [[channelId]], kwargs: { state, }, }, { shadow: true } ); }, /** * Performs the `channel_info` RPC on `mail.channel`. * * @param {Object} param0 * @param {integer[]} param0.ids list of id of channels * @returns {Thread[]} */ async performRpcChannelInfo({ ids }) { const channelInfos = await this.messaging.rpc( { model: "mail.channel", method: "channel_info", args: [ids], }, { shadow: true } ); const channels = this.messaging.models["Thread"].insert( channelInfos.map((channelInfo) => this.messaging.models["Thread"].convertData(channelInfo) ) ); return channels; }, /** * Performs the `/mail/channel/set_last_seen_message` RPC. * * @param {Object} param0 * @param {integer} param0.id id of channel * @param {integer[]} param0.lastMessageId */ async performRpcChannelSeen({ id, lastMessageId }) { return this.messaging.rpc( { route: `/mail/channel/set_last_seen_message`, params: { channel_id: id, last_message_id: lastMessageId, }, }, { shadow: true } ); }, /** * Performs the `channel_pin` RPC on `mail.channel`. * * @param {Object} param0 * @param {number} param0.channelId * @param {boolean} [param0.pinned=false] */ async performRpcChannelPin({ channelId, pinned = false }) { return this.messaging.rpc( { model: "mail.channel", method: "channel_pin", args: [[channelId]], kwargs: { pinned, }, }, { shadow: true } ); }, /** * Performs the `channel_create` RPC on `mail.channel`. * * @param {Object} param0 * @param {string} param0.name * @param {integer} param0.group_id * @returns {Thread} the created channel */ async performRpcCreateChannel({ name, group_id }) { const data = await this.messaging.rpc({ model: "mail.channel", method: "channel_create", args: [name, group_id], }); return this.messaging.models["Thread"].insert( this.messaging.models["Thread"].convertData(data) ); }, /** * Search for thread matching `searchTerm`. * * @param {Object} param0 * @param {integer} param0.limit * @param {string} param0.searchTerm */ async searchChannelsToOpen({ limit, searchTerm }) { const domain = [ ["channel_type", "=", "channel"], ["name", "ilike", searchTerm], ]; const fields = ["channel_type", "name"]; const channelsData = await this.messaging.rpc({ model: "mail.channel", method: "search_read", kwargs: { domain, fields, limit, }, }); return this.insert( channelsData.map((channelData) => this.messaging.models["Thread"].convertData({ channel: { channel_type: channelData.channel_type, id: channelData.id, }, id: channelData.id, name: channelData.name, }) ) ); }, /** * 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. * * @param {string} searchTerm * @param {Object} [options={}] * @param {Thread} [options.thread] prioritize and/or restrict * result in the context of given thread * @returns {[mail.threads[], mail.threads[]]} */ searchSuggestions(searchTerm, { thread } = {}) { let threads; if ( thread && thread.model === "mail.channel" && (thread.channel.channel_type !== "channel" || (thread.channel.channel_type === "channel" && thread.authorizedGroupFullName)) ) { // Only return the current channel when in the context of a // group restricted channel or group or chat. 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.messaging.models["Thread"].all(); } const cleanedSearchTerm = cleanSearchTerm(searchTerm); return [ threads.filter( (thread) => !thread.isTemporary && thread.channel && thread.channel.channel_type === "channel" && thread.displayName && cleanSearchTerm(thread.displayName).includes(cleanedSearchTerm) ), ]; }, }, recordMethods: { /** * Changes description of the thread to the given new description. * Only makes sense for channels. * * @param {string} description */ async changeDescription(description) { this.update({ description }); return this.messaging.rpc({ model: "mail.channel", method: "channel_change_description", args: [[this.id]], kwargs: { description }, }); }, /** * Client-side ending of the call. */ endCall() { if (this.rtc) { this.rtc.reset(); this.messaging.soundEffects.channelLeave.play(); } this.update({ rtc: clear(), rtcInvitingSession: clear(), }); }, /** * Requests the given `requestList` data from the server. * * @param {string[]} requestList */ async fetchData(requestList) { if (this.isTemporary) { return; } const requestSet = new Set(requestList); if (!this.hasActivities) { requestSet.delete("activities"); } if (requestSet.has("attachments")) { this.update({ isLoadingAttachments: true }); } if (requestSet.has("messages")) { this.cache.loadNewMessages(); } const { activities: activitiesData, attachments: attachmentsData, followers: followersData, hasWriteAccess, mainAttachment, hasReadAccess, suggestedRecipients: suggestedRecipientsData, } = await this.messaging.rpc( { route: "/mail/thread/data", params: { request_list: [...requestSet], thread_id: this.id, thread_model: this.model, }, }, { shadow: true } ); if (!this.exists()) { return; } const values = { hasWriteAccess, mainAttachment, hasReadAccess }; if (activitiesData) { Object.assign(values, { activities: activitiesData.map((activityData) => this.messaging.models["Activity"].convertData(activityData) ), }); } if (attachmentsData) { Object.assign(values, { areAttachmentsLoaded: true, isLoadingAttachments: false, originThreadAttachments: attachmentsData, }); } if (followersData) { Object.assign(values, { followers: followersData.map((followerData) => this.messaging.models["Follower"].convertData(followerData) ), }); } if (suggestedRecipientsData) { const recipientInfoList = suggestedRecipientsData.map((recipientInfoData) => { const [partner_id, emailInfo, lang, reason] = recipientInfoData; const [name, email] = emailInfo && mailUtils.parseEmail(emailInfo); return { email, id: getSuggestedRecipientInfoNextTemporaryId(), name, lang, partner: partner_id ? insert({ id: partner_id }) : clear(), reason, }; }); Object.assign(values, { suggestedRecipientInfoList: recipientInfoList, }); } this.update(values); this.messaging.messagingBus.trigger("o-thread-loaded-data", { thread: this }); }, /** * Add current user to provided thread's followers. */ async follow() { await this.messaging.rpc({ model: this.model, method: "message_subscribe", args: [[this.id]], kwargs: { partner_ids: [this.messaging.currentPartner.id], }, }); if (!this.exists()) { return; } this.fetchData(["followers", "suggestedRecipients"]); }, /** * Performs the rpc to leave the rtc call of the channel. */ async performRpcLeaveCall() { await this.messaging.rpc( { route: "/mail/rtc/channel/leave_call", params: { channel_id: this.id }, }, { shadow: true } ); }, /** * Leaves the current call if there is one, joins the call if the user was * not yet in it. * * @param {Object} options */ async toggleCall(options) { this.update({ hasPendingRtcRequest: true }); const isActiveCall = !!this.rtc; if (this.messaging.rtc.channel) { await this.messaging.rtc.channel.leaveCall(); } if (isActiveCall) { this.update({ hasPendingRtcRequest: false }); return; } await this._joinCall(options); this.update({ hasPendingRtcRequest: false }); }, /** * @param {Object} [param0] * @param {boolean} [param0.startWithVideo] whether or not to start the call with the video * @param {boolean} [param0.videoType] type of the video: 'user-video' or 'display' */ async _joinCall({ startWithVideo = false, videoType } = {}) { if (this.model !== "mail.channel") { return; } if (!this.messaging.device.hasRtcSupport) { this.messaging.notify({ message: this.env._t("Your browser does not support webRTC."), type: "warning", }); return; } const { rtcSessions, iceServers, sessionId, invitedMembers } = await this.messaging.rpc( { route: "/mail/rtc/channel/join_call", params: { channel_id: this.id, check_rtc_session_ids: this.rtcSessions.map((rtcSession) => rtcSession.id), }, }, { shadow: true } ); if (!this.exists()) { return; } this.update({ rtc: this.messaging.rtc, rtcInvitingSession: clear(), rtcSessions, invitedMembers, }); await this.messaging.rtc.initSession({ currentSessionId: sessionId, iceServers, startWithAudio: true, startWithVideo, videoType, }); if (!this.exists()) { return; } this.messaging.soundEffects.channelJoin.play(); }, /** * Notifies the server and does the cleanup of the current call. */ async leaveCall() { await this.performRpcLeaveCall(); this.endCall(); }, /** * @param {Array} rtcSessions server representation of the current rtc sessions of the channel */ updateRtcSessions(rtcSessions) { const oldCount = this.rtcSessions.length; this.update({ rtcSessions }); if (this.rtc) { const newCount = this.rtcSessions.length; if (newCount > oldCount) { this.messaging.soundEffects.channelJoin.play(); } if (newCount < oldCount) { this.messaging.soundEffects.memberLeave.play(); } } this.rtc && this.rtc.filterCallees(this.rtcSessions); if (this.rtc && !this.rtc.currentRtcSession) { this.endCall(); } }, /** * Returns the name of the given persona in the context of this thread. * * @param {Persona} persona * @returns {string} */ getMemberName(persona) { return persona.name; }, /** * Joins this thread. Only makes sense on channels of type channel. */ async join() { await this.messaging.rpc({ model: "mail.channel", method: "add_members", args: [[this.id]], kwargs: { partner_ids: [this.messaging.currentPartner.id] }, }); }, /** * Leaves this thread. Only makes sense on channels of type channel. */ async leave() { await this.messaging.rpc({ model: "mail.channel", method: "action_unfollow", args: [[this.id]], }); }, /** * Mark the specified conversation as fetched. */ async markAsFetched() { await this.messaging.rpc( { model: "mail.channel", method: "channel_fetched", args: [[this.id]], }, { shadow: true } ); }, /** * Mark the specified conversation as read/seen. * * @param {Message} message the message to be considered as last seen. */ async markAsSeen(message) { if (this.messaging.currentGuest) { return; } 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.messaging.models["Thread"].performRpcChannelSeen({ id: this.id, lastMessageId: message.id, }); }, /** * Marks as read all needaction messages with this thread as origin. */ async markNeedactionMessagesAsOriginThreadAsRead() { await this.messaging.models["Message"].markAsRead( this.needactionMessagesAsOriginThread ); }, /** * 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; } return this.messaging.models["Thread"].performRpcChannelFold(this.id, 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.channel.channel_type === "channel") { await this.leave(); return; } await this.messaging.models["Thread"].performRpcChannelPin({ channelId: this.id, pinned: this.isPendingPinned, }); }, /** * 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] * @param {boolean} [param0.focus] */ async open({ expanded = false, focus } = {}) { const discuss = this.messaging.discuss; // check if thread must be opened in form view if (!this.channel && !this.mailbox) { if (expanded || discuss.discussView) { // Close chat window because having the same thread opened // both in chat window and as main document does not look // good. this.messaging.chatWindowManager.closeThread(this); await this.messaging.openDocument({ id: this.id, model: this.model, }); return; } } // check if thread must be opened in discuss if ( (!this.messaging.device.isSmall && (discuss.discussView || expanded)) || this.mailbox ) { return discuss.openThread(this, { focus: focus !== undefined ? focus : !this.messaging.device.isMobileDevice, }); } // thread must be opened in chat window return this.messaging.chatWindowManager.openThread(this, { makeActive: true, }); }, /** * Opens the most appropriate view that is a profile for this thread. */ async openProfile() { return this.messaging.openDocument({ id: this.id, model: this.model, }); }, /** * Pin this thread and notify server of the change. */ async pin() { this.update({ isPendingPinned: true }); if (this.messaging.currentGuest) { return; } await this.notifyPinStateToServer(); }, /** * Refresh the typing status of the current partner. */ refreshCurrentPartnerIsTyping() { this.update({ currentPartnerInactiveTypingTimer: { doReset: this.currentPartnerInactiveTypingTimer ? true : undefined, }, }); }, /** * Called to refresh a registered other member that is typing something. * * @param {Member} member */ refreshOtherMemberTypingMember(member) { this.messaging.models["OtherMemberLongTypingInThreadTimer"].insert({ member, thread: this, timer: { doReset: true, }, }); }, /** * 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.update({ currentPartnerInactiveTypingTimer: {}, currentPartnerLongTypingTimer: {}, }); // Manage typing member relation. const memberOfCurrentUser = this.channel.memberOfCurrentUser; const newOrderedTypingMembers = [ ...this.orderedTypingMembers.filter((member) => member !== memberOfCurrentUser), memberOfCurrentUser, ]; this.update({ isCurrentPartnerTyping: true, orderedTypingMembers: newOrderedTypingMembers, typingMembers: link(memberOfCurrentUser), }); // Notify typing status to other members. await this.throttleNotifyCurrentPartnerTypingStatus.do(); }, /** * Called to register a new other member partner is typing something. * * @param {Member} member */ registerOtherMemberTypingMember(member) { this.update({ otherMembersLongTypingTimers: insert({ member }) }); const newOrderedTypingMembers = [ ...this.orderedTypingMembers.filter((currentMember) => currentMember !== member), member, ]; this.update({ orderedTypingMembers: newOrderedTypingMembers, typingMembers: link(member), }); }, /** * Renames this thread to the given new name. * Only makes sense for channels. * * @param {string} name */ async rename(name) { this.update({ name }); return this.messaging.rpc({ model: "mail.channel", method: "channel_rename", args: [[this.id]], kwargs: { name }, }); }, /** * Sets the custom name of this thread for the current user to the given * new name. * Only makes sense for channels. * * @param {string} newName */ async setCustomName(newName) { return this.messaging.rpc({ model: "mail.channel", method: "channel_set_custom_name", args: [this.id], kwargs: { name: newName }, }); }, /** * @param {Attachment} attachment */ async setMainAttachment(attachment) { this.update({ mainAttachment: attachment }); await this.messaging.rpc({ model: "ir.attachment", method: "register_as_main_attachment", args: [[this.mainAttachment.id]], }); }, /** * Unfollow current partner from this thread. */ async unfollow() { await this.followerOfCurrentPartner.remove(); }, /** * Unpin this thread and notify server of the change. */ async unpin() { this.update({ isPendingPinned: false }); if (this.messaging.currentGuest) { return; } 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. */ unregisterCurrentPartnerIsTyping({ immediateNotify = false } = {}) { // Handling of typing timers. this.update({ currentPartnerInactiveTypingTimer: clear(), currentPartnerLongTypingTimer: clear(), }); // Manage typing member relation. const memberOfCurrentUser = this.channel.memberOfCurrentUser; const newOrderedTypingMembers = this.orderedTypingMembers.filter( (member) => member !== memberOfCurrentUser ); this.update({ isCurrentPartnerTyping: false, orderedTypingMembers: newOrderedTypingMembers, typingMembers: unlink(memberOfCurrentUser), }); // Notify typing status to other members. if (immediateNotify) { this.throttleNotifyCurrentPartnerTypingStatus.clear(); } this.throttleNotifyCurrentPartnerTypingStatus.do(); }, /** * Called to unregister an other member partner that is no longer typing * something. * * @param {Member} member */ unregisterOtherMemberTypingMember(member) { this.update({ otherMembersLongTypingTimers: insertAndUnlink({ member }) }); const newOrderedTypingMembers = this.orderedTypingMembers.filter( (currentMember) => currentMember !== member ); this.update({ orderedTypingMembers: newOrderedTypingMembers, typingMembers: unlink(member), }); }, /** * Unsubscribe current user from provided channel. */ unsubscribe() { this.leaveCall(); this.messaging.chatWindowManager.closeThread(this); this.unpin(); }, /** * @private */ async _notifyCurrentPartnerTypingStatus() { if ( this.forceNotifyNextCurrentPartnerTypingStatus || this.isCurrentPartnerTyping !== this.currentPartnerLastNotifiedIsTyping ) { if (this.model === "mail.channel") { await this.messaging.rpc( { route: "/mail/channel/notify_typing", params: { channel_id: this.id, is_typing: this.isCurrentPartnerTyping, }, }, { shadow: true } ); if (!this.exists()) { return; } } if (this.isCurrentPartnerTyping && this.currentPartnerLongTypingTimer) { this.currentPartnerLongTypingTimer.update({ doReset: true }); } } this.update({ currentPartnerLastNotifiedIsTyping: this.isCurrentPartnerTyping, forceNotifyNextCurrentPartnerTypingStatus: false, }); }, /** * @private */ _onChangeLastSeenByCurrentPartnerMessageId() { this.messaging.messagingBus.trigger( "o-thread-last-seen-by-current-partner-message-id-changed", { thread: this, } ); }, /** * 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.messaging.chatWindowManager) { // avoid crash during destroy return; } if (this.messaging.device.isSmall) { return; } if (this.serverFoldState === "closed") { this.messaging.chatWindowManager.closeThread(this, { notifyServer: false, }); } else { this.messaging.chatWindowManager.openThread(this, { isFolded: this.serverFoldState === "folded", notifyServer: false, }); } }, /** * Event handler for clicking thread in discuss app. */ async onClick() { await this.open(); }, onCurrentPartnerInactiveTypingTimeout() { this.unregisterCurrentPartnerIsTyping(); }, /** * Called when current partner has been typing for a very long time. * Immediately notify other members that he/she is still typing. */ async onCurrentPartnerLongTypingTimeout() { this.update({ currentPartnerLongTypingTimer: clear(), forceNotifyNextCurrentPartnerTypingStatus: true, isCurrentPartnerTyping: true, }); this.throttleNotifyCurrentPartnerTypingStatus.clear(); await this.throttleNotifyCurrentPartnerTypingStatus.do(); }, }, fields: { accessRestrictedToGroupText: attr({ default: "", compute() { if (!this.authorizedGroupFullName) { return clear(); } return sprintf(this.env._t('Access restricted to group "%(groupFullName)s"'), { groupFullName: this.authorizedGroupFullName, }); }, }), /** * Determines the `mail.activity` that belong to `this`, assuming `this` * has activities (@see hasActivities). */ activities: many("Activity", { inverse: "thread" }), activity_state: attr({ compute() { if (this.overdueActivities.length > 0) { return "overdue"; } if (this.todayActivities.length > 0) { return "today"; } if (this.futureActivities.length > 0) { return "planned"; } return clear(); }, }), allAttachments: many("Attachment", { inverse: "allThreads", compute() { return this.originThreadAttachments; }, sort: [ ["truthy-first", "isUploading"], ["greater-first", "id"], ], }), areAttachmentsLoaded: attr({ default: false }), attachmentsInWebClientView: many("Attachment", { inverse: "threadsAsAttachmentsInWebClientView", readonly: true, sort: [["greater-first", "id"]], }), authorizedGroupFullName: attr(), cache: one("ThreadCache", { default: {}, inverse: "thread", readonly: true, required: true, }), channel: one("Channel", { inverse: "thread", isCausal: true, readonly: true }), /** * States the chat window related to this thread (if any). */ chatWindow: one("ChatWindow", { inverse: "thread" }), /** * Determines the composer state of this thread. */ composer: one("Composer", { inverse: "thread", compute() { if (this.mailbox) { return clear(); } return {}; }, }), creator: one("User"), /** * 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. */ currentPartnerInactiveTypingTimer: one("Timer", { inverse: "threadAsCurrentPartnerInactiveTypingTimerOwner", }), /** * 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. */ currentPartnerLastNotifiedIsTyping: attr(), /** * 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. */ currentPartnerLongTypingTimer: one("Timer", { inverse: "threadAsCurrentPartnerLongTypingTimerOwner", }), /** * Determines the default display mode of this channel. Should contain * either no value (to display the chat), or 'video_full_screen' to * start a call in full screen. */ defaultDisplayMode: attr(), /** * States the description of this thread. Only applies to channels. */ description: attr(), displayName: attr({ compute() { if (this.channel) { return this.channel.displayName; } if (this.mailbox) { return this.mailbox.name; } return this.name; }, }), fetchMessagesParams: attr({ compute() { if (this.model === "mail.channel") { return { channel_id: this.id }; } if (this.mailbox) { return {}; } return { thread_id: this.id, thread_model: this.model, }; }, }), fetchMessagesUrl: attr({ compute() { if (this.model === "mail.channel") { return `/mail/channel/messages`; } if (this.mailbox) { return this.mailbox.fetchMessagesUrl; } return `/mail/thread/messages`; }, }), followerOfCurrentPartner: one("Follower", { inverse: "followedThreadAsFollowerOfCurrentPartner", }), followersPartner: many("Partner", { related: "followers.partner" }), followers: many("Follower", { inverse: "followedThread" }), /** * 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. */ forceNotifyNextCurrentPartnerTypingStatus: attr({ default: false }), /** * States the `Activity` that belongs to `this` and that are * planned in the future (due later than today). */ futureActivities: many("Activity", { compute() { return this.activities.filter((activity) => activity.state === "planned"); }, }), group_based_subscription: attr({ default: false }), /** * States whether the current user has read access for this thread. */ hasReadAccess: attr(), /** * States whether `this` has activities (`mail.activity.mixin` server side). */ hasActivities: attr({ default: false }), /** * Determines whether the RTC call feature should be displayed. */ hasCallFeature: attr({ compute() { if (!this.channel) { return clear(); } return ["channel", "chat", "group"].includes(this.channel.channel_type); }, }), /** * States whether this thread should has the invite feature. Only makes * sense for channels. */ hasInviteFeature: attr({ compute() { return this.model === "mail.channel"; }, }), /** * Determines whether it makes sense for this thread to have a member list. */ hasMemberListFeature: attr({ compute() { if (!this.channel) { return clear(); } return ["channel", "group"].includes(this.channel.channel_type); }, }), /** * States whether there is a server request for joining or leaving the RTC session. * TODO Should maybe be on messaging (after messaging env rebase) to lock the rpc across all threads. */ hasPendingRtcRequest: attr({ default: false }), /** * Determine whether this thread has the seen indicators (V and VV) * enabled or not. */ hasSeenIndicators: attr({ default: false, compute() { if (!this.channel) { return clear(); } return ["chat", "livechat"].includes(this.channel.channel_type); }, }), /** * States whether current user has write access for the record. If yes, few other operations * (like adding other followers to the thread) are enabled for the user. */ hasWriteAccess: attr({ default: false }), id: attr({ identifying: true }), invitationLink: attr({ compute() { if (!this.channel) { return clear(); } if ( !this.uuid || !this.channel.channel_type || this.channel.channel_type === "chat" ) { return clear(); } return `${window.location.origin}/chat/${this.id}/${this.uuid}`; }, }), /** * List of members that have been invited to the RTC call of this channel. */ invitedMembers: many("ChannelMember"), /** * Determines whether this description can be changed. * Only makes sense for channels. */ isChannelDescriptionChangeable: attr({ compute() { if (!this.channel) { return clear(); } return ["channel", "group"].includes(this.channel.channel_type); }, }), /** * Determines whether this thread can be renamed. * Only makes sense for channels. */ isChannelRenamable: attr({ compute() { if (!this.channel) { return clear(); } return ["chat", "channel", "group"].includes(this.channel.channel_type); }, }), /** * 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({ default: false, compute() { if (!this.channel) { return clear(); } return ["chat", "group"].includes(this.channel.channel_type); }, }), isCurrentPartnerFollowing: attr({ default: false, compute() { return this.followers.some( (follower) => follower.partner && follower.partner === this.messaging.currentPartner ); }, }), isCurrentPartnerTyping: attr({ default: false }), /** * States whether this thread description is editable by the current user. */ isDescriptionEditableByCurrentUser: attr({ compute() { return Boolean( this.messaging && this.messaging.currentUser && this.messaging.currentUser.isInternalUser && this.isChannelDescriptionChangeable ); }, }), /** * States whether `this` is currently loading attachments. */ isLoadingAttachments: attr({ default: false }), /** * 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() { return this.isPendingPinned !== undefined ? this.isPendingPinned : this.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 }), /** * States the date and time of the last interesting event that happened * in this channel for this partner. This includes: creating, joining, * pinning, and new message posted. It is contained as a Date object. */ lastInterestDateTime: attr(), lastCurrentPartnerMessageSeenByEveryone: one("Message", { compute() { const otherPartnerSeenInfos = this.partnerSeenInfos.filter( (partnerSeenInfo) => partnerSeenInfo.partner !== this.messaging.currentPartner ); if (otherPartnerSeenInfos.length === 0) { return clear(); } const otherPartnersLastSeenMessageIds = otherPartnerSeenInfos.map( (partnerSeenInfo) => partnerSeenInfo.lastSeenMessage ? partnerSeenInfo.lastSeenMessage.id : 0 ); if (otherPartnersLastSeenMessageIds.length === 0) { return clear(); } const lastMessageSeenByAllId = Math.min(...otherPartnersLastSeenMessageIds); const currentPartnerOrderedSeenMessages = this.orderedNonTransientMessages.filter( (message) => message.author === this.messaging.currentPartner && message.id <= lastMessageSeenByAllId ); if ( !currentPartnerOrderedSeenMessages || currentPartnerOrderedSeenMessages.length === 0 ) { return clear(); } return currentPartnerOrderedSeenMessages.slice().pop(); }, }), /** * Last message of the thread, could be a transient one. */ lastMessage: one("Message", { compute() { const { length: l, [l - 1]: lastMessage } = this.orderedMessages; if (lastMessage) { return lastMessage; } return clear(); }, }), /** * States the last known needaction message having this thread as origin. */ lastNeedactionMessageAsOriginThread: one("Message", { compute() { const orderedNeedactionMessagesAsOriginThread = this.needactionMessagesAsOriginThread.sort( (m1, m2) => (m1.id < m2.id ? -1 : 1) ); const { length: l, [l - 1]: lastNeedactionMessageAsOriginThread, } = orderedNeedactionMessagesAsOriginThread; if (lastNeedactionMessageAsOriginThread) { return lastNeedactionMessageAsOriginThread; } return clear(); }, }), /** * Last non-transient message. */ lastNonTransientMessage: one("Message", { compute() { const { length: l, [l - 1]: lastMessage } = this.orderedNonTransientMessages; if (lastMessage) { return lastMessage; } return clear(); }, }), /** * 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({ default: 0, /** * 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. */ compute() { const firstMessage = this.orderedMessages[0]; if ( firstMessage && this.rawLastSeenByCurrentPartnerMessageId && this.rawLastSeenByCurrentPartnerMessageId < firstMessage.id ) { // no deduction can be made if there is a gap return this.rawLastSeenByCurrentPartnerMessageId; } let lastSeenByCurrentPartnerMessageId = this.rawLastSeenByCurrentPartnerMessageId; for (const message of this.orderedMessages) { if (message.id <= this.rawLastSeenByCurrentPartnerMessageId) { continue; } if ( (message.author && this.messaging.currentPartner && message.author === this.messaging.currentPartner) || (message.guestAuthor && this.messaging.currentGuest && message.guestAuthor === this.messaging.currentGuest) || message.isTransient ) { lastSeenByCurrentPartnerMessageId = message.id; continue; } return lastSeenByCurrentPartnerMessageId; } return lastSeenByCurrentPartnerMessageId; }, }), mailbox: one("Mailbox", { inverse: "thread" }), mainAttachment: one("Attachment"), /** * Determines the last mentioned channels of the last composer related * to this thread. Useful to sync the composer when re-creating it. */ mentionedChannelsBackup: many("Thread"), /** * Determines the last mentioned partners of the last composer related * to this thread. Useful to sync the composer when re-creating it. */ mentionedPartnersBackup: many("Partner"), /** * Determines the message before which the "new message" separator must * be positioned, if any. */ messageAfterNewMessageSeparator: one("Message", { compute() { if (!this.channel) { return clear(); } if (this.channel.localMessageUnreadCounter === 0) { return clear(); } const index = this.orderedMessages.findIndex( (message) => message.id === this.lastSeenByCurrentPartnerMessageId ); if (index === -1) { return clear(); } const message = this.orderedMessages[index + 1]; if (!message) { return clear(); } return message; }, }), 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. */ messages: many("Message", { inverse: "threads", readonly: true }), /** * All messages that have been originally posted in this thread. */ messagesAsOriginThread: many("Message", { inverse: "originThread", isCausal: true }), /** * Contains the message fetched/seen indicators for all messages of this thread. */ messageSeenIndicators: many("MessageSeenIndicator", { inverse: "thread" }), messagingAsAllCurrentClientThreads: one("Messaging", { inverse: "allCurrentClientThreads", compute() { if ( !this.messaging || !this.channel || !this.channel.memberOfCurrentUser || !this.isServerPinned ) { return clear(); } return this.messaging; }, }), messagingAsRingingThread: one("Messaging", { inverse: "ringingThreads", compute() { if (this.rtcInvitingSession) { return this.messaging; } return clear(); }, }), messagingMenuAsPinnedAndUnreadChannel: one("MessagingMenu", { inverse: "pinnedAndUnreadChannels", compute() { if (!this.messaging || !this.messaging.messagingMenu) { return clear(); } if (this.channel && this.isPinned && this.channel.localMessageUnreadCounter > 0) { return this.messaging.messagingMenu; } return clear(); }, }), model: attr({ identifying: true }), model_name: attr(), moduleIcon: attr(), name: attr(), /** * States all known needaction messages having this thread as origin. */ needactionMessagesAsOriginThread: many("Message", { compute() { return this.messagesAsOriginThread.filter((message) => message.isNeedaction); }, }), /** * All messages ordered like they are displayed. */ orderedMessages: many("Message", { compute() { return this.messages.sort((m1, m2) => (m1.id < m2.id ? -1 : 1)); }, }), /** * All messages ordered like they are displayed. This field does not * contain transient messages which are not "real" records. */ orderedNonTransientMessages: many("Message", { compute() { return this.orderedMessages.filter((m) => !m.isTransient); }, }), /** * Ordered typing members on this thread, excluding the current partner. */ orderedOtherTypingMembers: many("ChannelMember", { compute() { return this.orderedTypingMembers.filter((member) => !member.isMemberOfCurrentUser); }, }), /** * Ordered list of typing members. */ orderedTypingMembers: many("ChannelMember"), originThreadAttachments: many("Attachment", { inverse: "originThread", isCausal: true }), /** * 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 */ otherMembersLongTypingTimers: many("OtherMemberLongTypingInThreadTimer", { inverse: "thread", }), /** * States the `Activity` that belongs to `this` and that are * overdue (due earlier than today). */ overdueActivities: many("Activity", { compute() { return this.activities.filter((activity) => activity.state === "overdue"); }, }), /** * Contains the seen information for all members of the thread. * FIXME This field should be readonly once task-2336946 is done. */ partnerSeenInfos: many("ThreadPartnerSeenInfo", { inverse: "thread" }), /** * 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(), rawLastSeenByCurrentPartnerMessageId: attr({ default: 0 }), /** * If set, the current thread is the thread that hosts the current RTC call. */ rtc: one("Rtc", { inverse: "channel" }), callInviteRequestPopup: one("CallInviteRequestPopup", { inverse: "thread", compute() { if (this.rtcInvitingSession) { return {}; } return clear(); }, }), /** * The session that invited the current user, it is only set when the * invitation is still pending. */ rtcInvitingSession: one("RtcSession", { inverse: "calledChannels" }), rtcSessions: many("RtcSession", { inverse: "channel", isCausal: true }), /** * 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 considered by the server. * * Useful to compute localMessageUnreadCounter field. * * @see localMessageUnreadCounter */ serverLastMessage: one("Message"), suggestable: one("ComposerSuggestable", { default: {}, inverse: "thread", readonly: true, required: true, }), /** * Determines the `SuggestedRecipientInfo` concerning `this`. */ suggestedRecipientInfoList: many("SuggestedRecipientInfo", { inverse: "thread", isCausal: true, }), /** * Determines the last content of the last composer related to this * thread. Useful to sync the composer when re-creating it. */ textInputContentBackup: attr({ default: "" }), /** * Determines the last cursor end of the last composer related to this * thread. Useful to sync the composer when re-creating it. */ textInputCursorEndBackup: attr({ default: 0 }), /** * Determines the last cursor start of the last composer related to this * thread. Useful to sync the composer when re-creating it. */ textInputCursorStartBackup: attr({ default: 0 }), /** * Determines the last selection direction of the last composer related * to this thread. Useful to sync the composer when re-creating it. */ textInputSelectionDirectionBackup: attr({ default: "none" }), threadViews: many("ThreadView", { inverse: "thread" }), /** * 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 */ throttleNotifyCurrentPartnerTypingStatus: one("Throttle", { inverse: "threadAsThrottleNotifyCurrentPartnerTypingStatus", compute() { return { func: () => this._notifyCurrentPartnerTypingStatus(), }; }, }), /** * States the `Activity` that belongs to `this` and that are due * specifically today. */ todayActivities: many("Activity", { compute() { return this.activities.filter((activity) => activity.state === "today"); }, }), threadNeedactionPreviewViews: many("ThreadNeedactionPreviewView", { inverse: "thread" }), /** * Members that are currently typing something in the composer of this * thread, including current partner. */ typingMembers: many("ChannelMember"), /** * Text that represents the status on this thread about typing members. */ typingStatusText: attr({ default: "", compute() { if (this.orderedOtherTypingMembers.length === 0) { return clear(); } if (this.orderedOtherTypingMembers.length === 1) { return sprintf( this.env._t("%s is typing..."), this.getMemberName(this.orderedOtherTypingMembers[0].persona) ); } if (this.orderedOtherTypingMembers.length === 2) { return sprintf( this.env._t("%s and %s are typing..."), this.getMemberName(this.orderedOtherTypingMembers[0].persona), this.getMemberName(this.orderedOtherTypingMembers[1].persona) ); } return sprintf( this.env._t("%s, %s and more are typing..."), this.getMemberName(this.orderedOtherTypingMembers[0].persona), this.getMemberName(this.orderedOtherTypingMembers[1].persona) ); }, }), /** * URL to access to the conversation. */ url: attr({ default: "", /** * Compute an url string that can be used inside a href attribute */ compute() { const baseHref = 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}`; }, }), uuid: attr(), /** * The amount of videos broadcast in the current Rtc call */ videoCount: attr({ default: 0, compute() { return this.rtcSessions.filter((session) => session.videoStream).length; }, }), }, onChanges: [ { dependencies: ["lastSeenByCurrentPartnerMessageId"], methodName: "_onChangeLastSeenByCurrentPartnerMessageId", }, { dependencies: ["isServerPinned"], methodName: "_onIsServerPinnedChanged", }, { dependencies: ["serverFoldState"], methodName: "_onServerFoldStateChanged", }, ], });