[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<Thread_Patch>,
    }

    export interface Thread_Patch {
        pinnedMessages: RecordSet<Models["Message"]>
    }
}
```

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<Thread_Patch>
message.originThread.pinnedMessages; // RecordList<Messages>
```

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
This commit is contained in:
Alexandre Kühn
2023-09-21 23:54:21 +00:00
parent 4023d523cc
commit b71e1fd22d
27 changed files with 237 additions and 274 deletions
@@ -8,19 +8,19 @@ import { url } from "@web/core/utils/urls";
export class Attachment extends Record {
static id = "id";
/** @type {Object.<number, Attachment>} */
/** @type {Object.<number, import("models").Models["Attachment"]>} */
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)) {
@@ -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));
}
}
/**
@@ -4,19 +4,19 @@ import { Record } from "@mail/core/common/record";
export class CannedResponse extends Record {
static id = "id";
/** @type {Object.<number, CannedResponse>} */
/** @type {Object.<number, import("models").Models["CannedResponse"]>} */
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);
@@ -11,19 +11,19 @@ import { Record } from "@mail/core/common/record";
*/
export class ChannelMember extends Record {
static id = "id";
/** @type {Object.<number, ChannelMember>} */
/** @type {Object.<number, import("models").Models["ChannelMember"]>} */
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;
@@ -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));
@@ -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;
@@ -12,19 +12,19 @@ import { Record } from "@mail/core/common/record";
export class Follower extends Record {
static id = "id";
/** @type {Object.<number, Follower>} */
/** @type {Object.<number, import("models").Models["Follower"]>} */
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);
@@ -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);
@@ -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 });
}
});
@@ -14,19 +14,19 @@ const { DateTime } = luxon;
export class Message extends Record {
static id = "id";
/** @type {Object.<number, Message>} */
/** @type {Object.<number, import("models").Models["Message"]>} */
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) {
@@ -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;
@@ -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) => {
@@ -6,19 +6,19 @@ import { _t } from "@web/core/l10n/translation";
export class Notification extends Record {
static id = "id";
/** @type {Object.<number, Notification>} */
/** @type {Object.<number, import("models").Models["Notification"]>} */
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);
@@ -14,19 +14,19 @@ import { AND, Record } from "@mail/core/common/record";
export class Persona extends Record {
static id = AND("type", "id");
/** @type {Object.<number, Persona>} */
/** @type {Object.<number, import("models").Models["Persona"]>} */
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);
+22 -7
View File
@@ -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++) {
@@ -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<Record>} */
const l1 = r1.__rels__.get(name);
/** @type {Record[]|Set<Record>|RecordList<Record>|RecordSet<Record>} */
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<Record>} */
const l1 = r1.__rels__.get(name);
/** @type {Record[]|Set<Record>|RecordList<Record>|RecordSet<Record>} */
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<Record>|RecordList<Record>|RecordSet<Record>} collection
*/
set(collection) {
const r1 = this;
/** @type {RecordList<Record>} */
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<Record>|RecordList<Record>|RecordSet<Record>} collection
*/
set(collection) {
/** @type {RecordSet<Record>} */
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());
@@ -23,18 +23,18 @@ import { Deferred } from "@web/core/utils/concurrency";
export class Thread extends Record {
static id = AND("model", "id");
/** @type {Object.<string, Thread>} */
/** @type {Object.<string, import("models").Models["Thread"]>} */
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)) {
@@ -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;
}
@@ -35,13 +35,13 @@ import { assignDefined } from "@mail/utils/common/misc";
export class Activity extends Record {
static id = "id";
/** @type {Object.<number, Activity>} */
/** @type {Object.<number, import("models").Models["Activity"]>} */
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);
@@ -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) });
@@ -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") {
@@ -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) {
@@ -4,19 +4,19 @@ import { Record } from "@mail/core/common/record";
export class RtcSession extends Record {
static id = "id";
/** @type {Object.<number, RtcSession>} */
/** @type {Object.<number, import("models").Models["RtcSession"]>} */
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);
@@ -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) {
@@ -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<number, string>} */
loadStateByChannelId = new Map();
/** @type {Map<number, Set<number>>} */
messageIdsByChannelId = new Map();
/** @type {Map<number, number>} */
channelIdByMessageId = new Map();
/** @type {Map<number, string>} */
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
@@ -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],
+13 -2
View File
@@ -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<Message_Patch>,
"MessageReactions": MessageReactions,
"Notification": Notification,
"Persona": Persona,
"RtcSession": RtcSession,
"Thread": Thread,
"Thread": Thread & Partial<Thread_Patch>,
}
export interface Message_Patch {
pinnedAt: string,
}
export interface Thread_Patch {
pinnedMessages: RecordSet<Models["Message"]>,
pinnedMessagesState: "loaded"|"loading"|"error"|undefined,
}
import { RecordList, RecordSet } from "@mail/core/common/record";