[PERF] mail: faster load of discuss at init messaging

- Messaging menu was slow when there are many mail failures
- Discuss app is slow whenever there are many pinned threads
- Initial page load was slow when pinned threads were group chats
  with hundreds of members

All these performance issue come from aggressively eager call
to reactive callbacks, which `onChange()` and `compute()` were
relying on.

This commits fixes the performance issue with:
- Update cycle in `@mail/record`, which calls eager computed fields
  and onChange at the end of an update transaction in discuss models,
  so that computation is called only once rather than many times
  (while only the later value matter)
- Add support for lazy computed fields (which they are by default),
  so that computed fields are only computed when needed.
- Add support for sorted many fields, which also follow the update
  cycle and eager/lazy mode of field. This makes sorting operation
  on many fields efficient and preserves eventual correctness of
  expected order of items in these relational fields.

With module `contacts` installed and populate `--size="medium"`,
init_messaging and mail_failures took 12sec. for Mitchell Admin.
With this commit, this has been reduced to 1sec.

closes odoo/odoo#143382

Related: odoo/enterprise#51410
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Alexandre Kühn
2023-11-28 21:21:04 +00:00
parent fb2ee0200d
commit 0c998ef1cd
26 changed files with 1269 additions and 672 deletions
@@ -6,19 +6,6 @@ import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
patch(MessagingMenu.prototype, {
/**
* @override
*/
tabToThreadType(tab) {
const threadTypes = super.tabToThreadType(tab);
if (tab === "chat" && !this.ui.isSmall) {
threadTypes.push("livechat");
}
if (tab === "livechat") {
threadTypes.push("livechat");
}
return threadTypes;
},
/**
* @override
*/
@@ -0,0 +1,21 @@
/* @odoo-module */
import { Store } from "@mail/core/common/store_service";
import { patch } from "@web/core/utils/patch";
patch(Store.prototype, {
/**
* @override
*/
tabToThreadType(tab) {
const threadTypes = super.tabToThreadType(tab);
if (tab === "chat" && !this.env.services.ui.isSmall) {
threadTypes.push("livechat");
}
if (tab === "livechat") {
threadTypes.push("livechat");
}
return threadTypes;
},
});
@@ -11,7 +11,6 @@ patch(Thread, {
if (thread.type === "livechat") {
assignIn(thread, data, ["anonymous_name", "anonymous_country"]);
this.store.discuss.livechat.threads.add(thread);
this.env.services["mail.thread"].sortChannels();
}
return thread;
},
@@ -28,6 +28,7 @@ patch(ThreadService.prototype, {
return super.canUnpin(thread);
},
/** @deprecated */
sortChannels() {
super.sortChannels();
// Live chats are sorted by most recent interest date time in the sidebar.
@@ -41,7 +41,7 @@ class ChannelMember(models.Model):
random.choices([1, 2], weights=[1, 100], k=1)[0]
if channel.channel_type == "chat"
else big
if big_done < 2
if big_done < 2 and channel.channel_type == "channel"
else random.randrange(max_users)
)
if number_users >= big and admin in users_by_channel[channel]:
+3
View File
@@ -13,6 +13,7 @@ declare module "models" {
import { MessageReactions as MessageReactionsClass } from "@mail/core/common/message_reactions_model";
import { Notification as NotificationClass } from "@mail/core/common/notification_model";
import { Persona as PersonaClass } from "@mail/core/common/persona_model";
import { Store as StoreClass } from "@mail/core/common/store_service";
import { Thread as ThreadClass } from "@mail/core/common/thread_model";
// define interfaces for jsdoc, including with patches
@@ -30,6 +31,7 @@ declare module "models" {
export interface MessageReactions extends MessageReactionsClass {}
export interface Notification extends NotificationClass {}
export interface Persona extends PersonaClass {}
export interface Store extends StoreClass {}
export interface Thread extends ThreadClass {}
// required to propagate types in relational fields
@@ -48,6 +50,7 @@ declare module "models" {
"MessageReactions": MessageReactions,
"Notification": Notification,
"Persona": Persona,
"Store": Store,
"Thread": Thread,
}
}
@@ -1,7 +1,6 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { onChange } from "@mail/utils/common/misc";
import { deserializeDateTime } from "@web/core/l10n/dates";
import { FileModelMixin } from "@web/core/file_viewer/file_model";
@@ -21,7 +20,7 @@ export class Attachment extends FileModelMixin(Record) {
static new(data) {
/** @type {import("models").Attachment} */
const attachment = super.new(data);
onChange(attachment, ["extension", "name"], () => {
Record.onChange(attachment, ["extension", "name"], () => {
if (!attachment.extension && attachment.name) {
attachment.extension = attachment.name.split(".").pop();
}
@@ -29,11 +28,6 @@ export class Attachment extends FileModelMixin(Record) {
return attachment;
}
update(data) {
super.update(data);
this.originThread?.attachments.sort((a1, a2) => (a1.id < a2.id ? 1 : -1));
}
originThread = Record.one("Thread", { inverse: "attachments" });
res_name;
message = Record.one("Message");
@@ -13,6 +13,19 @@ export class DiscussAppCategory extends Record {
return super.insert(...arguments);
}
/**
* @param {import("models").Thread} t1
* @param {import("models").Thread} t2
*/
sortThreads(t1, t2) {
if (this.id === "channels") {
return String.prototype.localeCompare.call(t1.name, t2.name);
}
if (this.id === "chats") {
return t2.lastInterestDateTime.ts - t1.lastInterestDateTime.ts;
}
}
/** @type {string} */
extraClass;
/** @string */
@@ -28,7 +41,11 @@ export class DiscussAppCategory extends Record {
addTitle;
/** @type {string} */
addHotkey;
threads = Record.many("Thread");
threads = Record.many("Thread", {
sort(t1, t2) {
return this.sortThreads(t1, t2);
},
});
}
DiscussAppCategory.register();
@@ -1,7 +1,7 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { assignIn, onChange } from "@mail/utils/common/misc";
import { assignIn } from "@mail/utils/common/misc";
import { markRaw } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
@@ -14,7 +14,7 @@ export class Failure extends Record {
static new(data) {
/** @type {import("models").Failure} */
const failure = super.new(data);
onChange(failure, "notifications", () => {
Record.onChange(failure, "notifications", () => {
if (failure.notifications.length === 0) {
failure.delete();
}
@@ -2,7 +2,7 @@
import { Record } from "@mail/core/common/record";
import { htmlToTextContentInline } from "@mail/utils/common/format";
import { assignDefined, assignIn, onChange } from "@mail/utils/common/misc";
import { assignDefined, assignIn } from "@mail/utils/common/misc";
import { toRaw } from "@odoo/owl";
@@ -19,7 +19,7 @@ export class Message extends Record {
static records = {};
static new(data) {
const message = super.new(data);
onChange(message, "isEmpty", () => {
Record.onChange(message, "isEmpty", () => {
if (message.isEmpty && message.isStarred) {
message.isStarred = false;
const starred = this.store.discuss.starred;
@@ -109,7 +109,16 @@ export class Message extends Record {
linkPreviews = Record.many("LinkPreview", { inverse: "message" });
/** @type {number[]} */
needaction_partner_ids = [];
originThread = Record.one("Thread", {
/**
* @deprecated
* Still necessary until custom insert()/update() rely on this.
* Fields are computed only at end of update cycle, thus it is not
* computed during custom update
*/
get originThread() {
return this._store.Thread.get({ model: this.model, id: this.res_id });
}
originThread2 = Record.one("Thread", {
inverse: "allMessages",
compute() {
if (this.model && this.res_id) {
@@ -2,8 +2,6 @@
import { cleanTerm } from "@mail/utils/common/format";
import { reactive } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Deferred } from "@web/core/utils/concurrency";
@@ -28,8 +26,6 @@ export class Messaging {
this.imStatusService = services.im_status;
const user = services.user;
this.store.Persona.insert({ id: user.partnerId, type: "partner", isAdmin: user.isAdmin });
this.registeredImStatusPartners = reactive([], () => this.updateImStatusRegistration());
this.store.registeredImStatusPartners = this.registeredImStatusPartners;
this.store.discuss.inbox = {
id: "inbox",
model: "mail.box",
@@ -93,17 +89,14 @@ export class Messaging {
this.store.hasMessageTranslationFeature = data.hasMessageTranslationFeature;
}
updateImStatusRegistration() {
this.imStatusService.registerToImStatus(
"res.partner",
/**
* Read value from registeredImStatusPartners own reactive rather than
* from store reactive to ensure the callback keeps being registered.
*/
[...this.registeredImStatusPartners]
);
/** @deprecated */
get registeredImStatusPartners() {
return this.store.registeredImStatusPartners;
}
/** @deprecated */
updateImStatusRegistration() {}
// -------------------------------------------------------------------------
// actions that can be performed on the messaging system
// -------------------------------------------------------------------------
@@ -32,7 +32,7 @@ export class Notification extends Record {
if (!this.message?.author?.eq(this._store.self)) {
return;
}
const failure = this._store.failures.find((f) => {
const failure = Object.values(this._store.Failure.records).find((f) => {
return (
f.resModel === thread?.model &&
f.type === this.notification_type &&
@@ -46,6 +46,7 @@ export class Notification extends Record {
}
: false;
},
eager: true,
});
/** @type {string} */
failure_type;
@@ -25,18 +25,6 @@ export class Persona extends Record {
return super.insert(...arguments);
}
update(data) {
super.update(data);
if (
this.type === "partner" &&
this.im_status !== "im_partner" &&
!this.is_public &&
!this._store.registeredImStatusPartners?.includes(this.id)
) {
this._store.registeredImStatusPartners?.push(this.id);
}
}
channelMembers = Record.many("ChannelMember");
/** @type {number} */
id;
File diff suppressed because it is too large Load Diff
@@ -1,32 +1,8 @@
/* @odoo-module */
import { onChange } from "@mail/utils/common/misc";
import { markup, reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { debounce } from "@web/core/utils/timing";
import { modelRegistry, Record, RecordUses, RecordList } from "./record";
/**
* Class of markup, useful to detect content that is markup and to
* automatically markup field during trusted insert
*/
const Markup = markup("").constructor;
export class BaseStore extends Record {
/**
* @param {string} localId
* @returns {Record}
*/
get(localId) {
if (typeof localId !== "string") {
return undefined;
}
const modelName = Record.modelFromLocalId(localId);
return this[modelName].records[localId];
}
}
import { Record, makeStore, BaseStore } from "./record";
export class Store extends BaseStore {
/** @returns {import("models").Store|import("models").Store[]} */
@@ -69,6 +45,10 @@ export class Store extends BaseStore {
/** @type {typeof import("@mail/core/common/thread_model").Thread} */
Thread;
get registeredImStatusPartners() {
return this.imStatusTrackedPersonas.map((persona) => persona.id);
}
lastChannelSubscription = "";
/** This is the current logged partner */
user = Record.one("Persona");
@@ -93,12 +73,109 @@ export class Store extends BaseStore {
odoobotOnboarding;
users = {};
internalUserGroupId = null;
registeredImStatusPartners = null;
imStatusTrackedPersonas = Record.many("Persona", {
compute() {
return Object.values(this.Persona?.records ?? []).filter(
(persona) =>
persona.type === "partner" &&
persona.im_status !== "im_partner" &&
!persona.is_public
);
},
onUpdate() {
this.updateImStatusRegistration();
},
eager: true,
});
hasLinkPreviewFeature = true;
// messaging menu
menu = { counter: 0 };
menuThreads = Record.many("Thread", {
/** @this {import("models").Store} */
compute() {
/** @type {import("models").Thread[]} */
let threads = Object.values(this.Thread.records).filter(
(thread) =>
thread.displayToSelf ||
(thread.needactionMessages.length > 0 && thread.type !== "mailbox")
);
const tab = this.discuss.activeTab;
if (tab !== "main") {
threads = threads.filter(({ type }) => this.tabToThreadType(tab).includes(type));
} else if (tab === "main" && this.env.inDiscussApp) {
threads = threads.filter(({ type }) =>
this.tabToThreadType("mailbox").includes(type)
);
}
return threads;
},
/**
* @this {import("models").Store}
* @param {import("models").Thread} a
* @param {import("models").Thread} b
*/
sort(a, b) {
/**
* Ordering:
* - threads with needaction
* - unread channels
* - read channels
* - odoobot chat
*
* In each group, thread with most recent message comes first
*/
if (a.correspondent?.eq(this.odoobot) && !b.correspondent?.eq(this.odoobot)) {
return 1;
}
if (b.correspondent?.eq(this.odoobot) && !a.correspondent?.eq(this.odoobot)) {
return -1;
}
if (a.needactionMessages.length > 0 && b.needactionMessages.length === 0) {
return -1;
}
if (b.needactionMessages.length > 0 && a.needactionMessages.length === 0) {
return 1;
}
if (a.message_unread_counter > 0 && b.message_unread_counter === 0) {
return -1;
}
if (b.message_unread_counter > 0 && a.message_unread_counter === 0) {
return 1;
}
if (
!a.newestPersistentNotEmptyOfAllMessage?.datetime &&
b.newestPersistentNotEmptyOfAllMessage?.datetime
) {
return 1;
}
if (
!b.newestPersistentNotEmptyOfAllMessage?.datetime &&
a.newestPersistentNotEmptyOfAllMessage?.datetime
) {
return -1;
}
if (
a.newestPersistentNotEmptyOfAllMessage?.datetime &&
b.newestPersistentNotEmptyOfAllMessage?.datetime &&
a.newestPersistentNotEmptyOfAllMessage?.datetime !==
b.newestPersistentNotEmptyOfAllMessage?.datetime
) {
return (
b.newestPersistentNotEmptyOfAllMessage.datetime -
a.newestPersistentNotEmptyOfAllMessage.datetime
);
}
return b.localId > a.localId ? 1 : -1;
},
});
discuss = Record.one("DiscussApp");
failures = Record.many("Failure");
failures = Record.many("Failure", {
/**
* @param {import("models").Failure} f1
* @param {import("models").Failure} f2
*/
sort: (f1, f2) => f2.lastMessage?.id - f1.lastMessage?.id,
});
activityCounter = 0;
isMessagingReady = false;
@@ -106,6 +183,20 @@ export class Store extends BaseStore {
return this.guest ?? this.user;
}
updateImStatusRegistration() {
this.env.services.im_status?.registerToImStatus("res.partner", [
...this.registeredImStatusPartners,
]);
}
/**
* @param {'chat' | 'group'} tab
* @returns Thread types matching the given tab.
*/
tabToThreadType(tab) {
return tab === "chat" ? ["chat", "group"] : [tab];
}
setup() {
super.setup();
this.updateBusSubscription = debounce(this.updateBusSubscription, 0); // Wait for thread fully inserted.
@@ -129,238 +220,6 @@ export class Store extends BaseStore {
}
Store.register();
export function makeStore(env) {
const res = {
// fake store for now, until it becomes a model
/** @type {Store} */
store: {
env,
get: (...args) => Store.prototype.get.call(this, ...args),
},
};
const Models = {};
for (const [name, _OgClass] of modelRegistry.getEntries()) {
/** @type {typeof Record} */
const OgClass = _OgClass;
if (res.store[name]) {
throw new Error(`There must be no duplicated Model Names (duplicate found: ${name})`);
}
// classes cannot be made reactive because they are functions and they are not supported.
// work-around: make an object whose prototype is the class, so that static props become
// instance props.
const Model = Object.assign(Object.create(OgClass), { env, store: res.store });
// Produce another class with changed prototype, so that there are automatic get/set on relational fields
const Class = {
[OgClass.name]: class extends OgClass {
constructor() {
super();
const proxy = new Proxy(this, {
/** @param {Record} receiver */
get(target, name, receiver) {
if (name !== "_fields" && name in receiver._fields) {
const l1 = receiver._fields[name];
if (RecordList.isMany(l1)) {
return l1;
}
return l1[0];
}
return Reflect.get(target, name, receiver);
},
deleteProperty(target, name) {
if (name !== "_fields" && name in target._fields) {
const r1 = target;
const l1 = r1._fields[name];
l1.clear();
return true;
}
const ret = Reflect.deleteProperty(target, name);
return ret;
},
/** @param {Record} receiver */
set(target, name, val, receiver) {
if (name === "Model" || !(name in receiver.Model._fields)) {
Reflect.set(target, name, val, receiver);
return true;
}
if (Record.isAttr(receiver.Model._fields[name])) {
if (
receiver.Model._fields[name].html &&
Record.trusted &&
typeof val === "string" &&
!(val instanceof Markup)
) {
Reflect.set(target, name, markup(val), receiver);
} else {
Reflect.set(target, name, val, receiver);
}
return true;
}
/** @type {RecordList<Record>} */
const l1 = receiver._fields[name];
if (RecordList.isMany(l1)) {
// [Record.many] =
if (Record.isCommand(val)) {
for (const [cmd, cmdData] of val) {
if (Array.isArray(cmdData)) {
for (const item of cmdData) {
if (cmd === "ADD") {
l1.add(item);
} else if (cmd === "ADD.noinv") {
l1._addNoinv(item);
} else if (cmd === "DELETE.noinv") {
l1._deleteNoinv(item);
} else {
l1.delete(item);
}
}
} else {
if (cmd === "ADD") {
l1.add(cmdData);
} else if (cmd === "ADD.noinv") {
l1._addNoinv(cmdData);
} else if (cmd === "DELETE.noinv") {
l1._deleteNoinv(cmdData);
} else {
l1.delete(cmdData);
}
}
}
return true;
}
if ([null, false, undefined].includes(val)) {
l1.clear();
return true;
}
if (!Array.isArray(val)) {
val = [val];
}
/** @type {Record[]|Set<Record>|RecordList<Record>} */
const collection = Record.isRecord(val) ? [val] : val;
const oldRecords = l1.slice();
for (const r2 of oldRecords) {
r2.__uses__.delete(l1);
}
// l1 and collection could be same record list,
// save before clear to not push mutated recordlist that is empty
const col = [...collection];
l1.clear();
l1.push(...col);
} else {
// [Record.one] =
if (Record.isCommand(val)) {
const [cmd, cmdData] = val.at(-1);
if (cmd === "ADD") {
l1.add(cmdData);
} else if (cmd === "ADD.noinv") {
l1._addNoinv(cmdData);
} else if (cmd === "DELETE.noinv") {
l1._deleteNoinv(cmdData);
} else {
l1.delete(cmdData);
}
return true;
}
if ([null, false, undefined].includes(val)) {
delete receiver[name];
return true;
}
l1.add(val);
}
return true;
},
});
if (this instanceof BaseStore) {
res.store = proxy;
}
for (const name in Model._fields) {
if (Record.isRelation(this[name]?.[0])) {
// Relational fields contain symbols for detection in original class.
// This constructor is called on genuine records:
// - 'one' fields => undefined
// - 'many' fields => RecordList
// this[name]?.[0] is ONE_SYM or MANY_SYM
const newVal = new RecordList(this[name]?.[0]);
if (this instanceof BaseStore) {
newVal.store = proxy;
} else {
newVal.store = res.store;
}
newVal.name = name;
newVal.owner = proxy;
this._fields[name] = newVal;
this.__uses__ = new RecordUses();
this[name] = newVal;
} else {
this[name] = Model._fields[name].default;
}
}
for (const [name, fn] of Object.entries(Model.__computes__)) {
let boundFn;
const proxy2 = reactive(proxy, () => boundFn());
boundFn = () => (proxy[name] = fn.call(proxy2));
this.__computes__[name] = boundFn;
}
return proxy;
}
},
}[OgClass.name];
Object.assign(Model, {
Class,
records: JSON.parse(JSON.stringify(OgClass.records)),
_fields: {},
__computes__: {},
});
Models[name] = Model;
res.store[name] = Model;
// Detect fields with a dummy record and setup getter/setters on them
const obj = new OgClass();
for (const [name, val] of Object.entries(obj)) {
const SYM = val?.[0];
if (!Record.isField(SYM)) {
continue;
}
Model._fields[name] = { [SYM]: true, ...val[1] };
if (val[1].compute) {
Model.__computes__[name] = val[1].compute;
}
}
}
// Sync inverse fields
for (const Model of Object.values(Models)) {
for (const [name, definition] of Object.entries(Model._fields)) {
if (!Record.isRelation(definition)) {
continue;
}
const { targetModel, inverse } = definition;
if (targetModel && !Models[targetModel]) {
throw new Error(`No target model ${targetModel} exists`);
}
if (inverse) {
const rel2 = Models[targetModel]._fields[inverse];
if (rel2.targetModel && rel2.targetModel !== Model.name) {
throw new Error(
`Fields ${Models[targetModel].name}.${inverse} has wrong targetModel. Expected: "${Model.name}" Actual: "${rel2.targetModel}"`
);
}
if (rel2.inverse && rel2.inverse !== name) {
throw new Error(
`Fields ${Models[targetModel].name}.${inverse} has wrong inverse. Expected: "${name}" Actual: "${rel2.inverse}"`
);
}
Object.assign(rel2, { targetModel: Model.name, inverse: name });
}
}
}
// Make true store (as a model)
res.store = reactive(res.store.Store.insert());
res.store.env = env;
for (const Model of Object.values(Models)) {
Model.store = res.store;
res.store[Model.name] = Model;
}
return res.store;
}
export const storeService = {
dependencies: ["bus_service", "ui"],
/**
@@ -371,7 +230,7 @@ export const storeService = {
const store = makeStore(env);
store.discuss = {};
store.discuss.activeTab = "main";
onChange(store.Thread, "records", () => store.updateBusSubscription());
Record.onChange(store.Thread, "records", () => store.updateBusSubscription());
services.ui.bus.addEventListener("resize", () => {
store.discuss.activeTab = "main";
if (
@@ -2,7 +2,7 @@
import { AND, Record } from "@mail/core/common/record";
import { ScrollPosition } from "@mail/core/common/scroll_position";
import { assignDefined, assignIn, onChange } from "@mail/utils/common/misc";
import { assignDefined, assignIn } from "@mail/utils/common/misc";
import { deserializeDateTime } from "@web/core/l10n/dates";
import { _t } from "@web/core/l10n/translation";
@@ -33,9 +33,9 @@ export class Thread extends Record {
/** @type {import("models").Thread} */
const thread = super.new(data);
thread.composer = {};
onChange(thread, "isLoaded", () => thread.isLoadedDeferred.resolve());
onChange(thread, "channelMembers", () => this.store.updateBusSubscription());
onChange(thread, "is_pinned", () => {
Record.onChange(thread, "isLoaded", () => thread.isLoadedDeferred.resolve());
Record.onChange(thread, "channelMembers", () => this.store.updateBusSubscription());
Record.onChange(thread, "is_pinned", () => {
if (!thread.is_pinned && thread.eq(this.store.discuss.thread)) {
this.store.discuss.thread = undefined;
}
@@ -64,7 +64,6 @@ export class Thread extends Record {
assignDefined(this, { id, name, description });
if (attachments) {
this.attachments = attachments;
this.attachments.sort((a1, a2) => a2.id - a1.id);
}
if (serverData) {
assignDefined(this, serverData, [
@@ -144,11 +143,17 @@ export class Thread extends Record {
/** @type {string} */
model;
allMessages = Record.many("Message", {
inverse: "originThread",
inverse: "originThread2",
});
/** @type {boolean} */
areAttachmentsLoaded = false;
attachments = Record.many("Attachment");
attachments = Record.many("Attachment", {
/**
* @param {import("models").Attachment} a1
* @param {import("models").Attachment} a2
*/
sort: (a1, a2) => (a1.id < a2.id ? 1 : -1),
});
activeRtcSession = Record.one("RtcSession");
/** @type {object|undefined} */
channel;
@@ -413,6 +413,7 @@ export class ThreadService {
});
}
/** @deprecated */
sortChannels() {
this.store.discuss.channels.threads.sort((t1, t2) =>
String.prototype.localeCompare.call(t1.name, t2.name)
@@ -541,7 +542,6 @@ export class ThreadService {
type: "channel",
channel: { avatarCacheKey: "hello" },
});
this.sortChannels();
this.open(thread);
return thread;
}
@@ -555,7 +555,6 @@ export class ThreadService {
model: "discuss.channel",
type: "chat",
});
this.sortChannels();
return thread;
}
@@ -23,7 +23,6 @@ export class MailCoreWeb {
this.messagingService.isReady.then(() => {
this.rpc("/mail/load_message_failures", {}, { silent: true }).then((messages) => {
this.store.Message.insert(messages, { html: true });
this.store.failures.sort((f1, f2) => f2.lastMessage?.id - f1.lastMessage?.id);
});
this.busService.subscribe("mail.activity/updated", (payload) => {
if (payload.activity_created) {
@@ -96,7 +95,14 @@ export class MailCoreWeb {
}
export const mailCoreWeb = {
dependencies: ["bus_service", "mail.message", "mail.messaging", "mail.store", "rpc", "mail.thread"],
dependencies: [
"bus_service",
"mail.message",
"mail.messaging",
"mail.store",
"rpc",
"mail.thread",
],
/**
* @param {import("@web/env").OdooEnv} env
* @param {Partial<import("services").Services>} services
@@ -4,7 +4,7 @@ import { ImStatus } from "@mail/core/common/im_status";
import { NotificationItem } from "@mail/core/web/notification_item";
import { onExternalClick, useDiscussSystray } from "@mail/utils/common/hooks";
import { Component, onWillRender, useState } from "@odoo/owl";
import { Component, useState } from "@odoo/owl";
import { hasTouch } from "@web/core/browser/feature_detection";
import { Dropdown } from "@web/core/dropdown/dropdown";
@@ -35,7 +35,6 @@ export class MessagingMenu extends Component {
onExternalClick("selector", () => {
Object.assign(this.state, { addingChat: false, addingChannel: false });
});
onWillRender(() => (this.threads = this.getThreads()));
}
beforeOpen() {
@@ -67,11 +66,12 @@ export class MessagingMenu extends Component {
}
/**
* @deprecated
* @param {'chat' | 'group'} tab
* @returns Thread types matching the given tab.
*/
tabToThreadType(tab) {
return tab === "chat" ? ["chat", "group"] : [tab];
return this.store.tabToThreadType(tab);
}
get canPromptToInstall() {
@@ -120,78 +120,12 @@ export class MessagingMenu extends Component {
};
}
get threads() {
return this.getThreads();
}
getThreads() {
/** @type {import("@mail/core/common/thread_model").Thread[]} */
let threads = Object.values(this.store.Thread.records).filter(
(thread) =>
thread.displayToSelf ||
(thread.needactionMessages.length > 0 && thread.type !== "mailbox")
);
const tab = this.store.discuss.activeTab;
if (tab !== "main") {
threads = threads.filter(({ type }) => this.tabToThreadType(tab).includes(type));
} else if (tab === "main" && this.env.inDiscussApp) {
threads = threads.filter(({ type }) => this.tabToThreadType("mailbox").includes(type));
}
return threads.sort((a, b) => {
/**
* Ordering:
* - threads with needaction
* - unread channels
* - read channels
* - odoobot chat
*
* In each group, thread with most recent message comes first
*/
if (
a.correspondent?.eq(this.store.odoobot) &&
!b.correspondent?.eq(this.store.odoobot)
) {
return 1;
}
if (
b.correspondent?.eq(this.store.odoobot) &&
!a.correspondent?.eq(this.store.odoobot)
) {
return -1;
}
if (a.needactionMessages.length > 0 && b.needactionMessages.length === 0) {
return -1;
}
if (b.needactionMessages.length > 0 && a.needactionMessages.length === 0) {
return 1;
}
if (a.message_unread_counter > 0 && b.message_unread_counter === 0) {
return -1;
}
if (b.message_unread_counter > 0 && a.message_unread_counter === 0) {
return 1;
}
if (
!a.newestPersistentNotEmptyOfAllMessage?.datetime &&
b.newestPersistentNotEmptyOfAllMessage?.datetime
) {
return 1;
}
if (
!b.newestPersistentNotEmptyOfAllMessage?.datetime &&
a.newestPersistentNotEmptyOfAllMessage?.datetime
) {
return -1;
}
if (
a.newestPersistentNotEmptyOfAllMessage?.datetime &&
b.newestPersistentNotEmptyOfAllMessage?.datetime &&
a.newestPersistentNotEmptyOfAllMessage?.datetime !==
b.newestPersistentNotEmptyOfAllMessage?.datetime
) {
return (
b.newestPersistentNotEmptyOfAllMessage.datetime -
a.newestPersistentNotEmptyOfAllMessage.datetime
);
}
return b.localId > a.localId ? 1 : -1;
});
return this.store.menuThreads;
}
/**
@@ -2,14 +2,13 @@
import { DiscussApp } from "@mail/core/common/discuss_app_model";
import { Record } from "@mail/core/common/record";
import { onChange } from "@mail/utils/common/misc";
import { patch } from "@web/core/utils/patch";
patch(DiscussApp, {
new(data) {
const app = super.new(data);
onChange(app, "ringingThreads", () => {
Record.onChange(app, "ringingThreads", () => {
if (app.ringingThreads.length > 0) {
this.env.services["mail.sound_effects"].play("incoming-call", { loop: true });
} else {
@@ -1,5 +1,6 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { reactive } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
@@ -23,12 +24,18 @@ export class DiscussCoreCommon {
this.threadService = services["mail.thread"];
}
/** @returns {import("models").Thread} */
insertInitChannel(data) {
return this.createChannelThread(data);
}
setup() {
this.messagingService.isReady.then((data) => {
for (const channelData of data.channels) {
this.createChannelThread(channelData);
}
this.threadService.sortChannels();
Record.MAKE_UPDATE(() => {
for (const channelData of data.channels) {
this.insertInitChannel(channelData);
}
});
this.busService.subscribe("discuss.channel/joined", (payload) => {
const { channel, invited_by_user_id: invitedByUserId } = payload;
const thread = this.store.Thread.insert({
@@ -48,9 +55,6 @@ export class DiscussCoreCommon {
const channel = this.store.Thread.get({ model: "discuss.channel", id });
if (channel) {
channel.last_interest_dt = last_interest_dt;
if (channel.type !== "channel") {
this.threadService.sortChannels();
}
}
});
this.busService.subscribe("discuss.channel/leave", (payload) => {
@@ -204,7 +208,6 @@ export class DiscussCoreCommon {
partners_to,
});
const channel = this.createChannelThread(data);
this.threadService.sortChannels();
this.threadService.open(channel);
return channel;
}
@@ -314,7 +317,7 @@ export const discussCoreCommon = {
*/
start(env, services) {
const discussCoreCommon = reactive(new DiscussCoreCommon(env, services));
discussCoreCommon.setup();
discussCoreCommon.setup(env, services);
return discussCoreCommon;
},
};
@@ -158,7 +158,6 @@ export class ChannelSelector extends Component {
])
.then((data) => {
const channel = this.discussCoreCommonService.createChannelThread(data);
this.threadService.sortChannels();
this.threadService.open(channel);
});
} else {
@@ -0,0 +1,23 @@
/* @odoo-module */
import { patch } from "@web/core/utils/patch";
import { DiscussCoreCommon, discussCoreCommon } from "../common/discuss_core_common_service";
discussCoreCommon.dependencies.push("ui");
patch(DiscussCoreCommon.prototype, {
setup(env, services) {
this.ui = services.ui;
super.setup(...arguments);
},
insertInitChannel(channelData) {
const thread = super.insertInitChannel(...arguments);
if (channelData.is_minimized && channelData.state !== "closed" && !this.ui.isSmall) {
this.store.ChatWindow.insert({
autofocus: 0,
folded: channelData.state === "folded",
thread,
});
}
},
});
@@ -24,20 +24,6 @@ export class DiscussCoreWeb {
setup() {
this.messagingService.isReady.then((data) => {
for (const channelData of data.channels) {
const thread = this.discussCoreCommonService.createChannelThread(channelData);
if (
channelData.is_minimized &&
channelData.state !== "closed" &&
!this.ui.isSmall
) {
this.store.ChatWindow.insert({
autofocus: 0,
folded: channelData.state === "folded",
thread,
});
}
}
this.store.discuss.channels.isOpen =
data.current_user_settings.is_discuss_sidebar_category_channel_open;
this.store.discuss.chats.isOpen =
+83 -4
View File
@@ -1,7 +1,6 @@
/* @odoo-module */
import { Record, modelRegistry } from "@mail/core/common/record";
import { BaseStore, makeStore } from "@mail/core/common/store_service";
import { BaseStore, Record, makeStore, modelRegistry } from "@mail/core/common/record";
import { registry } from "@web/core/registry";
import { clearRegistryWithCleanup, makeTestEnv } from "@web/../tests/helpers/mock_env";
@@ -113,10 +112,23 @@ QUnit.test("Assign & Delete on fields with inverses", async (assert) => {
assert.ok(thread.messages.length === 0);
});
QUnit.test("Computed relational field", async (assert) => {
QUnit.test("Computed fields", async (assert) => {
(class Thread extends Record {
static id = "name";
name;
type = Record.attr("", {
compute() {
if (this.members.length === 0) {
return "empty chat";
} else if (this.members.length === 1) {
return "self-chat";
} else if (this.members.length === 2) {
return "dm chat";
} else {
return "group chat";
}
},
});
admin = Record.one("Persona", {
compute() {
return this.members[0];
@@ -130,11 +142,78 @@ QUnit.test("Computed relational field", async (assert) => {
}).register();
const store = await start();
const thread = store.Thread.insert("General");
const [john, marc] = store.Persona.insert(["John", "Marc"]);
const [john, marc, antony] = store.Persona.insert(["John", "Marc", "Antony"]);
Object.assign(thread, { members: [john, marc] });
assert.ok(thread.admin.eq(john));
assert.strictEqual(thread.type, "dm chat");
thread.members.delete(john);
assert.ok(thread.admin.eq(marc));
assert.strictEqual(thread.type, "self-chat");
thread.members.unshift(antony, john);
assert.ok(thread.admin.eq(antony));
assert.strictEqual(thread.type, "group chat");
});
QUnit.test("Computed fields: lazy (default) vs. eager", async (assert) => {
let logs = [];
(class Thread extends Record {
static id = "name";
name;
computeType() {
if (this.members.length === 0) {
return "empty chat";
} else if (this.members.length === 1) {
return "self-chat";
} else if (this.members.length === 2) {
return "dm chat";
} else {
return "group chat";
}
}
typeLazy = Record.attr("", {
compute() {
logs.push("LAZY");
return this.computeType();
},
});
typeEager = Record.attr("", {
compute() {
logs.push("EAGER");
return this.computeType();
},
eager: true,
});
members = Record.many("Persona");
}).register();
(class Persona extends Record {
static id = "name";
name;
}).register();
const store = await start();
const thread = store.Thread.insert("General");
assert.deepEqual(logs, ["EAGER"]);
logs = [];
assert.strictEqual(thread.typeEager, "empty chat");
assert.deepEqual(logs, []);
assert.strictEqual(thread.typeLazy, "empty chat");
assert.deepEqual(logs, ["LAZY"]);
logs = [];
thread.members.add("John");
assert.deepEqual(logs, ["EAGER"]);
logs = [];
assert.strictEqual(thread.typeEager, "self-chat");
assert.deepEqual(logs, []);
thread.members.add("Antony");
assert.deepEqual(logs, ["EAGER"]);
logs = [];
assert.strictEqual(thread.typeEager, "dm chat");
assert.deepEqual(logs, []);
thread.members.add("Demo");
assert.deepEqual(logs, ["EAGER"]);
assert.strictEqual(thread.typeEager, "group chat");
logs = [];
assert.strictEqual(thread.typeLazy, "group chat");
assert.deepEqual(logs, ["LAZY"]);
});
QUnit.test("Trusted insert on html field with { html: true }", async (assert) => {
@@ -0,0 +1,17 @@
/* @odoo-module */
import { patch } from "@web/core/utils/patch";
import { DiscussAppCategory } from "@mail/core/common/discuss_app_category_model";
patch(DiscussAppCategory.prototype, {
/**
* @param {import("models").Thread} t1
* @param {import("models").Thread} t2
*/
sortThreads(t1, t2) {
if (this.id === "livechat") {
return t2.lastInterestDateTime?.ts - t1.lastInterestDateTime?.ts;
}
return super.sortThreads(t1, t2);
},
});