closes odoo/odoo#105677 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
1971 lines
73 KiB
JavaScript
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",
|
|
},
|
|
],
|
|
});
|