From 0c998ef1cd0849f07c0f4426164234ef4bc6b200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Fri, 24 Nov 2023 11:06:49 +0100 Subject: [PATCH] [PERF] mail: faster load of discuss at init messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/core/web/messaging_menu_patch.js | 13 - .../src/core/web/store_service_patch.js | 21 + .../static/src/core/web/thread_model_patch.js | 1 - .../src/core/web/thread_service_patch.js | 1 + .../discuss/discuss_channel_member.py | 2 +- .../static/src/core/common/@types/models.d.ts | 3 + .../src/core/common/attachment_model.js | 8 +- .../core/common/discuss_app_category_model.js | 19 +- .../static/src/core/common/failure_model.js | 4 +- .../static/src/core/common/message_model.js | 15 +- .../src/core/common/messaging_service.js | 19 +- .../src/core/common/notification_model.js | 3 +- .../static/src/core/common/persona_model.js | 12 - addons/mail/static/src/core/common/record.js | 1161 +++++++++++++---- .../static/src/core/common/store_service.js | 379 ++---- .../static/src/core/common/thread_model.js | 19 +- .../static/src/core/common/thread_service.js | 3 +- .../src/core/web/mail_core_web_service.js | 10 +- .../static/src/core/web/messaging_menu.js | 82 +- .../call/common/discuss_app_model_patch.js | 3 +- .../common/discuss_core_common_service.js | 21 +- .../src/discuss/core/web/channel_selector.js | 1 - .../web/discuss_core_common_service_patch.js | 23 + .../core/web/discuss_core_web_service.js | 14 - addons/mail/static/tests/core/record_tests.js | 87 +- .../web/discuss_app_category_model_patch.js | 17 + 26 files changed, 1269 insertions(+), 672 deletions(-) create mode 100644 addons/im_livechat/static/src/core/web/store_service_patch.js create mode 100644 addons/mail/static/src/discuss/core/web/discuss_core_common_service_patch.js create mode 100644 addons/website_livechat/static/src/web/discuss_app_category_model_patch.js diff --git a/addons/im_livechat/static/src/core/web/messaging_menu_patch.js b/addons/im_livechat/static/src/core/web/messaging_menu_patch.js index c325ff3e211..e0f0520a586 100644 --- a/addons/im_livechat/static/src/core/web/messaging_menu_patch.js +++ b/addons/im_livechat/static/src/core/web/messaging_menu_patch.js @@ -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 */ diff --git a/addons/im_livechat/static/src/core/web/store_service_patch.js b/addons/im_livechat/static/src/core/web/store_service_patch.js new file mode 100644 index 00000000000..20fdb5898e7 --- /dev/null +++ b/addons/im_livechat/static/src/core/web/store_service_patch.js @@ -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; + }, +}); diff --git a/addons/im_livechat/static/src/core/web/thread_model_patch.js b/addons/im_livechat/static/src/core/web/thread_model_patch.js index be2455c29e4..96baeca8e86 100644 --- a/addons/im_livechat/static/src/core/web/thread_model_patch.js +++ b/addons/im_livechat/static/src/core/web/thread_model_patch.js @@ -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; }, diff --git a/addons/im_livechat/static/src/core/web/thread_service_patch.js b/addons/im_livechat/static/src/core/web/thread_service_patch.js index 52e3147943d..aa59cff9944 100644 --- a/addons/im_livechat/static/src/core/web/thread_service_patch.js +++ b/addons/im_livechat/static/src/core/web/thread_service_patch.js @@ -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. diff --git a/addons/mail/populate/discuss/discuss_channel_member.py b/addons/mail/populate/discuss/discuss_channel_member.py index 216ba78e85b..1e7e940afd5 100644 --- a/addons/mail/populate/discuss/discuss_channel_member.py +++ b/addons/mail/populate/discuss/discuss_channel_member.py @@ -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]: diff --git a/addons/mail/static/src/core/common/@types/models.d.ts b/addons/mail/static/src/core/common/@types/models.d.ts index 047533c0bbb..5837f17aa86 100644 --- a/addons/mail/static/src/core/common/@types/models.d.ts +++ b/addons/mail/static/src/core/common/@types/models.d.ts @@ -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, } } diff --git a/addons/mail/static/src/core/common/attachment_model.js b/addons/mail/static/src/core/common/attachment_model.js index b30a166979f..5c34d9c8e2e 100644 --- a/addons/mail/static/src/core/common/attachment_model.js +++ b/addons/mail/static/src/core/common/attachment_model.js @@ -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"); diff --git a/addons/mail/static/src/core/common/discuss_app_category_model.js b/addons/mail/static/src/core/common/discuss_app_category_model.js index fe3eec7e578..4f2730e4004 100644 --- a/addons/mail/static/src/core/common/discuss_app_category_model.js +++ b/addons/mail/static/src/core/common/discuss_app_category_model.js @@ -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(); diff --git a/addons/mail/static/src/core/common/failure_model.js b/addons/mail/static/src/core/common/failure_model.js index 464cfea941f..2c92cbfc5c8 100644 --- a/addons/mail/static/src/core/common/failure_model.js +++ b/addons/mail/static/src/core/common/failure_model.js @@ -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(); } diff --git a/addons/mail/static/src/core/common/message_model.js b/addons/mail/static/src/core/common/message_model.js index baa1b586946..ac4f8273a78 100644 --- a/addons/mail/static/src/core/common/message_model.js +++ b/addons/mail/static/src/core/common/message_model.js @@ -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) { diff --git a/addons/mail/static/src/core/common/messaging_service.js b/addons/mail/static/src/core/common/messaging_service.js index a219fc520a1..ea2c519a8b7 100644 --- a/addons/mail/static/src/core/common/messaging_service.js +++ b/addons/mail/static/src/core/common/messaging_service.js @@ -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 // ------------------------------------------------------------------------- diff --git a/addons/mail/static/src/core/common/notification_model.js b/addons/mail/static/src/core/common/notification_model.js index 16592b8f3ca..125e85eabd0 100644 --- a/addons/mail/static/src/core/common/notification_model.js +++ b/addons/mail/static/src/core/common/notification_model.js @@ -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; diff --git a/addons/mail/static/src/core/common/persona_model.js b/addons/mail/static/src/core/common/persona_model.js index 960c3dc3555..a94de05380b 100644 --- a/addons/mail/static/src/core/common/persona_model.js +++ b/addons/mail/static/src/core/common/persona_model.js @@ -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; diff --git a/addons/mail/static/src/core/common/record.js b/addons/mail/static/src/core/common/record.js index 0d9a5f3cb50..d14675d00b4 100644 --- a/addons/mail/static/src/core/common/record.js +++ b/addons/mail/static/src/core/common/record.js @@ -1,18 +1,24 @@ /* @odoo-module */ -import { toRaw } from "@odoo/owl"; +import { markRaw, markup, reactive, toRaw } from "@odoo/owl"; import { registry } from "@web/core/registry"; export const modelRegistry = registry.category("discuss.model"); +/** + * Class of markup, useful to detect content that is markup and to + * automatically markup field during trusted insert + */ +const Markup = markup("").constructor; + +/** @typedef {ATTR_SYM|MANY_SYM|ONE_SYM} FIELD_SYM */ const ATTR_SYM = Symbol("attr"); const MANY_SYM = Symbol("many"); const ONE_SYM = Symbol("one"); const OR_SYM = Symbol("or"); const AND_SYM = Symbol("and"); const IS_RECORD_SYM = Symbol("isRecord"); - -/** @typedef {ATTR_SYM|MANY_SYM|ONE_SYM} FIELD_SYM */ +const IS_FIELD_SYM = Symbol("isField"); export function AND(...args) { return [AND_SYM, ...args]; @@ -21,7 +27,371 @@ export function OR(...args) { return [OR_SYM, ...args]; } -export class RecordUses { +export function makeStore(env) { + const res = { + // fake store for now, until it becomes a model + /** @type {import("models").Store} */ + store: { + env, + get: (...args) => BaseStore.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. + /** @type {typeof Record} */ + 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 { + [IS_RECORD_SYM] = true; + constructor() { + super(); + const proxy = new Proxy(this, { + /** @param {Record} receiver */ + get(target, name, receiver) { + if (name !== "_fields" && name in target._fields) { + const field = receiver._fields[name]; + const rfield = target._fields[name]; + if ( + (rfield.compute || rfield.sort) && + !rfield.eager && + !rfield.sorting && + !rfield.computing + ) { + if (Record.UPDATE !== 0) { + if (rfield.compute) { + rfield.computeInNeed = true; + } + if (rfield.sort) { + rfield.sortInNeed = true; + } + } else if (rfield.computeOnNeed || rfield.sortOnNeed) { + if (rfield.computeOnNeed) { + rfield.computeOnNeed = false; + rfield.computeInNeed = false; + rfield.compute(); + } + if (rfield.sortOnNeed) { + rfield.sortOnNeed = false; + rfield.sortInNeed = false; + rfield.sort(); + } + } + } + if (Record.isRelation(field)) { + const l1 = field.value; + if (RecordList.isMany(l1)) { + return l1; + } + return l1[0]; + } + } + return Reflect.get(target, name, receiver); + }, + deleteProperty(target, name) { + return Record.MAKE_UPDATE(() => { + if ( + name !== "_fields" && + name in target._fields && + Record.isRelation(target._fields[name]) + ) { + const r1 = target; + const l1 = r1._fields[name].value; + l1.clear(); + return true; + } + const ret = Reflect.deleteProperty(target, name); + return ret; + }); + }, + /** @param {Record} receiver */ + set(target, name, val, receiver) { + return Record.MAKE_UPDATE(() => { + if (name === "Model" || !(name in target.Model._fields)) { + Reflect.set(target, name, val, receiver); + return true; + } + if (Record.isAttr(target.Model._fields[name])) { + if ( + target.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} */ + const l1 = receiver._fields[name].value; + 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]; + } + l1.assign(val); + } 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) { + const { compute, default: defaultVal, eager, sort } = Model._fields[name]; + const SYM = this[name]?.[0]; + this._fields[name] = { [SYM]: true, eager }; + const field = this._fields[name]; + if (Record.isRelation(SYM)) { + // 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(SYM); + if (this instanceof BaseStore) { + newVal.store = proxy; + } else { + newVal.store = res.store; + } + newVal.name = name; + newVal.owner = proxy; + field.value = newVal; + this.__uses__ = new RecordUses(); + this[name] = newVal; + } else { + this[name] = defaultVal; + } + const rfield = toRaw(field); + if (compute) { + const proxy2 = reactive(proxy, () => rfield.requestCompute()); + Object.assign(rfield, { + compute: () => { + // store is wrapped in another reactive, hence proxy is not enough + const exactProxy = res.store.get(proxy.localId); + if (!exactProxy) { + return; // record was probably deleted; + } + rfield.computing = true; + exactProxy[name] = compute.call(proxy2); + rfield.computing = false; + }, + _compute: () => { + // dummy call to keep reactive cb + compute.call(proxy2); + }, + requestCompute: ({ force = false } = {}) => { + if (rfield.computing || rfield.sorting) { + Record.ADD_QUEUE(field, "_compute"); + return; + } + if (Record.UPDATE !== 0 && !force) { + Record.ADD_QUEUE(field, "compute"); + } else { + if (rfield.eager) { + rfield.compute(); + } else if (rfield.computeInNeed) { + rfield.computeInNeed = false; + rfield.computeOnNeed = false; + rfield.compute(); + } else { + rfield.computeOnNeed = true; + } + } + }, + }); + } + /** @type {Function} */ + let observe; + if (sort) { + const proxy2 = reactive(proxy, () => rfield.requestSort()); + Object.assign(rfield, { + sort: () => { + // store is wrapped in another reactive, hence proxy is not enough + const exactProxy = res.store.get(proxy.localId); + if (!exactProxy) { + return; // record was probably deleted; + } + rfield.sorting = true; + proxy2[name].sort(Model._fields[name].sort.bind(exactProxy)); + rfield.sorting = false; + }, + _sort: () => { + // dummy call to keep reactive cb + proxy2[name]._sort(Model._fields[name].sort.bind(proxy2)); + }, + requestSort: ({ force } = {}) => { + if (rfield.computing || rfield.sorting) { + Record.ADD_QUEUE(field, "_sort"); + return; + } + if (Record.UPDATE !== 0 && !force) { + Record.ADD_QUEUE(field, "sort"); + } else { + if (rfield.eager) { + rfield.sort(); + } else if (rfield.sortInNeed) { + rfield.sortInNeed = false; + rfield.sortOnNeed = false; + rfield.sort(); + } else { + rfield.sortOnNeed = true; + } + } + }, + }); + } + if (Model._fields[name].onUpdate) { + const fn = (record) => toRaw(Model)._fields[name].onUpdate.call(record); + Object.assign(rfield, { + onChange: () => { + // store is wrapped in another reactive, hence proxy is not enough + const exactProxy = res.store.get(proxy.localId); + if (!exactProxy) { + return; // record was probably deleted; + } + fn(exactProxy); + observe?.(); + }, + }); + Record._onChange(proxy, name, (obs) => { + observe = obs; + if (rfield.sorting) { + observe(); + return; + } + if (Record.UPDATE !== 0) { + Record.ADD_QUEUE(field, "onChange"); + } else { + field.onChange(); + } + }); + } + } + return proxy; + } + }, + }[OgClass.name]; + Object.assign(Model, { + Class, + records: JSON.parse(JSON.stringify(OgClass.records)), + _fields: {}, + }); + 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; + } + toRaw(Model)._fields[name] = { [IS_FIELD_SYM]: true, [SYM]: true, ...val[1] }; + } + } + // Sync inverse fields + for (const Model of Object.values(Models)) { + for (const [name, definition] of Object.entries(toRaw(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 }); + // // FIXME: lazy fields are not working properly with inverse. + definition.eager = true; + rel2.eager = true; + } + } + } + // 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; +} + +class RecordUses { /** * Track the uses of a record. Each record contains a single `RecordUses`: * - Key: localId of record that uses current record @@ -30,7 +400,7 @@ export class RecordUses { * * @type {Map>}} */ - data = new Map(); + data = markRaw(new Map()); /** @param {RecordList} list */ add(list) { if (!this.data.has(list.owner.localId)) { @@ -58,15 +428,13 @@ export class RecordUses { } } -/** - * @template {Record} R - */ -export class RecordList extends Array { +/** * @template {Record} R */ +class RecordList extends Array { static isOne(list) { - return Boolean(list[ONE_SYM]); + return Boolean(list?.[ONE_SYM]); } static isMany(list) { - return Boolean(list[MANY_SYM]); + return Boolean(list?.[MANY_SYM]); } /** @type {Record} */ owner; @@ -77,6 +445,10 @@ export class RecordList extends Array { /** @type {string[]} */ data = []; + get fieldDefinition() { + return toRaw(toRaw(this).owner).Model._fields[toRaw(this).name]; + } + /** @param {ONE_SYM|MANY_SYM} SYM */ constructor(SYM) { super(); @@ -106,45 +478,47 @@ export class RecordList extends Array { }, /** @param {RecordList} receiver */ set(target, name, val, receiver) { - if (typeof name !== "symbol" && !window.isNaN(parseInt(name))) { - // support for "array[index] = r3" syntax - const index = parseInt(name); - receiver._insert(val, (r3) => { - const r2 = receiver[index]; - if (r2 && r2.notEq(r3)) { - r2.__uses__.delete(receiver); - } - const { inverse, onDelete } = receiver.owner.Model._fields[receiver.name]; - onDelete?.call(receiver.owner, r2); - if (inverse) { - r2._fields[inverse].delete(receiver); - } - receiver.data[index] = r3?.localId; - if (r3) { - r3.__uses__.add(receiver); - const { inverse, onAdd } = receiver.owner.Model._fields[receiver.name]; - onAdd?.call(receiver.owner, r3); - if (inverse) { - r3._fields[inverse].add(receiver); + return Record.MAKE_UPDATE(() => { + if (typeof name !== "symbol" && !window.isNaN(parseInt(name))) { + // support for "array[index] = r3" syntax + const index = parseInt(name); + receiver._insert(val, (r3) => { + const r2 = receiver[index]; + if (r2 && r2.notEq(r3)) { + r2.__uses__.delete(receiver); } + const { inverse, onDelete } = target.fieldDefinition; + onDelete?.call(receiver.owner, r2); + if (inverse) { + r2._fields[inverse].value.delete(receiver); + } + receiver.data[index] = r3?.localId; + if (r3) { + r3.__uses__.add(receiver); + const { inverse, onAdd } = target.fieldDefinition; + onAdd?.call(receiver.owner, r3); + if (inverse) { + r3._fields[inverse].value.add(receiver); + } + } + }); + } else if (name === "length") { + const newLength = parseInt(val); + if (newLength < receiver.length) { + receiver.splice(newLength, receiver.length - newLength); } - }); - } else if (name === "length") { - const newLength = parseInt(val); - if (newLength < receiver.length) { - receiver.splice(newLength, receiver.length - newLength); + receiver.data.length = newLength; + } else { + Reflect.set(target, name, val, receiver); } - receiver.data.length = newLength; - } else { - Reflect.set(target, name, val, receiver); - } - return true; + return true; + }); }, }); } /** * @param {R|any} val - * @param {(R) => void} fn function that is called in-between preinsert and + * @param {(R) => void} [fn] function that is called in-between preinsert and * insert. Preinsert only inserted what's needed to make record, while * insert finalize with all remaining data. * @param {boolean} [inv=true] whether the inverse should be added or not. @@ -156,7 +530,7 @@ export class RecordList extends Array { * comes from deletion, we want to "DELETE". */ _insert(val, fn, { inv = true, mode = "ADD" } = {}) { - const { inverse } = this.owner.Model._fields[this.name]; + const { inverse } = this.fieldDefinition; if (inverse && inv) { // special command to call _addNoinv/_deleteNoInv, to prevent infinite loop val[inverse] = [[mode === "ADD" ? "ADD.noinv" : "DELETE.noinv", this.owner]]; @@ -164,69 +538,99 @@ export class RecordList extends Array { /** @type {R} */ let r3; if (!Record.isRecord(val)) { - const { targetModel } = this.owner.Model._fields[this.name]; - r3 = this.owner.Model.store[targetModel].preinsert(val); + const { targetModel } = this.fieldDefinition; + r3 = this.store[targetModel].preinsert(val); } else { r3 = val; } - fn(r3); + fn?.(r3); if (!Record.isRecord(val)) { // was preinserted, fully insert now - const { targetModel } = this.owner.Model._fields[this.name]; - this.owner.Model.store[targetModel].insert(val); + const { targetModel } = this.fieldDefinition; + this.store[targetModel].insert(val); } return r3; } + /** @param {R[]|any[]} data */ + assign(data) { + return Record.MAKE_UPDATE(() => { + /** @type {Record[]|Set|RecordList} */ + const collection = Record.isRecord(data) ? [data] : data; + // l1 and collection could be same record list, + // save before clear to not push mutated recordlist that is empty + const vals = [...collection]; + /** @type {R[]} */ + const oldRecords = this.slice(); + for (const r2 of oldRecords) { + r2.__uses__.delete(this); + } + const records = vals.map((val) => + this._insert(val, (r3) => { + r3.__uses__.add(this); + }) + ); + this.data = records.map((r) => r.localId); + }); + } /** @param {R[]} records */ push(...records) { - for (const val of records) { - const r = this._insert(val, (r3) => { - this.data.push(r3.localId); - r3.__uses__.add(this); - }); - const { inverse, onAdd } = this.owner.Model._fields[this.name]; - onAdd?.call(this.owner, r); - if (inverse) { - r._fields[inverse].add(this.owner); + return Record.MAKE_UPDATE(() => { + for (const val of records) { + const r = this._insert(val, (r3) => { + this.data.push(r3.localId); + r3.__uses__.add(this); + }); + const { inverse, onAdd } = this.fieldDefinition; + onAdd?.call(this.owner, r); + if (inverse) { + r._fields[inverse].value.add(this.owner); + } } - } - return this.data.length; + return this.data.length; + }); } /** @returns {R} */ pop() { - const r2 = this.at(-1); - if (r2) { - this.splice(this.length - 1, 1); - } - return r2; + return Record.MAKE_UPDATE(() => { + /** @type {R} */ + const r2 = this.at(-1); + if (r2) { + this.splice(this.length - 1, 1); + } + return r2; + }); } /** @returns {R} */ shift() { - const r2 = this.store.get(this.data.shift()); - r2?.__uses__.delete(this); - const { inverse, onDelete } = this.owner.Model._fields[this.name]; - if (r2) { - onDelete?.call(this.owner, r2); - if (inverse) { - r2._fields[inverse].delete(this.owner); + return Record.MAKE_UPDATE(() => { + const r2 = this.store.get(this.data.shift()); + r2?.__uses__.delete(this); + const { inverse, onDelete } = this.fieldDefinition; + if (r2) { + onDelete?.call(this.owner, r2); + if (inverse) { + r2._fields[inverse].value.delete(this.owner); + } } - } - return r2; + return r2; + }); } /** @param {R[]} records */ unshift(...records) { - for (let i = records.length - 1; i >= 0; i--) { - const r = this._insert(records[i], (r3) => { - this.data.unshift(r3.localId); - r3.__uses__.add(this); - }); - const { inverse, onAdd } = this.owner.Model._fields[this.name]; - onAdd?.call(this.owner, r); - if (inverse) { - r._fields[inverse].add(this.owner); + return Record.MAKE_UPDATE(() => { + for (let i = records.length - 1; i >= 0; i--) { + const r = this._insert(records[i], (r3) => { + this.data.unshift(r3.localId); + r3.__uses__.add(this); + }); + const { inverse, onAdd } = this.fieldDefinition; + onAdd?.call(this.owner, r); + if (inverse) { + r._fields[inverse].value.add(this.owner); + } } - } - return this.data.length; + return this.data.length; + }); } /** @param {R} record */ indexOf(record) { @@ -238,32 +642,47 @@ export class RecordList extends Array { * @param {...R} [newRecords] */ splice(start, deleteCount, ...newRecords) { - const oldRecords = this.slice(start, start + deleteCount); - const list = this.data.slice(); // splice on copy of list so that reactive observers not triggered while splicing - list.splice(start, deleteCount, ...newRecords.map((r) => r.localId)); - this.data = list; - for (const r of oldRecords) { - r.__uses__.delete(this); - const { inverse, onDelete } = this.owner.Model._fields[this.name]; - onDelete?.call(this.owner, r); - if (inverse) { - r._fields[inverse].delete(this.owner); + return Record.MAKE_UPDATE(() => { + const oldRecords = this.slice(start, start + deleteCount); + const list = this.data.slice(); // splice on copy of list so that reactive observers not triggered while splicing + list.splice(start, deleteCount, ...newRecords.map((r) => r.localId)); + this.data = list; + for (const r of oldRecords) { + r.__uses__.delete(this); + const { inverse, onDelete } = this.fieldDefinition; + onDelete?.call(this.owner, r); + if (inverse) { + r._fields[inverse].value.delete(this.owner); + } } - } - for (const r of newRecords) { - r.__uses__.add(this); - const { inverse, onAdd } = this.owner.Model._fields[this.name]; - onAdd?.call(this.owner, r); - if (inverse) { - r._fields[inverse].add(this.owner); + for (const r of newRecords) { + r.__uses__.add(this); + const { inverse, onAdd } = this.fieldDefinition; + onAdd?.call(this.owner, r); + if (inverse) { + r._fields[inverse].value.add(this.owner); + } } - } + }); } /** @param {(a: R, b: R) => boolean} func */ sort(func) { - const list = this.data.slice(); // sort on copy of list so that reactive observers not triggered while sorting - list.sort((a, b) => func(this.store.get(a), this.store.get(b))); - this.data = list; + return Record.MAKE_UPDATE(() => { + const list = this.data.slice(); // sort on copy of list so that reactive observers not triggered while sorting + list.sort((a, b) => func(this.store.get(a), this.store.get(b))); + this.data = list; + }); + } + /** + * Dummy sort just to re-tag everything for reactive callback. This has no effect on data in record list. + * + * @param {(a: R, b: R) => boolean} func + */ + _sort(func) { + return Record.MAKE_UPDATE(() => { + const list = this.data.slice(); // sort on copy of list so that reactive observers not triggered while sorting + list.sort((a, b) => func(this.store.get(a), this.store.get(b))); + }); } /** @param {...R[]|...RecordList[R]} collections */ concat(...collections) { @@ -273,29 +692,31 @@ export class RecordList extends Array { } /** @param {...R} */ add(...records) { - if (RecordList.isOne(this)) { - const last = records.at(-1); - if (Record.isRecord(last) && last.in(this)) { + return Record.MAKE_UPDATE(() => { + if (RecordList.isOne(this)) { + const last = records.at(-1); + if (Record.isRecord(last) && last.in(toRaw(this))) { + return; + } + this._insert(last, (r) => { + if (r.notEq(this[0])) { + this.pop(); + this.push(r); + } + }); return; } - this._insert(last, (r) => { - if (r.notEq(this[0])) { - this.pop(); - this.push(r); + for (const val of records) { + if (Record.isRecord(val) && val.in(toRaw(this))) { + continue; } - }); - return; - } - for (const val of records) { - if (Record.isRecord(val) && val.in(this)) { - continue; + this._insert(val, (r) => { + if (this.indexOf(r) === -1) { + this.push(r); + } + }); } - this._insert(val, (r) => { - if (this.indexOf(r) === -1) { - this.push(r); - } - }); - } + }); } /** * Version of add() that does not update the inverse. @@ -307,7 +728,7 @@ export class RecordList extends Array { _addNoinv(...records) { if (RecordList.isOne(this)) { const last = records.at(-1); - if (Record.isRecord(last) && last.in(this)) { + if (Record.isRecord(last) && last.in(toRaw(this))) { return; } this._insert( @@ -326,7 +747,7 @@ export class RecordList extends Array { return; } for (const val of records) { - if (Record.isRecord(val) && val.in(this)) { + if (Record.isRecord(val) && val.in(toRaw(this))) { continue; } this._insert( @@ -343,18 +764,20 @@ export class RecordList extends Array { } /** @param {...R} */ delete(...records) { - for (const val of records) { - this._insert( - val, - (r) => { - const index = this.indexOf(r); - if (index !== -1) { - this.splice(index, 1); - } - }, - { mode: "DELETE" } - ); - } + return Record.MAKE_UPDATE(() => { + for (const val of records) { + this._insert( + val, + (r) => { + const index = this.indexOf(r); + if (index !== -1) { + this.splice(index, 1); + } + }, + { mode: "DELETE" } + ); + } + }); } /** * Version of delete() that does not update the inverse. @@ -379,9 +802,11 @@ export class RecordList extends Array { } } clear() { - while (this.data.length > 0) { - this.pop(); - } + return Record.MAKE_UPDATE(() => { + while (this.data.length > 0) { + this.pop(); + } + }); } /** @yields {R} */ *[Symbol.iterator]() { @@ -400,20 +825,70 @@ export class RecordList extends Array { * @property {boolean} [html] whether the attribute is an html field. Useful to automatically markup * when the insert is trusted. * @property {string} [targetModel] model name of records contained in this relational field. - * @property {Function} [compute] if set the field is computed based on provided function. + * @property {() => any} [compute] if set the field is computed based on provided function. * The `this` of function is the record, and the function is recalled whenever any field - * in models used by this compute function is changed. + * in models used by this compute function is changed. The return value is the new value of + * the field. On relational field, passing a (list of) record(s) or data work as expected. + * @property {boolean} [eager=false] when field is computed, determines whether the computation + * of this field is eager or lazy. By default, fields are computed lazily, which means that + * they are computed when dependencies change AND when this field is being used. In eager mode, + * the field is immediately (re-)computed when dependencies changes, which matches the built-in + * behaviour of OWL reactive. * @property {string} [inverse] name of inverse relational field in targetModel. - * @property {Function} [onAdd] hook that is called when relation is updated + * @property {(r: Record) => void} [onAdd] hook that is called when relation is updated * with a record being added. Callback param is record being added into relation. - * @property {Function} [onDelete] hook that is called when relation is updated + * @property {(r: Record) => void} [onDelete] hook that is called when relation is updated * with a record being deleted. Callback param is record being deleted from relation. + * @property {() => void} [onUpdate] hook that is called when field is updated. + * @property {(r1: Record, r2: Record) => number} [sort] if defined, this many relational field is + * automatically sorted by this function. + */ +/** + * @typedef {Object} RecordField + * @property {boolean} [ATTR_SYM] true when this is an attribute, i.e. a non-relational field. + * @property {boolean} [MANY_SYM] true when this is a many relation. + * @property {boolean} [ONE_SYM] true when this is a one relation. + * @property {any} [default] the default value of this attribute. + * @property {() => void} [compute] for computed field, invoking this function (re-)computes the field. + * @property {boolean} [computing] for computed field, determines whether the field is computing its value. + * @property {() => void} [requestCompute] on computed field, calling this function makes a request to compute + * the field. This doesn't necessarily mean the field is immediately re-computed: during an update cycle, this + * is put in the compute FC_QUEUE and will be invoked at end. + * @property {boolean} [computeOnNeed] on lazy-computed field, determines whether the field should be (re-)computed + * when it's needed (i.e. accessed). Eager computed fields are immediately re-computed at end of update cycle, + * whereas lazy computed fields wait extra for them being needed. + * @property {boolean} [computeInNeed] on lazy computed-fields, determines whether this field is needed (i.e. accessed). + * This is only set when there's a get on this lazy computed-field during an update cycle (UPDATE !== 0), as computed + * fields are invoked only at the end of an update cycle. When outside of an update cycle, only `computeOnNeed` + * determines (re-)computation. + * @property {() => void} [_compute] on computed field, function to trigger observing of sort without side-effect to actually + * compute the field. Since OWL reactive are consumable and their callback can be triggered during a sorting in update cycle, + * there's likely a need to re-observe the reactive. This function is handy for this specific case for the compute. + * @property {() => void} [sort] for sorted field, invoking this function (re-)sorts the field. + * @property {boolean} [sorting] for sorted field, determines whether the field is sorting its value. + * @property {() => void} [requestSort] on sorted field, calling this function makes a request to sort + * the field. This doesn't necessarily mean the field is immediately re-sorted: during an update cycle, this + * is put in the sort FS_QUEUE and will be invoked at end. + * @property {boolean} [sortOnNeed] on lazy-sorted field, determines whether the field should be (re-)sorted + * when it's needed (i.e. accessed). Eager sorted fields are immediately re-sorted at end of update cycle, + * whereas lazy sorted fields wait extra for them being needed. + * @property {boolean} [sortInNeed] on lazy sorted-fields, determines whether this field is needed (i.e. accessed). + * This is only set when there's a get on this lazy sorted-field during an update cycle (UPDATE !== 0), as sorted + * fields are invoked only at the end of an update cycle. When outside of an update cycle, only `sortOnNeed` + * determines (re-)sort. + * @property {() => void} [_sort] on sorted field, function to trigger observing of sort without side-effect to actually + * sort the field. Since OWL reactive are consumable and their callback can be triggered during a sorting in update cycle, + * there's likely a need to re-observe the reactive. This function is handy for this specific case for the sort. + * @property {() => void} [onChange] function that contains functions to be called when the value of field + * has changed, e.g. sort and onUpdate. + * @property {RecordList} [value] value of the field. Either its raw value if it's an attribute, + * or a RecordList if it's a relational field. */ export class Record { /** @param {FieldDefinition} */ static isAttr(definition) { - return Boolean(definition[ATTR_SYM]); + return Boolean(definition?.[ATTR_SYM]); } /** * Determines whether the inserts are considered trusted or not. @@ -423,8 +898,170 @@ export class Record { static id; /** @type {Object} */ static records = {}; - /** @type {import("@mail/core/common/store_service").Store} */ + /** @type {import("models").Store} */ static store; + /** @type {RecordField[]} */ + static FC_QUEUE = []; // field-computes + /** @type {RecordField[]} */ + static FC2_QUEUE = []; // field-computes (dummy _compute, i.e. observing of compute, see Record._compute) + /** @type {RecordField[]} */ + static FS_QUEUE = []; // field-sorts + /** @type {RecordField[]} */ + static FS2_QUEUE = []; // field-sorts (dummy _sort, i.e. observing of sort, see RecordField._sort) + /** @type {RecordField[]} */ + static FO_QUEUE = []; // field-onchanges + /** @type {Function[]} */ + static RO_QUEUE = []; // record-onchanges + static UPDATE = 0; + /** @param {() => any} fn */ + static MAKE_UPDATE(fn) { + const selfRaw = toRaw(this); + selfRaw.UPDATE++; + const res = fn(); + selfRaw.UPDATE--; + if (selfRaw.UPDATE === 0) { + // pretend an increased update cycle so that nothing in queue creates many small update cycles + selfRaw.UPDATE++; + while ( + selfRaw.FC_QUEUE.length > 0 || + selfRaw.FC2_QUEUE.length > 0 || + selfRaw.FS_QUEUE.length > 0 || + selfRaw.FS2_QUEUE.length > 0 || + selfRaw.FO_QUEUE.length > 0 || + selfRaw.RO_QUEUE.length > 0 + ) { + const FC_QUEUE = [...selfRaw.FC_QUEUE]; + const FC2_QUEUE = [...selfRaw.FC2_QUEUE]; + const FS_QUEUE = [...selfRaw.FS_QUEUE]; + const FS2_QUEUE = [...selfRaw.FS2_QUEUE]; + const FO_QUEUE = [...selfRaw.FO_QUEUE]; + const RO_QUEUE = [...selfRaw.RO_QUEUE]; + selfRaw.FC_QUEUE = []; + selfRaw.FC2_QUEUE = []; + selfRaw.FS_QUEUE = []; + selfRaw.FS2_QUEUE = []; + selfRaw.FO_QUEUE = []; + selfRaw.RO_QUEUE = []; + while (FC_QUEUE.length > 0) { + const field = FC_QUEUE.pop(); + field.requestCompute({ force: true }); + } + while (FS_QUEUE.length > 0) { + const field = FS_QUEUE.pop(); + field.requestSort({ force: true }); + } + while (FC2_QUEUE.length > 0) { + const field = FC2_QUEUE.pop(); + field._compute(); + } + while (FS2_QUEUE.length > 0) { + const field = FS2_QUEUE.pop(); + field._sort(); + } + while (FO_QUEUE.length > 0) { + const field = FO_QUEUE.pop(); + field.onChange(); + } + while (RO_QUEUE.length > 0) { + const cb = RO_QUEUE.pop(); + cb(); + } + } + selfRaw.UPDATE--; + } + return res; + } + /** + * @param {RecordField} field + * @param {"compute"|"sort"|"onChange"|"_compute"|"_sort"} type + */ + static ADD_QUEUE(field, type) { + const selfRaw = toRaw(this); + const rawField = toRaw(field); + if (type === "compute") { + if (!selfRaw.FC_QUEUE.some((f) => toRaw(f) === rawField)) { + selfRaw.FC_QUEUE.push(field); + } + } + if (type === "sort") { + if (!selfRaw.FS_QUEUE.some((f) => toRaw(f) === rawField)) { + selfRaw.FS_QUEUE.push(field); + } + } + if (type === "onChange") { + if (!selfRaw.FO_QUEUE.some((f) => toRaw(f) === rawField)) { + selfRaw.FO_QUEUE.push(field); + } + } + if (type === "_compute") { + if (!selfRaw.FC2_QUEUE.some((f) => toRaw(f) === rawField)) { + selfRaw.FC2_QUEUE.push(field); + } + } + if (type === "_sort") { + if (!selfRaw.FS2_QUEUE.some((f) => toRaw(f) === rawField)) { + selfRaw.FS2_QUEUE.push(field); + } + } + } + static onChange(record, name, cb) { + const selfRaw = toRaw(this); + this._onChange(record, name, (observe) => { + const fn = () => { + observe(); + cb(); + }; + if (selfRaw.UPDATE !== 0) { + if (!selfRaw.RO_QUEUE.some((f) => toRaw(f) === fn)) { + selfRaw.RO_QUEUE.push(fn); + } + } else { + fn(); + } + }); + } + /** + * Version of onChange where the callback receives observe function as param. + * This is useful when there's desire to postpone calling the callback function, + * in which the observe is also intended to have its invocation postponed. + * + * @param {Record} record + * @param {string|string[]} key + * @param {(observe: Function) => any} callback + */ + static _onChange(record, key, callback) { + let proxy; + function _observe() { + // observe should not flag the field as in need + let oldComputeInNeed; + let oldSortInNeed; + if (record[IS_RECORD_SYM] && toRaw(record)._fields[key]) { + oldComputeInNeed = toRaw(record)._fields[key].computeInNeed; + oldSortInNeed = toRaw(record)._fields[key].sortInNeed; + } + void proxy[key]; + if (proxy[key] instanceof Object) { + void Object.keys(proxy[key]); + } + if (proxy[key] instanceof Array) { + void proxy[key].length; + void proxy[key].forEach((i) => i); + } + if (record[IS_RECORD_SYM] && record._fields[key]) { + toRaw(record)._fields[key].computeInNeed = oldComputeInNeed; + toRaw(record)._fields[key].sortInNeed = oldSortInNeed; + } + } + if (Array.isArray(key)) { + for (const k of key) { + this._onChange(record, k, callback); + } + return; + } + proxy = reactive(record, () => callback(_observe)); + _observe(); + return proxy; + } /** * Contains field definitions of the model: * - key : field name @@ -432,7 +1069,7 @@ export class Record { * * @type {Object.} */ - static _fields = {}; + static _fields = markRaw({}); static isRecord(record) { return Boolean(record?.[IS_RECORD_SYM]); } @@ -575,82 +1212,118 @@ export class Record { * @returns {Record} */ static new(data) { - const obj = new this.Class(); - obj.Model = this; - const ids = this._retrieveIdFromData(data); - let record = Object.assign(obj, { - [IS_RECORD_SYM]: true, - localId: this.localId(data), - ...ids, + return Record.MAKE_UPDATE(() => { + const obj = new this.Class(); + obj.Model = this; + const ids = this._retrieveIdFromData(data); + let record = Object.assign(obj, { + localId: this.localId(data), + ...ids, + }); + Object.assign(record, { _store: this.store }); + this.records[record.localId] = record; + // return reactive version + record = this.records[record.localId]; + for (const field of Object.values(record._fields)) { + field.requestCompute?.(); + field.requestSort?.(); + } + return record; }); - for (const compute of Object.values(record.__computes__)) { - compute(); - } - Object.assign(record, { _store: this.store }); - this.records[record.localId] = record; - // return reactive version - record = this.records[record.localId]; - return record; } /** * @template {keyof import("models").Models} M * @param {M} targetModel - * @param {Function} [compute] if set, the value of this relational field is declarative and + * @param {Object} [param1={}] + * @param {Function} [param1.compute] if set, the value of this relational field is declarative and * is computed automatically. All reactive accesses recalls that function. The context of * the function is the record. Returned value is new value assigned to this field. - * @param {string} [inverse] if set, the name of field in targetModel that acts as the inverse. - * @param {(r: M) => void} [onAdd] function that is called when a record is added + * * @property {boolean} [eager=false] when field is computed, determines whether the computation + * of this field is eager or lazy. By default, fields are computed lazily, which means that + * they are computed when dependencies change AND when this field is being used. In eager mode, + * the field is immediately (re-)computed when dependencies changes, which matches the built-in + * behaviour of OWL reactive. + * @param {string} [param1.inverse] if set, the name of field in targetModel that acts as the inverse. + * @param {(r: import("models").Models[M]) => void} [param1.onAdd] function that is called when a record is added * in the relation. - * @param {(r: M) => void} [onDelete] function that is called when a record is removed + * @param {(r: import("models").Models[M]) => void} [param1.onDelete] function that is called when a record is removed * from the relation. + * @param {() => void} [param1.onUpdate] function that is called when the field value is updated. + * This is called at least once at record creation. * @returns {import("models").Models[M]} */ - static one(targetModel, { compute, inverse, onAdd, onDelete } = {}) { - return [ONE_SYM, { targetModel, compute, inverse, onAdd, onDelete }]; + static one(targetModel, { compute, eager = false, inverse, onAdd, onDelete, onUpdate } = {}) { + return [ONE_SYM, { targetModel, compute, eager, inverse, onAdd, onDelete, onUpdate }]; } /** * @template {keyof import("models").Models} M * @param {M} targetModel - * @param {Function} [compute] if set, the value of this relational field is declarative and + * @param {Object} [param1={}] + * @param {Function} [param1.compute] if set, the value of this relational field is declarative and * is computed automatically. All reactive accesses recalls that function. The context of * the function is the record. Returned value is new value assigned to this field. - * @param {string} [inverse] if set, the name of field in targetModel that acts as the inverse. - * @param {(r: M) => void} [onAdd] function that is called when a record is added + * @property {boolean} [eager=false] when field is computed, determines whether the computation + * of this field is eager or lazy. By default, fields are computed lazily, which means that + * they are computed when dependencies change AND when this field is being used. In eager mode, + * the field is immediately (re-)computed when dependencies changes, which matches the built-in + * behaviour of OWL reactive. + * @param {string} [param1.inverse] if set, the name of field in targetModel that acts as the inverse. + * @param {(r: import("models").Models[M]) => void} [param1.onAdd] function that is called when a record is added * in the relation. - * @param {(r: M) => void} [onDelete] function that is called when a record is removed + * @param {(r: import("models").Models[M]) => void} [param1.onDelete] function that is called when a record is removed * from the relation. + * @param {() => void} [param1.onUpdate] function that is called when the field value is updated. + * This is called at least once at record creation. + * @property {(r1: import("models").Models[M], r2: import("models").Models[M]) => number} [sort] if defined, this field + * is automatically sorted by this function. * @returns {import("models").Models[M][]} */ - static many(targetModel, { compute, inverse, onAdd, onDelete } = {}) { - return [MANY_SYM, { targetModel, compute, inverse, onAdd, onDelete }]; + static many( + targetModel, + { compute, eager = false, inverse, onAdd, onDelete, onUpdate, sort } = {} + ) { + return [ + MANY_SYM, + { targetModel, compute, eager, inverse, onAdd, onDelete, onUpdate, sort }, + ]; } /** * @template T - * @param {T} def; - * @param {Function} [compute] if set, the value of this field is declarative and + * @param {T} def + * @param {Object} [param1={}] + * @param {Function} [param1.compute] if set, the value of this attr field is declarative and * is computed automatically. All reactive accesses recalls that function. The context of * the function is the record. Returned value is new value assigned to this field. - * @param {boolean} [html] if set, the field value contains html value. + * @property {boolean} [eager=false] when field is computed, determines whether the computation + * of this field is eager or lazy. By default, fields are computed lazily, which means that + * they are computed when dependencies change AND when this field is being used. In eager mode, + * the field is immediately (re-)computed when dependencies changes, which matches the built-in + * behaviour of OWL reactive. + * @param {boolean} [param1.html] if set, the field value contains html value. * Useful to automatically markup when the insert is trusted. + * @param {() => void} [param1.onUpdate] function that is called when the field value is updated. + * This is called at least once at record creation. * @returns {T} */ - static attr(def, { compute, html } = {}) { - return [ATTR_SYM, { compute, default: def, html }]; + static attr(def, { compute, eager = false, html, onUpdate } = {}) { + return [ATTR_SYM, { compute: compute, default: def, eager, html, onUpdate }]; } /** @returns {Record|Record[]} */ static insert(data, options = {}) { - const isMulti = Array.isArray(data); - if (!isMulti) { - data = [data]; - } - const oldTrusted = Record.trusted; - Record.trusted = options.html ?? Record.trusted; - const res = data.map((d) => this._insert(d, options)); - Record.trusted = oldTrusted; - if (!isMulti) { - return res[0]; - } - return res; + return Record.MAKE_UPDATE(() => { + const isMulti = Array.isArray(data); + if (!isMulti) { + data = [data]; + } + const oldTrusted = Record.trusted; + Record.trusted = options.html ?? Record.trusted; + const res = data.map((d) => this._insert(d, options)); + Record.trusted = oldTrusted; + if (!isMulti) { + return res[0]; + } + return res; + }); } /** @returns {Record} */ static _insert(data) { @@ -668,32 +1341,13 @@ export class Record { static isCommand(data) { return ["ADD", "DELETE", "ADD.noinv", "DELETE.noinv"].includes(data?.[0]?.[0]); } - /** - * Object that contains compute functions of computed fields, i.e. fields that have have - * a compute method. @see compute param in Record.one and Record.many. Key is field name - * and value is function. The function is the one from the definition. It is not bounded - * to the record nor its invoke does assign the value on the targeted field. See non-static - * __computes__ for bounded function whose call auto re-assign value on the field. - * - * @type {Object} - */ - static __computes__ = {}; - /** - * Object that contains bounded compute functions of computed fields. Equivalent to - * static `__computes__` but the functions are bounded to the current record, and - * invoking the function does automatically re-assign new value on the computed - * field. - * - * @type {Object} - */ - __computes__ = {}; /** * Raw relational values of the record, each of which contains object id(s) * rather than the record(s). This allows data in store and models being normalized, * which eases handling relations notably in when a record gets deleted. * - * @type {Object} + * @type {Object} */ _fields = {}; __uses__ = new RecordUses(); @@ -725,40 +1379,44 @@ export class Record { setup() {} update(data) { - if (typeof data === "object" && data !== null) { - Object.assign(this, data); - } else { - // update on single-id data - if (this.Model.id in this.Model._fields) { - this[this.Model.id] = data; + return Record.MAKE_UPDATE(() => { + if (typeof data === "object" && data !== null) { + Object.assign(this, data); + } else { + // update on single-id data + if (this.Model.id in toRaw(this).Model._fields) { + this[this.Model.id] = data; + } } - } + }); } delete() { - const r1 = this; - for (const name in r1._fields) { - r1[name] = undefined; - } - for (const [localId, names] of r1.__uses__.data.entries()) { - for (const [name2, count] of names.entries()) { - const r2 = this._store.get(localId); - if (!r2) { - // record already deleted, clean inverses - r1.__uses__.data.delete(localId); - continue; - } - const l2 = r2._fields[name2]; - if (RecordList.isMany(l2)) { - for (let c = 0; c < count; c++) { - r2[name2].delete(r1); + return Record.MAKE_UPDATE(() => { + const r1 = this; + for (const name in r1._fields) { + r1[name] = undefined; + } + for (const [localId, names] of r1.__uses__.data.entries()) { + for (const [name2, count] of names.entries()) { + const r2 = this._store.get(localId); + if (!r2) { + // record already deleted, clean inverses + r1.__uses__.data.delete(localId); + continue; + } + const l2 = r2._fields[name2].value; + if (RecordList.isMany(l2)) { + for (let c = 0; c < count; c++) { + r2[name2].delete(r1); + } + } else { + r2[name2] = undefined; } - } else { - r2[name2] = undefined; } } - } - delete this.Model.records[r1.localId]; + delete this.Model.records[r1.localId]; + }); } /** @param {Record} record */ @@ -790,10 +1448,10 @@ export class Record { toData() { const data = { ...this }; - for (const [name, val] of Object.entries(this._fields)) { - if (RecordList.isMany(val)) { - data[name] = val.map((r) => r.toIdData()); - } else if (RecordList.isOne(val)) { + for (const [name, { value }] of Object.entries(this._fields)) { + if (RecordList.isMany(value)) { + data[name] = value.map((r) => r.toIdData()); + } else if (RecordList.isOne(value)) { data[name] = this[name]?.toIdData(); } else { data[name] = this[name]; // Record.attr() @@ -817,3 +1475,20 @@ export class Record { } Record.register(); + +export class BaseStore extends Record { + /** + * @param {string} localId + * @returns {Record} + */ + get(localId) { + if (typeof localId !== "string") { + return undefined; + } + const modelName = Record.modelFromLocalId(localId); + if (modelName === "Store") { + return this; + } + return this[modelName].records[localId]; + } +} diff --git a/addons/mail/static/src/core/common/store_service.js b/addons/mail/static/src/core/common/store_service.js index 68e60080a57..016938b3db4 100644 --- a/addons/mail/static/src/core/common/store_service.js +++ b/addons/mail/static/src/core/common/store_service.js @@ -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} */ - 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|RecordList} */ - 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 ( diff --git a/addons/mail/static/src/core/common/thread_model.js b/addons/mail/static/src/core/common/thread_model.js index 3e24253c1d9..862599ac75e 100644 --- a/addons/mail/static/src/core/common/thread_model.js +++ b/addons/mail/static/src/core/common/thread_model.js @@ -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; diff --git a/addons/mail/static/src/core/common/thread_service.js b/addons/mail/static/src/core/common/thread_service.js index e37b5637795..490a6c6d89a 100644 --- a/addons/mail/static/src/core/common/thread_service.js +++ b/addons/mail/static/src/core/common/thread_service.js @@ -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; } diff --git a/addons/mail/static/src/core/web/mail_core_web_service.js b/addons/mail/static/src/core/web/mail_core_web_service.js index 3aeb257edd4..6ba7fec4f87 100644 --- a/addons/mail/static/src/core/web/mail_core_web_service.js +++ b/addons/mail/static/src/core/web/mail_core_web_service.js @@ -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} services diff --git a/addons/mail/static/src/core/web/messaging_menu.js b/addons/mail/static/src/core/web/messaging_menu.js index 3039adf2aa8..e2b9dd9760d 100644 --- a/addons/mail/static/src/core/web/messaging_menu.js +++ b/addons/mail/static/src/core/web/messaging_menu.js @@ -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; } /** diff --git a/addons/mail/static/src/discuss/call/common/discuss_app_model_patch.js b/addons/mail/static/src/discuss/call/common/discuss_app_model_patch.js index 03cd6b5ecd6..b96109a4d91 100644 --- a/addons/mail/static/src/discuss/call/common/discuss_app_model_patch.js +++ b/addons/mail/static/src/discuss/call/common/discuss_app_model_patch.js @@ -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 { diff --git a/addons/mail/static/src/discuss/core/common/discuss_core_common_service.js b/addons/mail/static/src/discuss/core/common/discuss_core_common_service.js index f3e7850b07b..68c9aeb2c70 100644 --- a/addons/mail/static/src/discuss/core/common/discuss_core_common_service.js +++ b/addons/mail/static/src/discuss/core/common/discuss_core_common_service.js @@ -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; }, }; diff --git a/addons/mail/static/src/discuss/core/web/channel_selector.js b/addons/mail/static/src/discuss/core/web/channel_selector.js index f9193d682c3..5b1a8cd81d1 100644 --- a/addons/mail/static/src/discuss/core/web/channel_selector.js +++ b/addons/mail/static/src/discuss/core/web/channel_selector.js @@ -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 { diff --git a/addons/mail/static/src/discuss/core/web/discuss_core_common_service_patch.js b/addons/mail/static/src/discuss/core/web/discuss_core_common_service_patch.js new file mode 100644 index 00000000000..e8d56f50094 --- /dev/null +++ b/addons/mail/static/src/discuss/core/web/discuss_core_common_service_patch.js @@ -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, + }); + } + }, +}); diff --git a/addons/mail/static/src/discuss/core/web/discuss_core_web_service.js b/addons/mail/static/src/discuss/core/web/discuss_core_web_service.js index 026f1d0073d..de4b3656ea5 100644 --- a/addons/mail/static/src/discuss/core/web/discuss_core_web_service.js +++ b/addons/mail/static/src/discuss/core/web/discuss_core_web_service.js @@ -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 = diff --git a/addons/mail/static/tests/core/record_tests.js b/addons/mail/static/tests/core/record_tests.js index a24c6cdf455..9492025156e 100644 --- a/addons/mail/static/tests/core/record_tests.js +++ b/addons/mail/static/tests/core/record_tests.js @@ -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) => { diff --git a/addons/website_livechat/static/src/web/discuss_app_category_model_patch.js b/addons/website_livechat/static/src/web/discuss_app_category_model_patch.js new file mode 100644 index 00000000000..a781ff68abb --- /dev/null +++ b/addons/website_livechat/static/src/web/discuss_app_category_model_patch.js @@ -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); + }, +});