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); + }, +});