[REF] mail: set data immediately on discuss model relational field

With this commit, instead of `Record.insert()` before setting
relation with records, we can immediately pass record data.
For example:
```js
message.author = { id: 3, type: "partner", name: "Admin" };
thread.messages.add({ id: 10, body: "some-text-content" })
```

This is supported on all relational fields that define a target
model.

To make this work while drastically avoiding cyclic dependencies
in code, whenever data have to be inserted in relation, they are
pre-inserted with essential data, and then they are fully inserted
after being registered in the relation.

closes odoo/odoo#136539

Related: odoo/enterprise#47854
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Alexandre Kühn
2023-10-02 11:42:55 +00:00
parent 485bcb82bf
commit b526078ae9
50 changed files with 451 additions and 486 deletions
@@ -8,8 +8,11 @@ patch(ChatWindow.prototype, {
async close(options) {
const thread = this.thread;
await super.close(options);
if (thread?.type === "livechat" && thread.isLoaded && thread.messages.length === 0) {
this.threadService.unpin(thread);
if (thread?.type === "livechat") {
await thread?.isLoadedDeferred;
if (thread.messages.length === 0) {
this.threadService.unpin(thread);
}
}
},
});
+3
View File
@@ -2,4 +2,7 @@ declare module "models" {
export interface DiscussApp {
livechat: DiscussAppCategory,
}
export interface Thread {
operator: Persona,
}
}
@@ -9,7 +9,7 @@ import { patch } from "@web/core/utils/patch";
patch(DiscussApp, {
new(data) {
const res = super.new(data);
res.livechat = this.store.DiscussAppCategory.insert({
res.livechat = {
extraClass: "o-mail-DiscussSidebarCategory-livechat",
id: "livechat",
name: _t("Livechat"),
@@ -17,7 +17,7 @@ patch(DiscussApp, {
canView: false,
canAdd: false,
serverStateKey: "is_discuss_sidebar_category_livechat_open",
});
};
return res;
},
});
@@ -1,6 +1,7 @@
/* @odoo-module */
import { DEFAULT_AVATAR } from "@mail/core/common/persona_service";
import { Record } from "@mail/core/common/record";
import { Thread } from "@mail/core/common/thread_model";
import { assignDefined } from "@mail/utils/common/misc";
@@ -8,29 +9,30 @@ import { patch } from "@web/core/utils/patch";
patch(Thread, {
insert(data) {
const isUnknown = !this.get(data);
const thread = super.insert(data);
if (thread.type === "livechat") {
if (data?.channel) {
assignDefined(thread, data.channel, ["anonymous_name"]);
}
if (data?.operator_pid) {
thread.operator = this.store.Persona.insert({
thread.operator = {
type: "partner",
id: data.operator_pid[0],
displayName: data.operator_pid[1],
});
}
if (isUnknown) {
this.store.discuss.livechat.threads.push(thread);
this.env.services["mail.thread"].sortChannels();
};
}
this.store.discuss.livechat.threads.add(thread);
this.env.services["mail.thread"].sortChannels();
}
return thread;
},
});
patch(Thread.prototype, {
setup() {
super.setup();
this.operator = Record.one("Persona");
},
get typesAllowingCalls() {
return super.typesAllowingCalls.concat(["livechat"]);
},
@@ -131,9 +131,7 @@ export class ChatBotService {
channel_uuid: this.livechatService.thread.uuid,
chatbot_script_id: this.chatbot.scriptId,
});
this.livechatService.thread?.messages.push(
this.store.Message.insert({ ...message, body: markup(message.body) })
);
this.livechatService.thread?.messages.push({ ...message, body: markup(message.body) });
this.currentStep = null;
this.start();
}
@@ -147,11 +145,10 @@ export class ChatBotService {
chatbot_script_id: this.chatbot.scriptId,
});
for (const rawMessage of rawMessages) {
const message = this.store.Message.insert({
this.livechatService.thread?.messages.add({
...rawMessage,
body: markup(rawMessage.body),
});
this.livechatService.thread?.messages.add(message);
}
this.hasPostedWelcomeSteps = true;
}
@@ -180,11 +177,10 @@ export class ChatBotService {
return;
}
if (stepMessage) {
const message = this.store.Message.insert({
this.livechatService.thread?.messages.add({
...stepMessage,
body: markup(stepMessage.body),
});
this.livechatService.thread?.messages.add(message);
}
this.currentStep = step;
if (
@@ -281,10 +277,8 @@ export class ChatBotService {
channel_uuid: this.livechatService.thread.uuid,
});
this.currentStep.isEmailValid = success;
if (msg && !this.livechatService.thread.messages.some((m) => m.id === msg.id)) {
this.livechatService.thread.messages.push(
this.store.Message.insert({ ...msg, body: markup(msg.body) })
);
if (msg) {
this.livechatService.thread.messages.add({ ...msg, body: markup(msg.body) });
}
}
@@ -0,0 +1,6 @@
declare module "models" {
export interface Thread {
chatbotTypingMessage: Message,
livechatWelcomeMessage: Message,
}
}
@@ -11,10 +11,10 @@ patch(Messaging.prototype, {
return super.initialize();
}
if (session.livechatData?.options.current_partner_id) {
this.store.user = this.store.Persona.insert({
this.store.user = {
type: "partner",
id: session.livechatData.options.current_partner_id,
});
};
}
this.store.isMessagingReady = true;
this.isReady.resolve({
@@ -1,5 +1,6 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { Thread } from "@mail/core/common/thread_model";
import { onChange } from "@mail/utils/common/misc";
@@ -25,20 +26,20 @@ patch(Thread, {
}
});
if (chatbotService.isChatbotThread(thread)) {
thread.chatbotTypingMessage = this.store.Message.insert({
thread.chatbotTypingMessage = {
id: messageService.getNextTemporaryId(),
res_id: thread.id,
model: thread.model,
author: thread.operator,
});
};
} else {
thread.livechatWelcomeMessage = this.store.Message.insert({
thread.livechatWelcomeMessage = {
id: messageService.getNextTemporaryId(),
body: livechatService.options.default_message,
res_id: thread.id,
model: thread.model,
author: thread.operator,
});
};
}
}
return thread;
@@ -48,14 +49,19 @@ patch(Thread, {
patch(Thread.prototype, {
chatbotScriptId: null,
setup() {
super.setup();
this.chatbotTypingMessage = Record.one("Message");
this.livechatWelcomeMessage = Record.one("Message");
},
update(data) {
super.update(...arguments);
if (data.operator_pid) {
this.operator = this._store.Persona.insert({
this.operator = {
type: "partner",
id: data.operator_pid[0],
name: data.operator_pid[1],
});
};
}
},
@@ -64,7 +64,7 @@ patch(ThreadService.prototype, {
},
avatarUrl(author, thread) {
if (thread.type !== "livechat") {
if (thread?.type !== "livechat") {
return super.avatarUrl(...arguments);
}
const isFromOperator =
@@ -5,7 +5,7 @@ import { startServer } from "@bus/../tests/helpers/mock_python_environment";
import { loadDefaultConfig, start } from "@im_livechat/../tests/embed/helper/test_utils";
import { LivechatButton } from "@im_livechat/embed/core_ui/livechat_button";
import { mockTimeout, triggerHotkey } from "@web/../tests/helpers/utils";
import { mockTimeout, nextTick, triggerHotkey } from "@web/../tests/helpers/utils";
import { click, contains, insertText } from "@web/../tests/utils";
QUnit.module("livechat session");
@@ -57,6 +57,7 @@ QUnit.test("Seen message is saved on the session", async (assert) => {
await insertText(".o-mail-Composer-input", "Hello World!");
triggerHotkey("Enter");
await contains(".o-mail-Message", { count: 2 });
await nextTick(); // wait for message seen
assert.strictEqual(
env.services["im_livechat.livechat"].sessionCookie.seen_message_id,
env.services["im_livechat.livechat"].thread.newestMessage.id
@@ -11,10 +11,6 @@ export class Attachment extends Record {
/** @type {Object.<number, import("models").Attachment>} */
static records = {};
/** @returns {import("models").Attachment} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").Attachment} */
static get(data) {
return super.get(data);
}
@@ -26,7 +22,8 @@ export class Attachment extends Record {
if (!("id" in data)) {
throw new Error("Cannot insert attachment: id is missing in data");
}
const attachment = this.get(data) ?? this.new(data);
/** @type {import("models").Attachment} */
const attachment = this.preinsert(data);
Object.assign(attachment, { id: data.id });
attachment.update(data);
return attachment;
@@ -55,10 +52,10 @@ export class Attachment extends Record {
const threadData = Array.isArray(data.originThread)
? data.originThread[0][1]
: data.originThread;
this.originThread = this._store.Thread.insert({
this.originThread = {
model: threadData.model,
id: threadData.id,
});
};
const thread = this.originThread;
thread.attachments.add(this);
thread.attachments.sort((a1, a2) => (a1.id < a2.id ? 1 : -1));
@@ -14,7 +14,7 @@ export class AutoresizeInput extends Component {
enabled: { optional: true },
onValidate: { type: Function, optional: true },
placeholder: { type: String, optional: true },
value: { type: String },
value: { type: String, optional: true },
};
static defaultProps = {
autofocus: false,
@@ -7,10 +7,6 @@ export class CannedResponse extends Record {
/** @type {Object.<number, import("models").CannedResponse>} */
static records = {};
/** @returns {import("models").CannedResponse} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").CannedResponse} */
static get(data) {
return super.get(data);
}
@@ -19,7 +15,8 @@ export class CannedResponse extends Record {
* @returns {import("models").CannedResponse}
*/
static insert(data) {
const cannedResponse = this.get(data) ?? this.new(data);
/** @type {import("models").CannedResponse} */
const cannedResponse = this.preinsert(data);
Object.assign(cannedResponse, {
id: data.id,
name: data.source,
@@ -14,10 +14,6 @@ export class ChannelMember extends Record {
/** @type {Object.<number, import("models").ChannelMember>} */
static records = {};
/** @returns {import("models").ChannelMember} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").ChannelMember} */
static get(data) {
return super.get(data);
}
@@ -27,7 +23,8 @@ export class ChannelMember extends Record {
*/
static insert(data) {
const memberData = Array.isArray(data) ? data[1] : data;
const member = this.get(memberData) ?? this.new(memberData);
/** @type {import("models").ChannelMember} */
const member = this.preinsert(memberData);
member.update(data);
return member;
}
@@ -36,19 +33,19 @@ export class ChannelMember extends Record {
const [command, memberData] = Array.isArray(data) ? data : ["ADD", data];
this.id = memberData.id;
if ("persona" in memberData) {
this.persona = this._store.Persona.insert({
this.persona = {
...(memberData.persona.partner ?? memberData.persona.guest),
type: memberData.persona.guest ? "guest" : "partner",
country: memberData.persona.partner?.country,
channelId: memberData.persona.guest ? memberData.channel.id : null,
});
};
}
let thread = memberData.thread ?? this.thread;
if (!thread && memberData.channel?.id) {
thread = this._store.Thread.insert({
thread = {
id: memberData.channel.id,
model: "discuss.channel",
});
};
}
if (thread && !this.thread) {
this.thread = thread;
@@ -12,10 +12,6 @@ export class ChatWindow extends Record {
/** @type {Object<number, import("models").ChatWindow} */
static records = {};
/** @returns {import("models").ChatWindow} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").ChatWindow} */
static get(data) {
return super.get(data);
}
@@ -26,7 +22,8 @@ export class ChatWindow extends Record {
static insert(data = {}) {
const chatWindow = this.store.discuss.chatWindows.find((c) => c.thread?.eq(data.thread));
if (!chatWindow) {
const chatWindow = this.new(data);
/** @type {import("models").ChatWindow} */
const chatWindow = this.preinsert(data);
Object.assign(chatWindow, { thread: data.thread });
assignDefined(chatWindow, data);
let index;
@@ -54,7 +51,7 @@ export class ChatWindow extends Record {
data.replaceNewMessageChatWindow ? 1 : 0,
chatWindow
);
return this.store.discuss.chatWindows[index]; // return reactive version
return chatWindow; // return reactive version
}
if (chatWindow.hidden) {
this.env.services["mail.chat_window"].makeVisible(chatWindow);
@@ -9,10 +9,6 @@ import { OR, Record } from "@mail/core/common/record";
export class Composer extends Record {
static id = OR("thread", "message");
/** @returns {import("models").Composer} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").Composer} */
static get(data) {
return super.get(data);
}
@@ -27,7 +23,8 @@ export class Composer extends Record {
}
let composer = (thread ?? message)?.composer;
if (!composer) {
composer = this.new(data);
/** @type {import("models").Composer} */
composer = this.preinsert(data);
const { message, thread } = data;
if (thread) {
composer.thread = thread;
@@ -6,16 +6,13 @@ import { Record } from "./record";
export class DiscussAppCategory extends Record {
static id = "id";
/** @returns {import("models").DiscussAppCategory} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").DiscussAppCategory} */
static get(data) {
return super.get(data);
}
/** @returns {import("models").DiscussAppCategory} */
static insert(data) {
const category = this.get(data) ?? this.new(data);
/** @type {import("models").DiscussAppCategory} */
const category = this.preinsert(data);
assignDefined(category, data);
return category;
}
@@ -4,11 +4,11 @@ import { _t } from "@web/core/l10n/translation";
import { Record } from "./record";
export class DiscussApp extends Record {
/** @returns {import("models").DiscussApp} */
static new(data) {
/** @type {import("models").DiscussApp} */
const res = super.new(data);
Object.assign(res, {
channels: this.store.DiscussAppCategory.insert({
channels: {
extraClass: "o-mail-DiscussSidebarCategory-channel",
id: "channels",
name: _t("Channels"),
@@ -18,8 +18,8 @@ export class DiscussApp extends Record {
serverStateKey: "is_discuss_sidebar_category_channel_open",
addTitle: _t("Add or join a channel"),
addHotkey: "c",
}),
chats: this.store.DiscussAppCategory.insert({
},
chats: {
extraClass: "o-mail-DiscussSidebarCategory-chat",
id: "chats",
name: _t("Direct messages"),
@@ -29,7 +29,7 @@ export class DiscussApp extends Record {
serverStateKey: "is_discuss_sidebar_category_chat_open",
addTitle: _t("Start a conversation"),
addHotkey: "d",
}),
},
});
return res;
}
@@ -38,9 +38,8 @@ export class DiscussApp extends Record {
return super.get(data);
}
/** @returns {import("models").DiscussApp} */
static insert() {
const app = this.get() ?? this.new();
return app;
static insert(data) {
return super.insert(data);
}
/** @type {'mailbox'|'all'|'channel'|'chat'|'livechat'} */
@@ -15,10 +15,6 @@ export class Follower extends Record {
/** @type {Object.<number, import("models").Follower>} */
static records = {};
/** @returns {import("models").Follower} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").Follower} */
static get(data) {
return super.get(data);
}
@@ -27,12 +23,13 @@ export class Follower extends Record {
* @returns {import("models").Follower}
*/
static insert(data) {
const follower = this.get(data) ?? this.new(data);
/** @type {import("models").Follower} */
const follower = this.preinsert(data);
Object.assign(follower, {
followedThread: data.followedThread,
id: data.id,
isActive: data.is_active,
partner: this.store.Persona.insert({ ...data.partner, type: "partner" }),
partner: { ...data.partner, type: "partner" },
});
return follower;
}
@@ -5,10 +5,6 @@ import { Record } from "@mail/core/common/record";
export class LinkPreview extends Record {
static id = "id";
/** @returns {import("models").LinkPreview} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").LinkPreview} */
static get(data) {
return super.get(data);
}
@@ -20,13 +16,10 @@ export class LinkPreview extends Record {
const message = this.store.Message.get(data.message_id);
data.message = message;
delete data.message_id;
let linkPreview = message?.linkPreviews.find((lp) => lp.id === data.id);
if (linkPreview) {
return Object.assign(linkPreview, data);
}
linkPreview = this.new(data);
/** @type {import("models").LinkPreview} */
const linkPreview = this.preinsert(data);
Object.assign(linkPreview, data);
message?.linkPreviews.push(linkPreview);
message?.linkPreviews.add(linkPreview);
return linkPreview;
}
@@ -1,7 +1,6 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { replaceArrayWithCompare } from "@mail/utils/common/arrays";
import { htmlToTextContentInline } from "@mail/utils/common/format";
import { assignDefined } from "@mail/utils/common/misc";
@@ -19,10 +18,6 @@ export class Message extends Record {
/** @type {Object.<number, import("models").Message>} */
static records = {};
/** @returns {import("models").Message} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").Message} */
static get(data) {
return super.get(data);
}
@@ -37,7 +32,8 @@ export class Message extends Record {
id: data.res_id,
});
}
const message = this.get(data) ?? this.new(data);
/** @type {import("models").Message} */
const message = this.preinsert(data);
message.update(data);
return message;
}
@@ -72,7 +68,7 @@ export class Message extends Record {
? this.starred_partner_ids.includes(this._store.user.id)
: false,
isTransient,
parentMessage: parentMessage ? this._store.Message.insert(parentMessage) : undefined,
parentMessage: parentMessage || undefined,
resId,
resModel,
subtypeDescription,
@@ -89,48 +85,23 @@ export class Message extends Record {
: record_name || undefined,
});
}
replaceArrayWithCompare(
this.attachments,
attachments.map((attachment) =>
this._store.Attachment.insert({ message: this, ...attachment })
)
);
this.attachments = attachments.map((attachment) => ({ message: this, ...attachment }));
if (data.author?.id) {
this.author = this._store.Persona.insert({
...data.author,
type: "partner",
});
this.author = { ...data.author, type: "partner" };
}
if (data.guestAuthor?.id) {
this.author = this._store.Persona.insert({
...data.guestAuthor,
type: "guest",
channelId: this.originThread.id,
});
this.author = { ...data.guestAuthor, type: "guest", channelId: this.originThread.id };
}
replaceArrayWithCompare(
this.linkPreviews,
linkPreviews.map((data) => this._store.LinkPreview.insert({ ...data, message: this }))
);
replaceArrayWithCompare(
this.notifications,
notifications.map((notification) =>
this._store.Notification.insert({ ...notification, message: this })
)
);
replaceArrayWithCompare(
this.recipients,
recipients.map((recipient) =>
this._store.Persona.insert({ ...recipient, type: "partner" })
)
);
this.linkPreviews = linkPreviews.map((data) => ({ ...data, message: this }));
this.notifications = notifications.map((notif) => ({ ...notif, message: this }));
this.recipients = recipients.map((recipient) => ({ ...recipient, type: "partner" }));
if ("user_follower_id" in data && data.user_follower_id && this._store.self) {
this.originThread.selfFollower = this._store.Follower.insert({
this.originThread.selfFollower = {
followedThread: this.originThread,
id: data.user_follower_id,
isActive: true,
partner: this._store.self,
});
};
}
if (data.messageReactionGroups) {
const reactionContentToUnlink = new Set();
@@ -5,10 +5,6 @@ import { AND, Record } from "@mail/core/common/record";
export class MessageReactions extends Record {
static id = AND("message", "content");
/** @returns {import("models").MessageReactions} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").MessageReactions} */
static get(data) {
return super.get(data);
}
@@ -21,7 +17,8 @@ export class MessageReactions extends Record {
({ content }) => content === data.content
);
if (!reaction) {
reaction = this.new(data);
/** @type {import("models").MessageReactions} */
reaction = this.preinsert(data);
}
const personasToUnlink = new Set();
const alreadyKnownPersonaIds = new Set(reaction.personas.map((p) => p.localId));
@@ -48,7 +45,7 @@ export class MessageReactions extends Record {
Object.assign(reaction, {
count: data.count,
content: data.content,
message: this.store.Message.insert(data.message),
message: data.message,
personas: reaction.personas.filter((p) => !personasToUnlink.has(p)),
});
return reaction;
@@ -30,26 +30,26 @@ export class Messaging {
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 = this.store.Thread.insert({
this.store.discuss.inbox = {
id: "inbox",
model: "mail.box",
name: _t("Inbox"),
type: "mailbox",
});
this.store.discuss.starred = this.store.Thread.insert({
};
this.store.discuss.starred = {
id: "starred",
model: "mail.box",
name: _t("Starred"),
type: "mailbox",
counter: 0,
});
this.store.discuss.history = this.store.Thread.insert({
};
this.store.discuss.history = {
id: "history",
model: "mail.box",
name: _t("History"),
type: "mailbox",
counter: 0,
});
};
this.updateImStatusRegistration();
}
@@ -64,22 +64,16 @@ export class Messaging {
initMessagingCallback(data) {
if (data.current_partner) {
this.store.user = this.store.Persona.insert({
...data.current_partner,
type: "partner",
});
this.store.user = { ...data.current_partner, type: "partner" };
}
if (data.currentGuest) {
this.store.guest = this.store.Persona.insert({
this.store.guest = {
...data.currentGuest,
type: "guest",
channelId: data.channels[0]?.id,
});
};
}
this.store.odoobot = this.store.Persona.insert({
...data.odoobot,
type: "partner",
});
this.store.odoobot = { ...data.odoobot, type: "partner" };
const settings = data.current_user_settings;
this.userSettingsService.updateFromCommands(settings);
this.userSettingsService.id = settings.id;
@@ -10,10 +10,6 @@ export class NotificationGroup extends Record {
/** @type {Object.<number, import("models").NotificationGroup>} */
static records = {};
/** @returns {import("models").NotificationGroup} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").NotificationGroup} */
static get(data) {
return super.get(data);
}
@@ -31,7 +27,8 @@ export class NotificationGroup extends Record {
});
if (!group) {
const id = nextId++;
group = this.new({ id });
/** @type {import("models").NotificationGroup} */
group = this.preinsert({ id });
Object.assign(group, { id });
this.store.discuss.notificationGroups.add(group);
}
@@ -9,10 +9,6 @@ export class Notification extends Record {
/** @type {Object.<number, import("models").Notification>} */
static records = {};
/** @returns {import("models").Notification} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").Notification} */
static get(data) {
return super.get(data);
}
@@ -21,7 +17,8 @@ export class Notification extends Record {
* @returns {import("models").Notification}
*/
static insert(data) {
const notification = this.get(data) ?? this.new(data);
/** @type {import("models").Notification} */
const notification = this.preinsert(data);
Object.assign(notification, { id: data.id });
notification.update(data);
return notification;
@@ -34,11 +31,11 @@ export class Notification extends Record {
notification_type: data.notification_type,
failure_type: data.failure_type,
persona: data.res_partner_id
? this._store.Persona.insert({
? {
id: data.res_partner_id[0],
displayName: data.res_partner_id[1],
type: "partner",
})
}
: undefined,
});
if (!this.message.author?.eq(this._store.self)) {
@@ -18,10 +18,6 @@ export class Persona extends Record {
/** @type {Object.<number, import("models").Persona>} */
static records = {};
/** @returns {import("models").Persona} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").Persona} */
static get(data) {
return super.get(data);
}
@@ -30,10 +26,7 @@ export class Persona extends Record {
* @returns {import("models").Persona}
*/
static insert(data) {
const persona = this.get(data) ?? this.new(data);
persona.update(data);
// return reactive version
return persona;
return super.insert(data);
}
update(data) {
+121 -42
View File
@@ -17,6 +17,29 @@ export function OR(...args) {
return [OR_SYM, ...args];
}
/**
* @param {R|any} val
* @param {Record} record
* @param {string} fname
* @param {(R) => void} fn
*/
export function preinsert(val, record, fname, fn) {
/** @type {R} */
let r3;
if (!(val instanceof Record)) {
const { targetModel } = record.Model.__rels__.get(fname);
r3 = record.Model.store[targetModel].preinsert(val);
} else {
r3 = val;
}
fn(r3);
if (!(val instanceof Record)) {
// was preinserted, fully insert now
const { targetModel } = record.Model.__rels__.get(fname);
record.Model.store[targetModel].insert(val);
}
}
export class RecordInverses {
/**
* Track the inverse of a record. Each record contains this map.
@@ -102,16 +125,16 @@ export class RecordList extends Array {
if (typeof name !== "symbol" && !window.isNaN(parseInt(name))) {
// support for "array[index] = r3" syntax
const index = parseInt(name);
/** @type {R} */
const r3 = val;
const r2 = receiver[index];
if (r2 && r2.notEq(r3)) {
receiver.__deleteInverse__(r2);
}
receiver.__list__[index] = r3?.localId;
if (r3) {
receiver.__addInverse__(r3);
}
receiver._preinsert(val, (r3) => {
const r2 = receiver[index];
if (r2 && r2.notEq(r3)) {
receiver.__deleteInverse__(r2);
}
receiver.__list__[index] = r3?.localId;
if (r3) {
receiver.__addInverse__(r3);
}
});
} else if (name === "length") {
const newLength = parseInt(val);
if (newLength < receiver.length) {
@@ -125,7 +148,13 @@ export class RecordList extends Array {
},
});
}
/**
* @param {R|any} val
* @param {(R) => void} fn
*/
_preinsert(val, fn) {
preinsert(val, this.owner, this.name, fn);
}
/**
* @param {number} index
* @returns {R}
@@ -135,11 +164,11 @@ export class RecordList extends Array {
}
/** @param {R[]} records */
push(...records) {
this.__list__.push(...records.map((r3) => r3.localId));
for (const r3 of records) {
if (r3) {
for (const val of records) {
this._preinsert(val, (r3) => {
this.__list__.push(r3.localId);
this.__addInverse__(r3);
}
});
}
return this.__list__.length;
}
@@ -161,11 +190,11 @@ export class RecordList extends Array {
}
/** @param {R[]} records */
unshift(...records) {
this.__list__.unshift(...records.map((r3) => r3.localId));
for (const r3 of records) {
if (r3) {
for (const val of records) {
this._preinsert(val, (r3) => {
this.__list__.unshift(r3.localId);
this.__addInverse__(r3);
}
});
}
return this.__list__.length;
}
@@ -251,20 +280,31 @@ export class RecordList extends Array {
.map((localId) => this.__store__.get(localId))
.concat(...collections.map((c) => [...c]));
}
/** @param {R} */
add(r) {
if (this.indexOf(r) !== -1) {
return;
/** @param {...R} */
add(...records) {
for (const val of records) {
this._preinsert(val, (r) => {
if (this.indexOf(r) === -1) {
this.push(r);
}
});
}
this.push(r);
}
/** @param {R} */
delete(r) {
const index = this.indexOf(r);
if (index === -1) {
return;
/** @param {...R} */
delete(...records) {
for (const val of records) {
this._preinsert(val, (r) => {
const index = this.indexOf(r);
if (index !== -1) {
this.splice(index, 1);
}
});
}
}
clear() {
while (this.__list__.length > 0) {
this.pop();
}
this.splice(index, 1);
}
/** @yields {R} */
*[Symbol.iterator]() {
@@ -306,7 +346,7 @@ export class Record {
}
static _localId(expr, data, { brackets = false } = {}) {
if (!Array.isArray(expr)) {
if (this.Class.__rels__.has(expr)) {
if (this.__rels__.has(expr)) {
// relational field (note: optional when OR)
return `(${data[expr]?.localId})`;
}
@@ -322,6 +362,35 @@ export class Record {
}
return res;
}
static _retrieveIdFromData(data) {
const res = {};
function _deepRetrieve(expr2) {
if (typeof expr2 === "string") {
return Object.assign(res, { [expr2]: data[expr2] });
}
if (expr2 instanceof Array) {
for (const expr of this.id) {
if (typeof expr === "symbol") {
continue;
}
_deepRetrieve(expr);
}
}
}
if (this.id === undefined) {
return res;
}
if (typeof this.id === "string") {
return { [this.id]: data[this.id] };
}
for (const expr of this.id) {
if (typeof expr === "symbol") {
continue;
}
_deepRetrieve(expr);
}
return res;
}
/**
* Technical attribute, DO NOT USE in business code.
* This class is almost equivalent to current class of model,
@@ -340,7 +409,8 @@ export class Record {
*/
static new(data) {
const obj = new this.Class();
let record = Object.assign(obj, { localId: this.localId(data), Model: this });
const ids = this._retrieveIdFromData(data);
let record = Object.assign(obj, { Model: this, localId: this.localId(data), ...ids });
Object.assign(record, { _store: this.store });
this.records[record.localId] = record;
// return reactive version
@@ -353,7 +423,7 @@ export class Record {
* @returns {import("models").Models[M]}
*/
static one(modelName) {
return ONE_SYM;
return [ONE_SYM, modelName];
}
/**
* @template {keyof import("model").Models} M
@@ -361,13 +431,24 @@ export class Record {
* @returns {import("models").Models[M][]}
*/
static many(modelName) {
return MANY_SYM;
return [MANY_SYM, modelName];
}
/**
* @param {Object} data
* @returns {Record}
*/
static insert(data) {}
static insert(data) {
const res = this.preinsert(data);
res.update(data);
return res;
}
/**
* @param {Object} data
* @returns {Record}
*/
static preinsert(data) {
return this.get(data) ?? this.new(data);
}
/**
* Raw relational values of the record, each of which contains object id(s)
@@ -377,7 +458,7 @@ export class Record {
* @type {Map<string, string|RecordList>}
*/
__rels__ = new Map();
/** Track inverse relations of current record. */
/** @type {Map<string, { targetModel: string }>} */
__invs__ = new RecordInverses();
/** @type {import("@mail/core/common/store_service").Store} */
_store;
@@ -405,14 +486,12 @@ export class Record {
setup() {}
update(data) {}
delete() {
const r1 = this;
for (const [name, l1] of r1.__rels__.entries()) {
if (l1 instanceof RecordList) {
r1[name] = [];
} else {
r1[name] = undefined;
}
for (const name of r1.__rels__.keys()) {
r1[name] = undefined;
}
for (const [localId, names] of r1.__invs__.__map__.entries()) {
for (const [name2, count] of names.entries()) {
@@ -6,12 +6,12 @@ import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { debounce } from "@web/core/utils/timing";
import { modelRegistry, Record, RecordInverses, RecordList } from "./record";
import { modelRegistry, preinsert, Record, RecordInverses, RecordList } from "./record";
export class Store extends Record {
/** @returns {import("models").Store} */
static insert() {
return this.get() ?? this.new();
return super.insert();
}
/** @type {typeof import("@mail/core/web/activity_model").Activity} */
@@ -50,18 +50,10 @@ export class Store extends Record {
Thread;
lastChannelSubscription = "";
/**
* This is the current logged partner
*
* @type {import("models").Persona}
*/
user = null;
/**
* This is the current logged guest
*
* @type {import("models").Persona}
*/
guest = null;
/** This is the current logged partner */
user = Record.one("Persona");
/** This is the current logged guest */
guest = Record.one("Persona");
/**
* The last id of bus notification at the time for fetch init_messaging.
* When receiving a notification:
@@ -77,8 +69,7 @@ export class Store extends Record {
*/
inPublicPage = false;
companyName = "";
/** @type {import("models").Persona} */
odoobot = null;
odoobot = Record.one("Persona");
odoobotOnboarding;
users = {};
internalUserGroupId = null;
@@ -145,9 +136,9 @@ export const storeService = {
},
};
const Models = {};
for (const [name, _Model] of modelRegistry.getEntries()) {
for (const [name, _OgClass] of modelRegistry.getEntries()) {
/** @type {typeof Record} */
const Model = _Model;
const OgClass = _OgClass;
if (res.store[name]) {
throw new Error(
`There must be no duplicated Model Names (duplicate found: ${name})`
@@ -156,27 +147,22 @@ export const storeService = {
// 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 entry = Object.assign(Object.create(Model), { env, store: res.store });
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
let detecting = true;
const Class = {
[Model.name]: class extends Model {
static __rels__ = new Set();
[OgClass.name]: class extends OgClass {
constructor() {
super();
if (detecting) {
return;
}
for (const name of this.constructor.__rels__) {
for (const name of Model.__rels__.keys()) {
// Relational fields contain symbols for detection in original class.
// This constructor is called on genuine records:
// - 'one' fields => undefined
// - 'many' fields => RecordList
let newVal;
if (this[name] === Record.one()) {
if (this[name]?.[0] === Record.one()[0]) {
newVal = undefined;
}
if (this[name] === Record.many()) {
if (this[name]?.[0] === Record.many()[0]) {
newVal = new RecordList();
newVal.__store__ = res.store;
newVal.name = name;
@@ -213,63 +199,68 @@ export const storeService = {
},
/** @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>} */
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) {
if (!receiver.__rels__.has(name)) {
Reflect.set(target, name, val, receiver);
return true;
}
const oldVal = receiver.__rels__.get(name);
if (oldVal instanceof RecordList) {
// [Record.many] =
const r1 = receiver;
/** @type {RecordList<Record>} */
const l1 = r1.__rels__.get(name);
/** @type {Record[]|Set<Record>|RecordList<Record>} */
const collection = val;
const oldRecords = l1.slice();
for (const r2 of oldRecords) {
r2.__invs__.delete(r1.localId, name);
}
l1.clear();
if ([null, false, undefined].includes(val)) {
return true;
}
for (const v of collection) {
preinsert(v, r1, name, (r3) => {
l1.__list__.push(r3.localId);
r3.__invs__.add(r1.localId, name);
}
} else {
const r1 = receiver;
const l1 = r1.__rels__.get(name);
const r2 = res.store.get(l1);
/** @type {Record} */
const r3 = val;
if (r2 && r2.notEq(r3)) {
r2.__invs__.delete(r1.localId, name);
}
if (r3) {
r1.__rels__.set(name, r3?.localId);
if (!(r3 instanceof Record)) {
return true; // not a record, ignored
}
r3.__invs__.add(r1.localId, name);
} else {
delete r1[name];
}
});
}
} else {
Reflect.set(target, name, val, receiver);
// [Record.one] =
const r1 = receiver;
const l1 = r1.__rels__.get(name);
const r2 = res.store.get(l1);
if (r2) {
r2.__invs__.delete(r1.localId, name);
}
if ([null, false, undefined].includes(val)) {
delete receiver[name];
return true;
}
preinsert(val, r1, name, (r3) => {
r1.__rels__.set(name, r3?.localId);
});
}
return true;
},
});
}
},
}[Model.name];
entry.Class = Class;
entry.records = JSON.parse(JSON.stringify(Model.records));
Models[name] = entry;
res.store[name] = entry;
}[OgClass.name];
Object.assign(Model, {
Class,
records: JSON.parse(JSON.stringify(OgClass.records)),
__rels__: new Map(),
});
Models[name] = Model;
res.store[name] = Model;
// Detect relational fields with a dummy record and setup getter/setters on them
const obj = new Model();
detecting = false;
const obj = new OgClass();
for (const [name, val] of Object.entries(obj)) {
if (![Record.one(), Record.many()].includes(val)) {
if (![Record.one()[0], Record.many()[0]].includes(val?.[0])) {
continue;
}
Class.__rels__.add(name);
Model.__rels__.set(name, { targetModel: val[1] });
}
}
// Make true store (as a model)
@@ -280,7 +271,7 @@ export const storeService = {
res.store[Model.name] = Model;
}
const store = res.store;
store.discuss = store.DiscussApp.insert();
store.discuss = {};
store.discuss.activeTab = env.services.ui.isSmall ? "mailbox" : "all";
onChange(store.Thread, "records", () => store.updateBusSubscription());
services.ui.bus.addEventListener("resize", () => {
+22 -19
View File
@@ -9,15 +9,7 @@ import {
useVisible,
} from "@mail/utils/common/hooks";
import {
Component,
onMounted,
onWillStart,
onWillUpdateProps,
useEffect,
useRef,
useState,
} from "@odoo/owl";
import { Component, onMounted, onWillUpdateProps, useEffect, useRef, useState } from "@odoo/owl";
import { Transition } from "@web/core/transition";
import { useBus, useService } from "@web/core/utils/hooks";
@@ -57,7 +49,11 @@ export class Thread extends Component {
setup() {
this.escape = escape;
this.store = useState(useService("mail.store"));
this.state = useState({ isReplyingTo: false, showJumpPresent: false });
this.state = useState({
isReplyingTo: false,
mountedAndLoaded: false,
showJumpPresent: false,
});
this.threadService = useState(useService("mail.thread"));
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
useAutoScroll("messages", () => {
@@ -140,16 +136,23 @@ export class Thread extends Component {
},
() => [this.props.jumpPresent]
);
onMounted(() => {
this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id;
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
this.scrollPosition.restore();
this.updateShowJumpPresent();
}
});
onWillStart(() => {
useEffect(
() => {
if (!this.state.mountedAndLoaded) {
return;
}
this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id;
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
this.scrollPosition.restore();
this.updateShowJumpPresent();
}
},
() => [this.state.mountedAndLoaded]
);
onMounted(async () => {
this.lastJumpPresent = this.props.jumpPresent;
this.threadService.fetchNewMessages(this.props.thread);
await this.threadService.fetchNewMessages(this.props.thread);
this.state.mountedAndLoaded = true;
});
useBus(this.env.bus, "MAIL:RELOAD-THREAD", ({ detail }) => {
const { model, id } = this.props.thread;
@@ -2,7 +2,6 @@
import { AND, Record } from "@mail/core/common/record";
import { ScrollPosition } from "@mail/core/common/scroll_position";
import { replaceArrayWithCompare } from "@mail/utils/common/arrays";
import { assignDefined, onChange } from "@mail/utils/common/misc";
import { deserializeDateTime } from "@web/core/l10n/dates";
@@ -27,13 +26,15 @@ export class Thread extends Record {
/** @type {Object.<string, import("models").Thread>} */
static records = {};
/** @returns {import("models").Thread} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").Thread} */
static get(data) {
return super.get(data);
}
static new(data) {
/** @type {import("models").Thread} */
const thread = super.new(data);
this.store.Composer.insert({ thread });
return thread;
}
/**
* @param {string} localId
* @returns {string}
@@ -61,7 +62,8 @@ export class Thread extends Record {
thread.update(data);
return thread;
}
thread = this.new(data);
/** @type {import("models").Thread} */
thread = this.preinsert(data);
Object.assign(thread, {
id: data.id,
model: data.model,
@@ -76,11 +78,10 @@ export class Thread extends Record {
onChange(thread, "channelMembers", () => this.store.updateBusSubscription());
onChange(thread, "is_pinned", () => {
if (!thread.is_pinned && thread.eq(this.store.discuss.thread)) {
delete this.store.discuss.thread;
this.store.discuss.thread = undefined;
}
});
thread.update(data);
this.store.Composer.insert({ thread });
// return reactive version.
return thread;
}
@@ -90,12 +91,7 @@ export class Thread extends Record {
const { id, name, attachments: attachmentsData, description, ...serverData } = data;
assignDefined(this, { id, name, description });
if (attachmentsData) {
replaceArrayWithCompare(
this.attachments,
attachmentsData
.map((attachmentData) => this._store.Attachment.insert(attachmentData))
.sort((a1, a2) => a2.id - a1.id)
);
this.attachments = attachmentsData.sort((a1, a2) => a2.id - a1.id);
}
if (serverData) {
assignDefined(this, serverData, [
@@ -124,9 +120,7 @@ export class Thread extends Record {
}
const lastServerMessageId = serverData.last_message_id ?? this.lastServerMessage?.id;
if (this.lastServerMessage?.id !== lastServerMessageId) {
this.lastServerMessage = this._store.Message.insert({
id: lastServerMessageId,
});
this.lastServerMessage = { id: lastServerMessageId };
}
if (this.model === "discuss.channel" && serverData.channel) {
this.channel = assignDefined(this.channel ?? {}, serverData.channel);
@@ -165,15 +159,13 @@ export class Thread extends Record {
case "ADD":
if (members) {
for (const member of members) {
const record = this._store.ChannelMember.insert(member);
this.invitedMembers.add(record);
this.invitedMembers.add(member);
}
}
break;
case "DELETE":
for (const member of members) {
const record = this._store.ChannelMember.insert(member);
this.invitedMembers.delete(record);
this.invitedMembers.delete(member);
}
break;
}
@@ -183,15 +175,10 @@ export class Thread extends Record {
({ fetched_message_id, partner_id, seen_message_id }) => {
return {
lastFetchedMessage: fetched_message_id
? this._store.Message.insert({ id: fetched_message_id })
? { id: fetched_message_id }
: undefined,
lastSeenMessage: seen_message_id
? this._store.Message.insert({ id: seen_message_id })
: undefined,
partner: this._store.Persona.insert({
id: partner_id,
type: "partner",
}),
lastSeenMessage: seen_message_id ? { id: seen_message_id } : undefined,
partner: { id: partner_id, type: "partner" },
};
}
);
@@ -81,7 +81,7 @@ export class ThreadService {
* @param {import("models").Thread} thread
*/
async markAsRead(thread) {
if (!thread.isLoaded && thread.status === "loading") {
if (!thread.isLoaded) {
await thread.isLoadedDeferred;
await new Promise(setTimeout);
}
@@ -38,10 +38,6 @@ export class Activity extends Record {
/** @type {Object.<number, import("models").Activity>} */
static records = {};
/** @returns {import("models").Activity} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").Activity} */
static get(data) {
return super.get(data);
}
@@ -52,7 +48,8 @@ export class Activity extends Record {
* @returns {import("models").Activity}
*/
static insert(data, { broadcast = true } = {}) {
const activity = this.get(data) ?? this.new(data);
/** @type {import("models").Activity} */
const activity = this.preinsert(data);
Object.assign(activity, { id: data.id });
if (data.request_partner_id) {
data.request_partner_id = data.request_partner_id[0];
+32 -20
View File
@@ -17,7 +17,6 @@ import {
markup,
onMounted,
onPatched,
onWillStart,
onWillUpdateProps,
useChildSubEnv,
useEffect,
@@ -135,9 +134,7 @@ export class Chatter extends Component {
"o-mail-Chatter-dropzone"
);
onMounted(this.scrollPosition.restore);
onPatched(this.scrollPosition.restore);
onWillStart(() => {
onMounted(async () => {
if (this.props.threadId) {
this.state.thread = this.store.Thread.insert({
id: this.props.threadId,
@@ -145,12 +142,14 @@ export class Chatter extends Component {
name: this.props.webRecord?.data?.display_name || undefined,
});
}
return this.load(this.props.threadId, [
await this.load(this.props.threadId, [
"followers",
"attachments",
"suggestedRecipients",
]);
this.scrollPosition.restore();
});
onPatched(this.scrollPosition.restore);
onWillUpdateProps((nextProps) => {
this.load(nextProps.threadId, ["followers", "attachments", "suggestedRecipients"]);
if (nextProps.threadId === false) {
@@ -168,22 +167,33 @@ export class Chatter extends Component {
});
useEffect(
() => {
if (this.attachments.length === 0) {
if (
this.state.thread &&
!["new", "loading"].includes(this.state.thread.status) &&
this.attachments.length === 0
) {
this.state.isAttachmentBoxOpened = false;
}
},
() => [this.attachments]
() => [this.state.thread?.status, this.attachments]
);
useEffect(
() => {
if (this.state.scrollToAttachments > 0) {
if (
this.state.thread &&
!["new", "loading"].includes(this.state.thread.status) &&
this.state.scrollToAttachments > 0
) {
this.attachmentBox.el.scrollIntoView({ block: "center" });
}
},
() => [this.state.scrollToAttachments]
() => [this.state.thread?.status, this.state.scrollToAttachments]
);
useEffect(
() => {
if (!this.state.thread) {
return;
}
browser.clearTimeout(this.loadingAttachmentTimeout);
if (this.state.thread?.isLoadingAttachments) {
this.loadingAttachmentTimeout = browser.setTimeout(
@@ -195,7 +205,7 @@ export class Chatter extends Component {
}
return () => browser.clearTimeout(this.loadingAttachmentTimeout);
},
() => [this.state.thread?.isLoadingAttachments]
() => [this.state.thread, this.state.thread?.isLoadingAttachments]
);
}
@@ -203,7 +213,7 @@ export class Chatter extends Component {
* @returns {import("models").Activity[]}
*/
get activities() {
return this.state.thread.activities;
return this.state.thread?.activities ?? [];
}
get followerButtonLabel() {
@@ -218,7 +228,7 @@ export class Chatter extends Component {
* @returns {boolean}
*/
get isDisabled() {
return !this.props.threadId || !this.state.thread.hasReadAccess;
return !this.props.threadId || !this.state.thread?.hasReadAccess;
}
get attachments() {
@@ -229,17 +239,19 @@ export class Chatter extends Component {
* @returns {string}
*/
get toRecipientsText() {
const recipients = [...this.state.thread.recipients].slice(0, 5).map(({ partner }) => {
const text = partner.email ? partner.emailWithoutDomain : partner.name;
return `<span class="text-muted" title="${escapeHTML(partner.email)}">${escapeHTML(
text
)}</span>`;
});
const recipients = [...(this.state.thread?.recipients ?? [])]
.slice(0, 5)
.map(({ partner }) => {
const text = partner.email ? partner.emailWithoutDomain : partner.name;
return `<span class="text-muted" title="${escapeHTML(partner.email)}">${escapeHTML(
text
)}</span>`;
});
const formatter = new Intl.ListFormat(
this.store.env.services["user"].lang?.replace("_", "-"),
{ type: "unit" }
);
if (this.state.thread.recipients.length > 5) {
if (this.state.thread && this.state.thread.recipients.length > 5) {
recipients.push("…");
}
return markup(formatter.format(recipients));
@@ -255,7 +267,7 @@ export class Chatter extends Component {
) {
const { threadModel } = this.props;
this.state.thread = this.threadService.getThread(threadModel, threadId);
this.scrollPosition.model = this.state.thread.scrollPosition;
this.scrollPosition.model = this.state.thread?.scrollPosition;
if (!threadId) {
return;
}
+1 -1
View File
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="mail.Chatter">
<div class="o-mail-Chatter w-100 h-100 flex-grow-1 d-flex flex-column" t-att-class="{ 'overflow-auto': props.hasMessageListScrollAdjust, 'o-chatter-disabled': props.threadId === false }" t-on-scroll="onScrollDebounced" t-ref="root">
<div t-if="state.thread" class="o-mail-Chatter w-100 h-100 flex-grow-1 d-flex flex-column" t-att-class="{ 'overflow-auto': props.hasMessageListScrollAdjust, 'o-chatter-disabled': props.threadId === false }" t-on-scroll="onScrollDebounced" t-ref="root">
<div class="o-mail-Chatter-top position-sticky top-0" t-att-class="{ 'shadow-sm': state.isTopStickyPinned }" t-ref="top">
<div class="o-mail-Chatter-topbar d-flex flex-shrink-0 flex-grow-0 px-3 overflow-x-auto">
<button t-if="props.hasMessageList" class="o-mail-Chatter-sendMessage btn text-nowrap me-1" t-att-class="{
@@ -287,7 +287,7 @@ export class MessagingMenu extends Component {
);
}
if (this.store.discuss.activeTab !== "mailbox") {
delete this.store.discuss.thread;
this.store.discuss.thread = undefined;
}
}
@@ -70,19 +70,14 @@ patch(ThreadService.prototype, {
});
}
if ("mainAttachment" in result) {
thread.mainAttachment = result.mainAttachment.id
? this.store.Attachment.insert(result.mainAttachment)
: undefined;
thread.mainAttachment = result.mainAttachment.id ? result.mainAttachment : undefined;
}
if (!thread.mainAttachment && thread.attachmentsInWebClientView.length > 0) {
this.setMainAttachmentFromIndex(thread, 0);
}
if ("followers" in result) {
if (result.selfFollower) {
thread.selfFollower = this.store.Follower.insert({
followedThread: thread,
...result.selfFollower,
});
thread.selfFollower = { followedThread: thread, ...result.selfFollower };
}
thread.followersCount = result.followersCount;
for (const followerData of result.followers) {
@@ -96,11 +91,7 @@ patch(ThreadService.prototype, {
}
thread.recipientsCount = result.recipientsCount;
for (const recipientData of result.recipients) {
const recipient = this.store.Follower.insert({
followedThread: thread,
...recipientData,
});
thread.recipients.add(recipient);
thread.recipients.add({ followedThread: thread, ...recipientData });
}
}
if ("suggestedRecipients" in result) {
@@ -123,18 +114,15 @@ patch(ThreadService.prototype, {
type: "chatter",
});
if (resId === false) {
const tmpId = this.messageService.getNextTemporaryId();
const tmpData = {
id: tmpId,
thread.messages.push({
id: this.messageService.getNextTemporaryId(),
author: { id: this.store.self.id },
body: _t("Creating a new record..."),
message_type: "notification",
trackingValues: [],
res_id: thread.id,
model: thread.model,
};
const message = this.store.Message.insert(tmpData);
thread.messages.push(message);
});
}
return thread;
},
@@ -156,12 +144,7 @@ patch(ThreadService.prototype, {
email,
lang,
reason,
persona: partner_id
? this.store.Persona.insert({
type: "partner",
id: partner_id,
})
: false,
persona: partner_id ? { type: "partner", id: partner_id } : false,
checked: true,
});
}
@@ -198,11 +181,7 @@ patch(ThreadService.prototype, {
{ filter_recipients: true }
);
for (const data of recipients) {
const recipient = this.store.Follower.insert({
followedThread: thread,
...data,
});
thread.recipients.add(recipient);
thread.recipients.add({ followedThread: thread, ...data });
}
},
open(thread, replaceNewMessageChatWindow) {
@@ -273,7 +273,7 @@ export class Rtc {
* @param {import("models").Thread} [channel]
*/
endCall(channel = this.state.channel) {
channel.rtcInvitingSessionId = undefined;
channel.rtcInvitingSession = undefined;
if (channel.eq(this.state.channel)) {
this.clear();
this.soundEffectsService.play("channel-leave");
@@ -762,7 +762,7 @@ export class Rtc {
3000,
true
);
this.state.channel.rtcInvitingSessionId = undefined;
this.state.channel.rtcInvitingSession = undefined;
this.call();
this.soundEffectsService.play("channel-join");
await this.resetAudioTrack({ force: true });
@@ -830,8 +830,7 @@ export class Rtc {
if (this.state.channel && rtcSessions) {
const activeSessionsData = rtcSessions[0][1];
for (const sessionData of activeSessionsData) {
const session = this.store.RtcSession.insert(sessionData);
this.state.channel.rtcSessions.add(session);
this.state.channel.rtcSessions.add(sessionData);
}
const outdatedSessionsData = rtcSessions[1][1];
for (const sessionData of outdatedSessionsData) {
@@ -892,19 +891,19 @@ export class Rtc {
// ignore error during remove, the value will be overwritten at next usage anyway
}
}
delete session.audioStream;
delete session.connectionState;
delete session.localCandidateType;
delete session.remoteCandidateType;
delete session.dataChannelState;
delete session.packetsReceived;
delete session.packetsSent;
delete session.dtlsState;
delete session.iceState;
delete session.raisingHand;
delete session.logStep;
delete session.audioError;
delete session.videoError;
session.audioStream = undefined;
session.connectionState = undefined;
session.localCandidateType = undefined;
session.remoteCandidateType = undefined;
session.dataChannelState = undefined;
session.packetsReceived = undefined;
session.packetsSent = undefined;
session.dtlsState = undefined;
session.iceState = undefined;
session.raisingHand = undefined;
session.logStep = undefined;
session.audioError = undefined;
session.videoError = undefined;
session.isTalking = false;
this.removeVideoFromSession(session);
session.dataChannel?.close();
@@ -927,7 +926,7 @@ export class Rtc {
}
}
peerConnection.close();
delete session.peerConnection;
session.peerConnection = undefined;
}
browser.clearTimeout(this.state.recoverTimeouts.get(session.id));
this.state.recoverTimeouts.delete(session.id);
@@ -1537,8 +1536,7 @@ export class Rtc {
break;
case "ADD":
for (const sessionData of sessionsData) {
const session = this.store.RtcSession.insert(sessionData);
channel.rtcSessions.add(session);
channel.rtcSessions.add(sessionData);
}
break;
}
@@ -7,10 +7,6 @@ export class RtcSession extends Record {
/** @type {Object.<number, import("models").RtcSession>} */
static records = {};
/** @returns {import("models").RtcSession} */
static new(data) {
return super.new(data);
}
/** @returns {import("models").RtcSession} */
static get(data) {
return super.get(data);
}
@@ -19,7 +15,8 @@ export class RtcSession extends Record {
* @returns {number, import("models").RtcSession}
*/
static insert(data) {
const session = this.get(data) ?? this.new(data);
/** @type {import("models").RtcSession} */
const session = this.preinsert(data);
const { channelMember, ...remainingData } = data;
for (const key in remainingData) {
session[key] = remainingData[key];
@@ -8,8 +8,7 @@ patch(Thread.prototype, {
update(data) {
super.update(data);
if ("rtc_inviting_session" in data) {
const session = this._store.RtcSession.insert(data.rtc_inviting_session);
this.rtcSessions.add(session);
this.rtcSessions.add(data.rtc_inviting_session);
this._store.discuss.ringingThreads.add(this);
}
let rtcContinue = true;
@@ -21,8 +20,7 @@ patch(Thread.prototype, {
}
rtcContinue = false;
} else {
const session = this._store.RtcSession.insert(data.rtcInvitingSession);
this.rtcSessions.add(session);
this.rtcSessions.add(data.rtcInvitingSession);
this._store.discuss.ringingThreads.add(this);
}
}
@@ -37,8 +35,7 @@ patch(Thread.prototype, {
break;
case "ADD":
for (const rtcSessionData of sessionsData) {
const session = this._store.RtcSession.insert(rtcSessionData);
this.rtcSessions.add(session);
this.rtcSessions.add(rtcSessionData);
}
break;
}
@@ -40,7 +40,7 @@ patch(MessageModel.prototype, {
this.originThread.pinnedMessages.add(this);
this.pinnedAt = pinnedAt;
} else {
delete this.pinnedAt;
this.pinnedAt = undefined;
this.originThread.pinnedMessages.delete(this);
}
}
@@ -1,7 +1,5 @@
/* @odoo-module */
import { toRaw } from "@odoo/owl";
export function removeFromArray(array, elem) {
const index = array.indexOf(elem);
if (index >= 0) {
@@ -15,29 +13,3 @@ export function removeFromArrayWithPredicate(array, predicate) {
array.splice(index, 1);
}
}
/**
* Replaces the content of array1 with the content of array2. Order of elements
* is not guaranteed: new elements are inserted last.
*
* Smart process to avoid triggering reactives when there is no change between
* the 2 arrays.
*/
export function replaceArrayWithCompare(array1, array2) {
array1 = toRaw(array1);
array2 = toRaw(array2);
const elementsToRemove = new Set();
for (const el1 of array1) {
if (!array2.includes(el1)) {
elementsToRemove.add(el1);
}
}
for (const el of elementsToRemove) {
removeFromArray(array1, el);
}
for (const el2 of array2) {
if (!array1.includes(el2)) {
array1.push(el2);
}
}
}
+13 -2
View File
@@ -331,6 +331,7 @@ export function useSelection({ refName, model, preserveOnClickAwayPredicate = ()
*/
export function useScrollPosition(refName, model, clearOn) {
const ref = useRef(refName);
let observeScroll = false;
const self = {
ref,
model,
@@ -368,11 +369,21 @@ export function useScrollPosition(refName, model, clearOn) {
}
onMounted(() => {
ref.el.addEventListener("scroll", onScrolled);
if (ref.el) {
observeScroll = true;
}
ref.el?.addEventListener("scroll", onScrolled);
});
onPatched(() => {
if (!observeScroll && ref.el) {
observeScroll = true;
ref.el.addEventListener("scroll", onScrolled);
}
});
onWillUnmount(() => {
ref.el.removeEventListener("scroll", onScrolled);
ref.el?.removeEventListener("scroll", onScrolled);
});
return self;
}
@@ -612,7 +612,8 @@ QUnit.test("emptying inbox displays rainbow man in inbox", async () => {
]);
const { openDiscuss } = await start();
openDiscuss();
await click("button", { text: "Mark all read" });
await contains(".o-mail-Message");
await click("button:enabled", { text: "Mark all read" });
await contains(".o_reward_rainbow");
});
@@ -4,6 +4,7 @@ import { startServer } from "@bus/../tests/helpers/mock_python_environment";
import { start } from "@mail/../tests/helpers/test_utils";
import { getOrigin } from "@web/core/utils/urls";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { click, contains } from "@web/../tests/utils";
@@ -87,10 +88,9 @@ QUnit.test("reply shows correct author avatar", async (assert) => {
});
const { openDiscuss } = await start();
await openDiscuss(channelId);
const replyAvatar = document.querySelector(".o-mail-MessageInReply-avatar");
assert.ok(
replyAvatar.dataset["src"].includes(
`/discuss/channel/${channelId}/partner/${pyEnv.currentPartnerId}/avatar_128`
)
await contains(
`.o-mail-MessageInReply-avatar[data-src='${`${getOrigin()}/discuss/channel/${channelId}/partner/${
pyEnv.currentPartnerId
}/avatar_128`}']`
);
});
@@ -16,7 +16,10 @@ QUnit.test("Form view not scrolled when switching record", async () => {
description: [...Array(60).keys()].join("\n"),
display_name: "Partner 1",
},
{ display_name: "Partner 2" },
{
description: [...Array(60).keys()].join("\n"),
display_name: "Partner 2",
},
]);
const messages = [...Array(60).keys()].map((id) => {
return {
+4 -11
View File
@@ -4,6 +4,8 @@ import { startServer } from "@bus/../tests/helpers/mock_python_environment";
import { start } from "@mail/../tests/helpers/test_utils";
import { contains } from "@web/../tests/utils";
QUnit.module("chatter");
QUnit.test("Send message button activation (access rights dependent)", async function (assert) {
@@ -54,19 +56,10 @@ QUnit.test("Send message button activation (access rights dependent)", async fun
res_model: model,
views: [[false, "form"]],
});
const details = `hasReadAccess: ${hasReadAccess}, hasWriteAccess: ${hasWriteAccess}, model: ${model}, resId: ${resId}`;
if (enabled) {
assert.containsNone(
document.body,
".o-mail-Chatter-topbar button:contains(Send message):disabled",
`${msg}: send message button must not be disabled (${details}`
);
await contains(".o-mail-Chatter-topbar button:enabled", { text: "Send message" });
} else {
assert.containsOnce(
document.body,
".o-mail-Chatter-topbar button:contains(Send message):disabled",
`${msg}: send message button must be disabled (${details})`
);
await contains(".o-mail-Chatter-topbar button:disabled", { text: "Send message" });
}
}
await assertSendButton(
@@ -16,6 +16,7 @@ import { deepCopy, pick } from "@web/core/utils/objects";
import { makeFakeRPCService, makeMockFetch } from "./mock_services";
import { patchWithCleanup } from "./utils";
import { makeErrorFromResponse } from "@web/core/network/rpc_service";
import { registerCleanup } from "./cleanup";
const serviceRegistry = registry.category("services");
@@ -134,6 +135,7 @@ function traverseElementTree(tree, cb) {
// -----------------------------------------------------------------------------
export class MockServer {
active = true;
constructor(data, options = {}) {
this.init(data, options);
}
@@ -2517,6 +2519,10 @@ export async function makeMockServer(serverData, mockRPC) {
// simulates that we serialized the call to be passed in a real request
args = JSON.parse(JSON.stringify(args));
}
if (!mockServer.active) {
// End of test => all RPCs are blocking
return new Promise(() => {});
}
if (mockRPC) {
res = await mockRPC(route, args, mockServer.performRPC.bind(mockServer));
}
@@ -2558,6 +2564,7 @@ export async function makeMockServer(serverData, mockRPC) {
});
}
// Replace RPC service
registerCleanup(() => (mockServer.active = false));
serviceRegistry.add("rpc", rpcService, { force: true });
return mockServer;
}
@@ -0,0 +1,5 @@
declare module "models" {
export interface Thread {
visitor: Persona,
}
}
@@ -1,17 +1,19 @@
/** @odoo-module */
import { Record } from "@mail/core/common/record";
import { Thread } from "@mail/core/common/thread_model";
import { assignDefined } from "@mail/utils/common/misc";
import { patch } from "@web/core/utils/patch";
patch(Thread.prototype, {
setup() {
super.setup();
this.visitor = Record.one("Persona");
},
update(data) {
super.update(data);
if (data?.visitor) {
this.visitor = this._store.Persona.insert({
...data.visitor,
type: "visitor",
});
this.visitor = { ...data.visitor, type: "visitor" };
}
assignDefined(this, data, ["requested_by_operator"]);
},
@@ -4,7 +4,7 @@ import { startServer } from "@bus/../tests/helpers/mock_python_environment";
import { start } from "@mail/../tests/helpers/test_utils";
import { click } from "@web/../tests/utils";
import { click, contains } from "@web/../tests/utils";
QUnit.module("activity (patch)");
@@ -36,9 +36,7 @@ QUnit.test("grant course access", async (assert) => {
res_model: "slide.channel",
views: [[false, "form"]],
});
assert.containsOnce($, ".o-mail-Activity");
assert.containsOnce($, "button:contains(Grant Access)");
await contains(".o-mail-Activity");
await click("button", { text: "Grant Access" });
assert.verifySteps(["access_grant"]);
});
@@ -71,9 +69,7 @@ QUnit.test("refuse course access", async (assert) => {
res_model: "slide.channel",
views: [[false, "form"]],
});
assert.containsOnce($, ".o-mail-Activity");
assert.containsOnce($, "button:contains(Refuse Access)");
await contains(".o-mail-Activity");
await click("button", { text: "Refuse Access" });
assert.verifySteps(["access_refuse"]);
});