From b71e1fd22d4a404ea65245989c55e7c0bd90bda2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Wed, 20 Sep 2023 14:28:48 +0200 Subject: [PATCH] [IMP] mail: add relational fields on patch of discuss model With the introduction of relational fields on discuss models, which makes it easier to manage relations between discuss records, we are incentivized to define all discuss relations in the models. Some services managed relations with their own datastructure. For example, pin_message service uses some Maps and Sets to model messages pinned on channels. This is cumbersome to manage deletion of records by hand... And also the name of attributes in the service are too long, which shows they are not placed in the right context (= in a dedicated service rather than in models). This commit turn these relational data in pin_message service into new relational fields on Thread and Message models. ```js patch(Thread.prototype, { setup() { this.pinnedMessages = Thread.Set("Messages"); }, }); ``` For type inference of the discuss model patches, we rely on module augmentation of TypeScript with interfaces: we define an interface for the patch of a given model, and we combine the type of this "patch" interface with the original Model type in the listing of Models types: ```ts declare module "models" { import { Persona } from "./persona"; import { Thread } from "./thread"; export interface Models { "Message": Message, "Thread": Thread & Partial, } export interface Thread_Patch { pinnedMessages: RecordSet } } ``` That way, whenever we use a record e.g. from a one relational field, we have proper typing for the patches: ```js class Message { originThread = Record.one("Thread"); } message.originThread; // Thread & Partial message.originThread.pinnedMessages; // RecordList ``` So whenever you adapt a discuss model patch or add a new one, make sure to register new props in an interface of "models". Other devs will be grateful towards you! ------ Note that the previous implementation of Relational fields on discuss models was not robust enough with patching, notably with the implementation details of `@web/patch()` function. Instead of using `Object.defineProperty` on the model class prototype of each relational field, we now use a proxy. This prevents the proxy hook to be shadowed by the patches. This also allows to support a new way to delete `Record.one()` relations with the `delete` keyword. Part-of: odoo/odoo#134884 --- .../src/core/common/attachment_model.js | 8 +- .../src/core/common/attachment_service.js | 17 -- .../src/core/common/canned_response_model.js | 8 +- .../src/core/common/channel_member_model.js | 8 +- .../src/core/common/chat_window_model.js | 8 +- .../static/src/core/common/composer_model.js | 6 +- .../static/src/core/common/follower_model.js | 8 +- .../src/core/common/link_preview_model.js | 6 +- .../core/common/mail_core_common_service.js | 5 - .../static/src/core/common/message_model.js | 8 +- .../core/common/message_reactions_model.js | 8 +- .../core/common/notification_group_model.js | 8 +- .../src/core/common/notification_model.js | 8 +- .../static/src/core/common/persona_model.js | 8 +- addons/mail/static/src/core/common/record.js | 29 ++- .../static/src/core/common/store_service.js | 173 ++++++++++-------- .../static/src/core/common/thread_model.js | 10 +- .../static/src/core/common/thread_service.js | 11 +- .../static/src/core/web/activity_model.js | 8 +- .../src/core/web/mail_core_web_service.js | 16 -- .../static/src/core/web/messaging_menu.js | 2 +- .../src/core/web/thread_service_patch.js | 7 - .../discuss/call/common/rtc_session_model.js | 8 +- .../common/discuss_core_common_service.js | 3 - .../message_pin/common/message_pin_service.js | 111 +++++------ .../messaging_menu/messaging_menu_tests.js | 4 +- addons/mail/tooling/types/models.d.ts | 15 +- 27 files changed, 237 insertions(+), 274 deletions(-) diff --git a/addons/mail/static/src/core/common/attachment_model.js b/addons/mail/static/src/core/common/attachment_model.js index a78c0c45400..17d7bbb30c9 100644 --- a/addons/mail/static/src/core/common/attachment_model.js +++ b/addons/mail/static/src/core/common/attachment_model.js @@ -8,19 +8,19 @@ import { url } from "@web/core/utils/urls"; export class Attachment extends Record { static id = "id"; - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {Attachment} */ + /** @returns {import("models").Models["Attachment"]} */ static new(data) { return super.new(data); } - /** @returns {Attachment} */ + /** @returns {import("models").Models["Attachment"]} */ static get(data) { return super.get(data); } /** * @param {Object} data - * @returns {Attachment} + * @returns {import("models").Models["Attachment"]} */ static insert(data) { if (!("id" in data)) { diff --git a/addons/mail/static/src/core/common/attachment_service.js b/addons/mail/static/src/core/common/attachment_service.js index 6add0e631d2..6374e8cdbcc 100644 --- a/addons/mail/static/src/core/common/attachment_service.js +++ b/addons/mail/static/src/core/common/attachment_service.js @@ -1,6 +1,5 @@ /* @odoo-module */ -import { removeFromArrayWithPredicate } from "@mail/utils/common/arrays"; import { assignDefined } from "@mail/utils/common/misc"; import { registry } from "@web/core/registry"; @@ -65,22 +64,6 @@ export class AttachmentService { URL.revokeObjectURL(attachment.tmpUrl); } attachment.delete(); - if (attachment.originThread) { - removeFromArrayWithPredicate(attachment.originThread.attachments, (att) => - att.eq(attachment) - ); - } - for (const message of Object.values(this.store.Message.records)) { - removeFromArrayWithPredicate(message.attachments, (att) => att.eq(attachment)); - if (message.composer) { - removeFromArrayWithPredicate(message.composer.attachments, (att) => - att.eq(attachment) - ); - } - } - for (const thread of Object.values(this.store.Thread.records)) { - removeFromArrayWithPredicate(thread.composer.attachments, (att) => att.eq(attachment)); - } } /** diff --git a/addons/mail/static/src/core/common/canned_response_model.js b/addons/mail/static/src/core/common/canned_response_model.js index c4bc0ec7d52..c10cac9172c 100644 --- a/addons/mail/static/src/core/common/canned_response_model.js +++ b/addons/mail/static/src/core/common/canned_response_model.js @@ -4,19 +4,19 @@ import { Record } from "@mail/core/common/record"; export class CannedResponse extends Record { static id = "id"; - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {CannedResponse} */ + /** @returns {import("models").Models["CannedResponse"]} */ static new(data) { return super.new(data); } - /** @returns {CannedResponse} */ + /** @returns {import("models").Models["CannedResponse"]} */ static get(data) { return super.get(data); } /** * @param {Object} data - * @returns {CannedResponse} + * @returns {import("models").Models["CannedResponse"]} */ static insert(data) { const cannedResponse = this.get(data) ?? this.new(data); diff --git a/addons/mail/static/src/core/common/channel_member_model.js b/addons/mail/static/src/core/common/channel_member_model.js index 1ec3dc78cf5..73b65fcefdd 100644 --- a/addons/mail/static/src/core/common/channel_member_model.js +++ b/addons/mail/static/src/core/common/channel_member_model.js @@ -11,19 +11,19 @@ import { Record } from "@mail/core/common/record"; */ export class ChannelMember extends Record { static id = "id"; - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {ChannelMember} */ + /** @returns {import("models").Models["ChannelMember"]} */ static new(data) { return super.new(data); } - /** @returns {ChannelMember} */ + /** @returns {import("models").Models["ChannelMember"]} */ static get(data) { return super.get(data); } /** * @param {Object|Array} data - * @returns {ChannelMember} + * @returns {import("models").Models["ChannelMember"]} */ static insert(data) { const memberData = Array.isArray(data) ? data[1] : data; diff --git a/addons/mail/static/src/core/common/chat_window_model.js b/addons/mail/static/src/core/common/chat_window_model.js index a62df699c90..66ab0c0af5a 100644 --- a/addons/mail/static/src/core/common/chat_window_model.js +++ b/addons/mail/static/src/core/common/chat_window_model.js @@ -9,19 +9,19 @@ import { _t } from "@web/core/l10n/translation"; export class ChatWindow extends Record { static id = "thread"; - /** @type {ChatWindow[]} */ + /** @type {import("models").Models["ChatWindow"][]} */ static records = []; - /** @returns {ChatWindow} */ + /** @returns {import("models").Models["ChatWindow"]} */ static new(data) { return super.new(data); } - /** @returns {ChatWindow} */ + /** @returns {import("models").Models["ChatWindow"]} */ static get(data) { return super.get(data); } /** * @param {ChatWindowData} [data] - * @returns {ChatWindow} + * @returns {import("models").Models["ChatWindow"]} */ static insert(data = {}) { const chatWindow = this.records.find((c) => c.thread?.eq(data.thread)); diff --git a/addons/mail/static/src/core/common/composer_model.js b/addons/mail/static/src/core/common/composer_model.js index e576fd290da..1df4f160f00 100644 --- a/addons/mail/static/src/core/common/composer_model.js +++ b/addons/mail/static/src/core/common/composer_model.js @@ -8,17 +8,17 @@ import { OR, Record } from "@mail/core/common/record"; export class Composer extends Record { static id = OR("thread", "message"); - /** @returns {Composer} */ + /** @returns {import("models").Models["Composer"]} */ static new(data) { return super.new(data); } - /** @returns {Composer} */ + /** @returns {import("models").Models["Composer"]} */ static get(data) { return super.get(data); } /** * @param {Object} data - * @returns {Composer} + * @returns {import("models").Models["Composer"]} */ static insert(data) { const { message, thread } = data; diff --git a/addons/mail/static/src/core/common/follower_model.js b/addons/mail/static/src/core/common/follower_model.js index 6e95d63e6a8..321daaa481e 100644 --- a/addons/mail/static/src/core/common/follower_model.js +++ b/addons/mail/static/src/core/common/follower_model.js @@ -12,19 +12,19 @@ import { Record } from "@mail/core/common/record"; export class Follower extends Record { static id = "id"; - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {Follower} */ + /** @returns {import("models").Models["Follower"]} */ static new(data) { return super.new(data); } - /** @returns {Follower} */ + /** @returns {import("models").Models["Follower"]} */ static get(data) { return super.get(data); } /** * @param {Data} data - * @returns {Follower} + * @returns {import("models").Models["Follower"]} */ static insert(data) { const follower = this.get(data) ?? this.new(data); diff --git a/addons/mail/static/src/core/common/link_preview_model.js b/addons/mail/static/src/core/common/link_preview_model.js index 18a9696b35a..c8fb7957e61 100644 --- a/addons/mail/static/src/core/common/link_preview_model.js +++ b/addons/mail/static/src/core/common/link_preview_model.js @@ -4,17 +4,17 @@ import { Record } from "@mail/core/common/record"; export class LinkPreview extends Record { static id = "id"; - /** @returns {LinkPreview} */ + /** @returns {import("models").Models["LinkPreview"]} */ static new(data) { return super.new(data); } - /** @returns {LinkPreview} */ + /** @returns {import("models").Models["LinkPreview"]} */ static get(data) { return super.get(data); } /** * @param {Object} data - * @returns {LinkPreview} + * @returns {import("models").Models["LinkPreview"]} */ static insert(data) { const message = this.store.Message.get(data.message_id); diff --git a/addons/mail/static/src/core/common/mail_core_common_service.js b/addons/mail/static/src/core/common/mail_core_common_service.js index 1acfe607acd..d77488dadfe 100644 --- a/addons/mail/static/src/core/common/mail_core_common_service.js +++ b/addons/mail/static/src/core/common/mail_core_common_service.js @@ -50,11 +50,6 @@ export class MailCoreCommon { continue; } message.delete(); - if (message.originThread) { - removeFromArrayWithPredicate(message.originThread.messages, (msg) => - msg.eq(message) - ); - } this.env.bus.trigger("mail.message/delete", { message }); } }); diff --git a/addons/mail/static/src/core/common/message_model.js b/addons/mail/static/src/core/common/message_model.js index 1b40a2b9cc4..92568181ab1 100644 --- a/addons/mail/static/src/core/common/message_model.js +++ b/addons/mail/static/src/core/common/message_model.js @@ -14,19 +14,19 @@ const { DateTime } = luxon; export class Message extends Record { static id = "id"; - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {Message} */ + /** @returns {import("models").Models["Message"]} */ static new(data) { return super.new(data); } - /** @returns {Message} */ + /** @returns {import("models").Models["Message"]} */ static get(data) { return super.get(data); } /** * @param {Object} data - * @returns {Message} + * @returns {import("models").Models["Message"]} */ static insert(data) { if (data.res_id) { diff --git a/addons/mail/static/src/core/common/message_reactions_model.js b/addons/mail/static/src/core/common/message_reactions_model.js index 104d21b3da9..1c40f30b7a0 100644 --- a/addons/mail/static/src/core/common/message_reactions_model.js +++ b/addons/mail/static/src/core/common/message_reactions_model.js @@ -4,17 +4,17 @@ import { AND, Record } from "@mail/core/common/record"; export class MessageReactions extends Record { static id = AND("message", "content"); - /** @returns {MessageReactions} */ + /** @returns {import("models").Models["MessageReactions"]} */ static new(data) { return super.new(data); } - /** @returns {MessageReactions} */ + /** @returns {import("models").Models["MessageReactions"]} */ static get(data) { return super.get(data); } /** * @param {Object} data - * @returns {MessageReactions} + * @returns {import("models").Models["MessageReactions"]} */ static insert(data) { let reaction = this.store.Message.get(data.message.id)?.reactions.find( @@ -48,7 +48,7 @@ export class MessageReactions extends Record { Object.assign(reaction, { count: data.count, content: data.content, - message: data.message, + message: this.store.Message.insert(data.message), personas: reaction.personas.filter((p) => !personasToUnlink.has(p)), }); return reaction; diff --git a/addons/mail/static/src/core/common/notification_group_model.js b/addons/mail/static/src/core/common/notification_group_model.js index dccc4661825..10834557c9d 100644 --- a/addons/mail/static/src/core/common/notification_group_model.js +++ b/addons/mail/static/src/core/common/notification_group_model.js @@ -8,19 +8,19 @@ import { _t } from "@web/core/l10n/translation"; let nextId = 1; export class NotificationGroup extends Record { static id = "id"; - /** @type {NotificationGroup[]} */ + /** @type {import("models").Models["NotificationGroup"][]} */ static records = []; - /** @returns {NotificationGroup} */ + /** @returns {import("models").Models["NotificationGroup"]} */ static new(data) { return super.new(data); } - /** @returns {NotificationGroup} */ + /** @returns {import("models").Models["NotificationGroup"]} */ static get(data) { return super.get(data); } /** * @param {Object} data - * @returns {NotificationGroup} + * @returns {import("models").Models["NotificationGroup"]} */ static insert(data) { let group = this.records.find((group) => { diff --git a/addons/mail/static/src/core/common/notification_model.js b/addons/mail/static/src/core/common/notification_model.js index 42e3d820d87..d3c14edf779 100644 --- a/addons/mail/static/src/core/common/notification_model.js +++ b/addons/mail/static/src/core/common/notification_model.js @@ -6,19 +6,19 @@ import { _t } from "@web/core/l10n/translation"; export class Notification extends Record { static id = "id"; - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {Notification} */ + /** @returns {import("models").Models["Notification"]} */ static new(data) { return super.new(data); } - /** @returns {Notification} */ + /** @returns {import("models").Models["Notification"]} */ static get(data) { return super.get(data); } /** * @param {Object} data - * @returns {Notification} + * @returns {import("models").Models["Notification"]} */ static insert(data) { const notification = this.get(data) ?? this.new(data); diff --git a/addons/mail/static/src/core/common/persona_model.js b/addons/mail/static/src/core/common/persona_model.js index fc8574f7809..627c4e58fd5 100644 --- a/addons/mail/static/src/core/common/persona_model.js +++ b/addons/mail/static/src/core/common/persona_model.js @@ -14,19 +14,19 @@ import { AND, Record } from "@mail/core/common/record"; export class Persona extends Record { static id = AND("type", "id"); - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {Persona} */ + /** @returns {import("models").Models["Persona"]} */ static new(data) { return super.new(data); } - /** @returns {Persona} */ + /** @returns {import("models").Models["Persona"]} */ static get(data) { return super.get(data); } /** * @param {Data} data - * @returns {Persona} + * @returns {import("models").Models["Persona"]} */ static insert(data) { const persona = this.get(data) ?? this.new(data); diff --git a/addons/mail/static/src/core/common/record.js b/addons/mail/static/src/core/common/record.js index d6d49543c0d..ba2fbd29327 100644 --- a/addons/mail/static/src/core/common/record.js +++ b/addons/mail/static/src/core/common/record.js @@ -148,14 +148,14 @@ export class RecordList extends Array { return this.__list__.length; } /** - * @param {Function} func + * @param {(a: R, b: R) => boolean} func * @returns {R[]} */ map(func) { return this.__list__.map((localId) => func(this.__store__.get(localId))); } /** - * @param {Function} predicate + * @param {(a: R, b: R) => boolean} predicate * @returns {R[]} */ filter(predicate) { @@ -163,16 +163,16 @@ export class RecordList extends Array { .filter((localId) => predicate(this.__store__.get(localId))) .map((localId) => this.__store__.get(localId)); } - /** @param {Function} predicate */ + /** @param {(a: R, b: R) => boolean} predicate */ some(predicate) { return this.__list__.some((localId) => predicate(this.__store__.get(localId))); } - /** @param {Function} predicate */ + /** @param {(a: R, b: R) => boolean} predicate */ every(predicate) { return this.__list__.every((localId) => predicate(this.__store__.get(localId))); } /** - * @param {Function} predicate + * @param {(a: R, b: R) => boolean} predicate * @returns {R} */ find(predicate) { @@ -180,7 +180,7 @@ export class RecordList extends Array { this.__list__.find((localId) => predicate(this.__store__.get(localId))) ); } - /** @param {Function} predicate */ + /** @param {(a: R, b: R) => boolean} predicate */ findIndex(predicate) { return this.__list__.findIndex((localId) => predicate(this.__store__.get(localId))); } @@ -215,7 +215,7 @@ export class RecordList extends Array { this.__addInverse__(r); } } - /** @param {Function} func */ + /** @param {(a: R, b: R) => boolean} func */ sort(func) { this.__list__.sort((a, b) => func(this.__store__.get(a), this.__store__.get(b))); } @@ -225,6 +225,7 @@ export class RecordList extends Array { .map((localId) => this.__store__.get(localId)) .concat(...collections.map((c) => [...c])); } + /** @yields {R} */ *[Symbol.iterator]() { for (const localId of this.__list__) { yield this.__store__.get(localId); @@ -275,16 +276,19 @@ export class RecordSet extends Set { has(record) { return this.__set__.has(record?.localId); } + /** @yields {R} */ *values() { for (const localId of this.__set__) { yield this.__store__.get(localId); } } + /** @yields {R} */ *keys() { for (const localId of this.__set__) { yield this.__store__.get(localId); } } + /** @yields {R} */ *[Symbol.iterator]() { for (const localId of this.__set__) { yield this.__store__.get(localId); @@ -454,6 +458,12 @@ export class Record { /** @type {string} */ localId; + constructor() { + this.setup(); + } + + setup() {} + delete() { const r1 = this; for (const [name, l1] of r1.__rels__.entries()) { @@ -468,6 +478,11 @@ export class Record { for (const [localId, names] of r1.__invs__.__map__.entries()) { for (const [name2, count] of names.entries()) { const r2 = this._store.get(localId); + if (!r2) { + // record already deleted, clean inverses + r1.__invs__.__map__.delete(localId); + continue; + } const l2 = r2.__rels__.get(name2); if (l2 instanceof RecordList) { for (let c = 0; c < count; c++) { diff --git a/addons/mail/static/src/core/common/store_service.js b/addons/mail/static/src/core/common/store_service.js index c0d51ac5d94..2b2e1aab355 100644 --- a/addons/mail/static/src/core/common/store_service.js +++ b/addons/mail/static/src/core/common/store_service.js @@ -205,11 +205,15 @@ export const storeService = { // instance props. const entry = Object.assign(Object.create(Model), { env, store: res }); // Produce another class with changed prototype, so that there are automatic get/set on relational fields + let detecting = true; const Class = { [Model.name]: class extends Model { static __rels__ = new Set(); constructor() { super(); + if (detecting) { + return; + } for (const name of this.constructor.__rels__) { // Relational fields contain symbols for detection in original class. // This constructor is called on genuine records: @@ -230,10 +234,100 @@ export const storeService = { newVal.name = name; newVal.owner = this; } - this[name] = newVal; this.__rels__.set(name, newVal); this.__invs__ = new RecordInverses(); + this[name] = newVal; } + return new Proxy(this, { + /** @param {Record} receiver */ + get(target, name, receiver) { + if (name !== "__rels__" && receiver.__rels__.has(name)) { + const l1 = receiver.__rels__.get(name); + if (l1 instanceof RecordList || l1 instanceof RecordSet) { + return l1; + } + return res.get(l1); + } + return Reflect.get(target, name, receiver); + }, + deleteProperty(target, key) { + if (name !== "__rels__" && target.__rels__.has(name)) { + const r1 = target; + const l1 = r1.__rels__.get(name); + const r2 = res.get(l1); + if (r2) { + r2.__invs__.delete(r1.localId, name); + } + } + const ret = Reflect.deleteProperty(target, key); + return ret; + }, + /** @param {Record} receiver */ + set(target, name, val, receiver) { + if (receiver.__rels__.has(name)) { + const oldVal = receiver.__rels__.get(name); + if (oldVal instanceof RecordList) { + const r1 = receiver; + /** @type {RecordList} */ + const l1 = r1.__rels__.get(name); + /** @type {Record[]|Set|RecordList|RecordSet} */ + const collection = val; + const oldRecords = l1.slice(); + l1.__list__ = []; + for (const r2 of oldRecords) { + r2.__invs__.delete(r1.localId, name); + } + for (const r3 of collection) { + l1.__list__.push(r3.localId); + r3.__invs__.add(r1.localId, name); + } + } else if (oldVal instanceof RecordSet) { + const r1 = receiver; + /** @type {RecordSet} */ + const l1 = r1.__rels__.get(name); + /** @type {Record[]|Set|RecordList|RecordSet} */ + const collection = val; + const oldRecords = new Set(); + const newRecords = new Set(); + for (const r of collection) { + if (!l1.__set__.has(r?.localId)) { + newRecords.add(r); + } + } + for (const r of r1) { + if (r.notIn(oldRecords)) { + oldRecords.add(r); + } + } + for (const r of oldRecords) { + r1.delete(r); + } + for (const r of newRecords) { + r1.add(r); + } + } else { + const r1 = receiver; + const l1 = r1.__rels__.get(name); + const r2 = res.get(l1); + /** @type {Record} */ + const r3 = val; + if (r2 && r2.notEq(r3)) { + r2.__invs__.delete(r1.localId, name); + } + r1.__rels__.set(name, r3?.localId); + if (r3) { + if (!(r3 instanceof Record)) { + return true; // not a record, ignored + } + r3.__invs__.add(r1.localId, name); + } + } + } else { + Reflect.set(target, name, val, receiver); + } + return true; + }, + }); } }, }[Model.name]; @@ -242,87 +336,12 @@ export const storeService = { res[name] = entry; // Detect relational fields with a dummy record and setup getter/setters on them const obj = new Model(); + detecting = false; for (const [name, val] of Object.entries(obj)) { if (![Record.one(), Record.List(), Record.Set()].includes(val)) { continue; } Class.__rels__.add(name); - if (val === Record.one()) { - Object.defineProperty(Class.prototype, name, { - /** @this {Record} */ - get() { - return res.get(this.__rels__.get(name).value); - }, - /** - * @this {Record} - * @param {Record} r3 - */ - set(r3) { - const r1 = this; - const l1 = r1.__rels__.get(name); - const r2 = res.get(l1.value); - if (r2 && r2.notEq(r3)) { - r2.__invs__.delete(r1.localId, name); - } - l1.value = r3?.localId; - if (r3) { - r3.__invs__.add(r1.localId, name); - } - }, - }); - } - if (val === Record.List()) { - Object.defineProperty(Class.prototype, name, { - /** - * @this {Record} - * @param {Record[]|Set|RecordList|RecordSet} collection - */ - set(collection) { - const r1 = this; - /** @type {RecordList} */ - const l1 = r1.__rels__.get(name).value; - const oldRecords = l1.slice(); - l1.__list__ = []; - for (const r2 of oldRecords) { - r2.__invs__.delete(r1.localId, name); - } - for (const r3 of collection) { - l1.__list__.push(r3.localId); - r3.__invs__.add(r1.localId, name); - } - }, - }); - } - if (val === Record.Set()) { - Object.defineProperty(Class.prototype, name, { - /** - * @this {Record} - * @param {Record[]|Set|RecordList|RecordSet} collection - */ - set(collection) { - /** @type {RecordSet} */ - const l1 = this.__rels__.get(name).value; - const oldRecords = new Set(); - const newRecords = new Set(); - for (const r of collection) { - if (!l1.__set__.has(r?.localId)) { - newRecords.add(r); - } - } - for (const r of this) { - if (r.notIn(oldRecords)) { - oldRecords.add(r); - } - } - for (const r of oldRecords) { - this.delete(r); - } - for (const r of newRecords) { - this.add(r); - } - }, - }); - } } } onChange(res.Thread, "records", () => res.updateBusSubscription()); diff --git a/addons/mail/static/src/core/common/thread_model.js b/addons/mail/static/src/core/common/thread_model.js index 81a340e48fa..f50cefbc77d 100644 --- a/addons/mail/static/src/core/common/thread_model.js +++ b/addons/mail/static/src/core/common/thread_model.js @@ -23,18 +23,18 @@ import { Deferred } from "@web/core/utils/concurrency"; export class Thread extends Record { static id = AND("model", "id"); - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {Thread} */ + /** @returns {import("models").Models["Thread"]} */ static new(data) { return super.new(data); } - /** @returns {Thread} */ + /** @returns {import("models").Models["Thread"]} */ static get(data) { return super.get(data); } /** - * @param {Thread.localId} localId + * @param {string} localId * @returns {string} */ static localIdToActiveId(localId) { @@ -46,7 +46,7 @@ export class Thread extends Record { } /** * @param {Object} data - * @returns {Thread} + * @returns {import("models").Models["Thread"]} */ static insert(data) { if (!("id" in data)) { diff --git a/addons/mail/static/src/core/common/thread_service.js b/addons/mail/static/src/core/common/thread_service.js index 0b7d2f8bc10..6fead89aa62 100644 --- a/addons/mail/static/src/core/common/thread_service.js +++ b/addons/mail/static/src/core/common/thread_service.js @@ -2,11 +2,7 @@ import { loadEmoji } from "@web/core/emoji_picker/emoji_picker"; import { DEFAULT_AVATAR } from "@mail/core/common/persona_service"; -import { - removeFromArray, - removeFromArrayWithPredicate, - replaceArrayWithCompare, -} from "@mail/utils/common/arrays"; +import { removeFromArray, replaceArrayWithCompare } from "@mail/utils/common/arrays"; import { prettifyMessageContent } from "@mail/utils/common/format"; import { assignDefined, nullifyClearCommands } from "@mail/utils/common/misc"; @@ -868,10 +864,7 @@ export class ThreadService { thread.seen_message_id = tmpMsg.id; } const data = await this.rpc(this.getMessagePostRoute(thread), params); - if (thread.type !== "chatter") { - removeFromArrayWithPredicate(thread.messages, (msg) => msg.eq(tmpMsg)); - tmpMsg.delete(); - } + tmpMsg?.delete(); if (!data) { return; } diff --git a/addons/mail/static/src/core/web/activity_model.js b/addons/mail/static/src/core/web/activity_model.js index 3870ef76cc9..5cfbff660ff 100644 --- a/addons/mail/static/src/core/web/activity_model.js +++ b/addons/mail/static/src/core/web/activity_model.js @@ -35,13 +35,13 @@ import { assignDefined } from "@mail/utils/common/misc"; export class Activity extends Record { static id = "id"; - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {Activity} */ + /** @returns {import("models").Models["Activity"]} */ static new(data) { return super.new(data); } - /** @returns {Activity} */ + /** @returns {import("models").Models["Activity"]} */ static get(data) { return super.get(data); } @@ -49,7 +49,7 @@ export class Activity extends Record { * @param {Data} data * @param {Object} [param1] * @param {boolean} param1.broadcast - * @returns {Activity} + * @returns {import("models").Models["Activity"]} */ static insert(data, { broadcast = true } = {}) { const activity = this.get(data) ?? this.new(data); 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 176ba71394a..5c3ffd11345 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 @@ -47,27 +47,11 @@ export class MailCoreWeb { }); this.env.bus.addEventListener("mail.message/delete", ({ detail: { message } }) => { if (message.isNeedaction) { - removeFromArrayWithPredicate( - this.store.discuss.inbox.messages, - ({ id }) => id === message.id - ); this.store.discuss.inbox.counter--; } if (message.isStarred) { - removeFromArrayWithPredicate( - this.store.discuss.starred.messages, - ({ id }) => id === message.id - ); this.store.discuss.starred.counter--; } - if (message.originThread) { - if (message.isNeedaction) { - removeFromArrayWithPredicate( - message.originThread.needactionMessages, - ({ id }) => id === message.id - ); - } - } }); this.busService.subscribe("mail.message/inbox", (payload) => { const data = Object.assign(payload, { body: markup(payload.body) }); diff --git a/addons/mail/static/src/core/web/messaging_menu.js b/addons/mail/static/src/core/web/messaging_menu.js index ee61ce18190..91929ac312b 100644 --- a/addons/mail/static/src/core/web/messaging_menu.js +++ b/addons/mail/static/src/core/web/messaging_menu.js @@ -298,7 +298,7 @@ export class MessagingMenu extends Component { (thread) => thread.is_pinned && thread.message_unread_counter > 0 ).length + Object.values(this.store.NotificationGroup.records).reduce( - (acc, ng) => acc + parseInt(Object.values(ng.notifications).length), + (acc, ng) => acc + parseInt(ng.notifications.length), 0 ); if (this.notification.permission === "prompt") { diff --git a/addons/mail/static/src/core/web/thread_service_patch.js b/addons/mail/static/src/core/web/thread_service_patch.js index 2ff8805239f..4e6aa718fa2 100644 --- a/addons/mail/static/src/core/web/thread_service_patch.js +++ b/addons/mail/static/src/core/web/thread_service_patch.js @@ -237,13 +237,6 @@ patch(ThreadService.prototype, { [follower.followedThread.id], [follower.partner.id], ]); - const thread = follower.followedThread; - if (follower.eq(thread.selfFollower)) { - thread.selfFollower = undefined; - } else { - thread.followers.delete(follower); - } - this.removeRecipient(follower); follower.delete(); }, unpin(thread) { diff --git a/addons/mail/static/src/discuss/call/common/rtc_session_model.js b/addons/mail/static/src/discuss/call/common/rtc_session_model.js index 59fc621ba91..a6493fb14d4 100644 --- a/addons/mail/static/src/discuss/call/common/rtc_session_model.js +++ b/addons/mail/static/src/discuss/call/common/rtc_session_model.js @@ -4,19 +4,19 @@ import { Record } from "@mail/core/common/record"; export class RtcSession extends Record { static id = "id"; - /** @type {Object.} */ + /** @type {Object.} */ static records = {}; - /** @returns {RtcSession} */ + /** @returns {import("models").Models["RtcSession"]} */ static new(data) { return super.new(data); } - /** @returns {RtcSession} */ + /** @returns {import("models").Models["RtcSession"]} */ static get(data) { return super.get(data); } /** * @param {Object} data - * @returns {number, RtcSession} + * @returns {number, import("models").Models["RtcSession"]} */ static insert(data) { const session = this.get(data) ?? this.new(data); 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 3bab9b66963..6d6095fb06e 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,7 +1,5 @@ /* @odoo-module */ -import { removeFromArrayWithPredicate } from "@mail/utils/common/arrays"; - import { markup, reactive } from "@odoo/owl"; import { _t } from "@web/core/l10n/translation"; @@ -226,7 +224,6 @@ export class DiscussCoreCommon { if (!channel.is_pinned) { this.threadService.pin(channel); } - removeFromArrayWithPredicate(channel.messages, ({ id }) => id === messageData.temporary_id); this.store.Message.get(messageData.temporary_id)?.delete(); messageData.temporary_id = null; if ("parentMessage" in messageData && messageData.parentMessage.body) { diff --git a/addons/mail/static/src/discuss/message_pin/common/message_pin_service.js b/addons/mail/static/src/discuss/message_pin/common/message_pin_service.js index 66a45cca1ff..da21f5825ee 100644 --- a/addons/mail/static/src/discuss/message_pin/common/message_pin_service.js +++ b/addons/mail/static/src/discuss/message_pin/common/message_pin_service.js @@ -2,26 +2,38 @@ import { Message } from "@mail/core/common/message"; import { MessageConfirmDialog } from "@mail/core/common/message_confirm_dialog"; +import { Message as MessageModel } from "@mail/core/common/message_model"; +import { Thread } from "@mail/core/common/thread_model"; import { markup, reactive } from "@odoo/owl"; import { _t } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; +import { patch } from "@web/core/utils/patch"; export const OTHER_LONG_TYPING = 60000; +patch(Thread.prototype, { + setup() { + super.setup(); + /** @type {'loaded'|'loading'|'error'|undefined} */ + this.pinnedMessagesState = undefined; + this.pinnedMessages = Thread.Set("Message"); + }, +}); + +patch(MessageModel.prototype, { + setup() { + super.setup(); + /** @type {string} */ + this.pinnedAt = undefined; + }, +}); + export class MessagePin { busService; - /** @type {Map} */ - loadStateByChannelId = new Map(); - /** @type {Map>} */ - messageIdsByChannelId = new Map(); - /** @type {Map} */ - channelIdByMessageId = new Map(); - /** @type {Map} */ - pinnedAtByMessageId = new Map(); /** @type {import("@mail/core/common/store_service").Store} */ - storeService; + store; /** * @param {import("@web/env").OdooEnv} env @@ -33,47 +45,27 @@ export class MessagePin { this.dialogService = services.dialog; this.ormService = services.orm; this.rpcService = services.rpc; - this.storeService = services["mail.store"]; + this.store = services["mail.store"]; } setup() { this.env.bus.addEventListener("mail.message/onUpdate", ({ detail: { message, data } }) => { this.onMessageUpdate(message, data); }); - this.busService.subscribe("mail.message/delete", ({ message_ids }) => { - for (const messageId of message_ids) { - this.removePinnedMessage(messageId); - } - }); this.busService.start(); } /** - * @param {number} channelId - * @param {number} messageId - * @param {string} pinnedAt - */ - addPinnedMessage(channelId, messageId, pinnedAt) { - if (!this.messageIdsByChannelId.has(channelId)) { - this.messageIdsByChannelId.set(channelId, new Set()); - } - const messageIds = this.messageIdsByChannelId.get(channelId); - messageIds.add(messageId); - this.channelIdByMessageId.set(messageId, channelId); - this.pinnedAtByMessageId.set(messageId, pinnedAt); - } - - /** - * @param {import("@mail/core/common/thread_model").Thread} channel + * @param {import("models").Models["Thread"]} channel */ async fetchPinnedMessages(channel) { if ( channel.model !== "discuss.channel" || - ["loaded", "loading"].includes(this.loadStateByChannelId.get(channel.id)) + ["loaded", "loading"].includes(channel.pinnedMessagesState) ) { return; } - this.loadStateByChannelId.set(channel.id, "loading"); + channel.pinnedMessagesState = "loading"; try { const messagesData = await this.rpcService("/discuss/channel/pinned_messages", { channel_id: channel.id, @@ -83,11 +75,11 @@ export class MessagePin { messageData.parentMessage.body = markup(messageData.parentMessage.body); } messageData.body = markup(messageData.body); - this.storeService.Message.insert(messageData); + this.store.Message.insert(messageData); }); - this.loadStateByChannelId.set(channel.id, "loaded"); + channel.pinnedMessagesState = "loaded"; } catch (e) { - this.loadStateByChannelId.set(channel.id, "error"); + channel.pinnedMessagesState = "error"; throw e; } } @@ -97,25 +89,21 @@ export class MessagePin { * @returns {string|null} */ getPinnedAt(messageId) { - const pinnedAt = this.pinnedAtByMessageId.get(messageId); + const pinnedAt = this.store.Message.get(messageId)?.pinnedAt; return pinnedAt ? luxon.DateTime.fromISO(new Date(pinnedAt).toISOString()) : null; } /** - * @param {import("@mail/core/common/thread_model").Thread} channel - * @returns {import("@mail/core/common/message_model").Message[]} + * @param {import("models").Models["Thread"]} channel + * @returns {import("models").Models["Message"][]} */ getPinnedMessages(channel) { - return [...(this.messageIdsByChannelId.get(channel.id) ?? new Set())] - .map((id) => this.storeService.Message.insert({ id })) - .sort((a, b) => { - const aPinnedAt = this.pinnedAtByMessageId.get(a.id); - const bPinnedAt = this.pinnedAtByMessageId.get(b.id); - if (aPinnedAt === bPinnedAt) { - return b.id - a.id; - } - return aPinnedAt < bPinnedAt ? 1 : -1; - }); + return [...channel.pinnedMessages].sort((a, b) => { + if (a.pinnedAt === b.pinnedAt) { + return b.id - a.id; + } + return a.pinnedAt < b.pinnedAt ? 1 : -1; + }); } /** @@ -136,9 +124,11 @@ export class MessagePin { (pinnedAt !== undefined || message.isEmpty) ) { if (pinnedAt && !message.isEmpty) { - this.addPinnedMessage(message.originThread.id, message.id, pinnedAt); + message.originThread.pinnedMessages.add(message); + message.pinnedAt = pinnedAt; } else { - this.removePinnedMessage(message.id); + delete message.pinnedAt; + message.originThread.pinnedMessages.delete(message); } } } @@ -163,25 +153,6 @@ export class MessagePin { }); } - /** - * @param {number} messageId - */ - removePinnedMessage(messageId) { - const channelId = this.channelIdByMessageId.get(messageId); - if (!channelId) { - return; - } - const messageIds = this.messageIdsByChannelId.get(channelId); - if (messageIds) { - messageIds.delete(messageId); - if (messageIds.size === 0) { - this.messageIdsByChannelId.delete(channelId); - } - } - this.channelIdByMessageId.delete(messageId); - this.pinnedAtByMessageId.delete(messageId); - } - /** * @param {import("@mail/core/common/message_model").Message} * @param {boolean} pinned diff --git a/addons/mail/static/tests/messaging_menu/messaging_menu_tests.js b/addons/mail/static/tests/messaging_menu/messaging_menu_tests.js index 9522999d110..b3918381edd 100644 --- a/addons/mail/static/tests/messaging_menu/messaging_menu_tests.js +++ b/addons/mail/static/tests/messaging_menu/messaging_menu_tests.js @@ -37,10 +37,12 @@ QUnit.test("messaging menu should have topbar buttons", async () => { QUnit.test("counter is taking into account failure notification", async () => { patchBrowserNotification("denied"); const pyEnv = await startServer(); - const channelId = pyEnv["discuss.channel"].create({}); + const channelId = pyEnv["discuss.channel"].create({ display_name: "general" }); const messageId = pyEnv["mail.message"].create({ model: "discuss.channel", res_id: channelId, + record_name: "general", + res_model_name: "Channel", }); const [memberId] = pyEnv["discuss.channel.member"].search([ ["channel_id", "=", channelId], diff --git a/addons/mail/tooling/types/models.d.ts b/addons/mail/tooling/types/models.d.ts index 17cff8a25c8..066ffe5ab41 100644 --- a/addons/mail/tooling/types/models.d.ts +++ b/addons/mail/tooling/types/models.d.ts @@ -1,5 +1,6 @@ /* add this file in jsconfig.json, in typeRoots array */ declare module "models" { + import { Activity } from "@mail/core/web/activity_model"; import { Attachment } from "@mail/core/common/attachment_model"; import { CannedResponse } from "@mail/core/common/canned_response_model"; import { ChannelMember } from "@mail/core/common/channel_member_model"; @@ -14,18 +15,28 @@ declare module "models" { import { Thread } from "@mail/core/common/thread_model"; export interface Models { + "Activity": Activity, "Attachment": Attachment, "CannedResponse": CannedResponse, "ChannelMember": ChannelMember, "Composer": Composer, "Follower": Follower, "LinkPreview": LinkPreview, - "Message": Message, + "Message": Message & Partial, "MessageReactions": MessageReactions, "Notification": Notification, "Persona": Persona, "RtcSession": RtcSession, - "Thread": Thread, + "Thread": Thread & Partial, + } + + export interface Message_Patch { + pinnedAt: string, + } + + export interface Thread_Patch { + pinnedMessages: RecordSet, + pinnedMessagesState: "loaded"|"loading"|"error"|undefined, } import { RecordList, RecordSet } from "@mail/core/common/record";