Files
odoo_source/addons/mail/static/src/models/thread.js
T
2022-11-14 16:21:39 +01:00

1971 lines
73 KiB
JavaScript

/** @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<Object>} 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",
},
],
});