[FIX] mail: properly compare discuss models with toRaw

Before this commit, discuss models were compared with object
reference, e.g. `thread_1 === thread_2`.

This is unsafe with `owl.reactive()`, because it wraps the objects
in a Proxy, and different proxies may map to a same raw object.

This commit fixes the issue by defining `.eq()` and `.in()` methods
on discuss models, which makes comparison on raw value rather than
proxy/reactive one.

This PR also introduce "not" variants, e.g. `.notEq()`, which eases
readability by having "not" and "eq" next to each other.
These "not" variants must be used without optional chaining,
otherwise this gives the opposite result. For example:

```js
const [t1, t2] = [Thread.insert()];
t1 === t1     // true (OK)
t1.eq(t1)     // true (OK)
t1.notEq(t1)  // false (OK)
t2?.notEq(t1) // undefined => falsy (notOk)
!t2?.eq(t1)   // true (OK)
```

closes odoo/odoo#133065

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Alexandre Kühn
2023-08-25 12:21:03 +02:00
parent 4cca73816f
commit 0c3e66340d
59 changed files with 186 additions and 132 deletions
@@ -40,9 +40,7 @@ patch(Thread.prototype, {
// For livechat threads, the correspondent is the first
// channel member that is not the operator.
const orderedChannelMembers = [...this.channelMembers].sort((a, b) => a.id - b.id);
const isFirstMemberOperator =
orderedChannelMembers[0]?.persona.type === "partner" &&
orderedChannelMembers[0]?.persona.id === this.operator.id;
const isFirstMemberOperator = orderedChannelMembers[0]?.persona.eq(this.operator);
correspondent = isFirstMemberOperator
? orderedChannelMembers[1]?.persona
: orderedChannelMembers[0]?.persona;
@@ -261,7 +261,7 @@ export class ChatBotService {
channel_uuid: this.livechatService.thread.uuid,
});
this.currentStep.isEmailValid = success;
if (msg && !this.livechatService.thread.hasMessage(msg)) {
if (msg && !this.livechatService.thread.messages.some((m) => m.id === msg.id)) {
this.livechatService.thread.messages.push(
this.messageService.insert({ ...msg, body: markup(msg.body) })
);
@@ -58,9 +58,7 @@ export class AutopopupService {
*/
async shouldOpenChatWindow() {
const thread = await this.threadService.getLivechatThread();
return this.storeService.chatWindows.every(
(chatWindow) => chatWindow.thread.localId !== thread?.localId
);
return this.storeService.chatWindows.every((cw) => !cw.thread?.eq(thread));
}
get allowAutoPopup() {
@@ -1,10 +1,11 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { assignDefined } from "@mail/utils/common/misc";
import { url } from "@web/core/utils/urls";
export class Attachment {
export class Attachment extends Record {
/** @type {import("@mail/core/common/store_service").Store} */
_store;
accessToken;
@@ -63,7 +63,7 @@ export class AttachmentService {
});
attachment.originThreadLocalId = createLocalId(threadData.model, threadData.id);
const thread = attachment.originThread;
if (!thread.attachments.includes(attachment)) {
if (attachment.notIn(thread.attachments)) {
thread.attachments.push(attachment);
thread.attachments.sort((a1, a2) => (a1.id < a2.id ? 1 : -1));
}
@@ -81,25 +81,20 @@ export class AttachmentService {
}
delete this.store.attachments[attachment.id];
if (attachment.originThread) {
removeFromArrayWithPredicate(
attachment.originThread.attachments,
({ id }) => id === attachment.id
removeFromArrayWithPredicate(attachment.originThread.attachments, (att) =>
att.eq(attachment)
);
}
for (const message of Object.values(this.store.messages)) {
removeFromArrayWithPredicate(message.attachments, ({ id }) => id === attachment.id);
removeFromArrayWithPredicate(message.attachments, (att) => att.eq(attachment));
if (message.composer) {
removeFromArrayWithPredicate(
message.composer.attachments,
({ id }) => id === attachment.id
removeFromArrayWithPredicate(message.composer.attachments, (att) =>
att.eq(attachment)
);
}
}
for (const thread of Object.values(this.store.threads)) {
removeFromArrayWithPredicate(
thread.composer.attachments,
({ id }) => id === attachment.id
);
removeFromArrayWithPredicate(thread.composer.attachments, (att) => att.eq(attachment));
}
}
@@ -33,8 +33,8 @@ export class AttachmentView extends Component {
}
onClickNext() {
const index = this.state.thread.attachmentsInWebClientView.findIndex(
(attachment) => attachment.id === this.state.thread.mainAttachment.id
const index = this.state.thread.attachmentsInWebClientView.findIndex((attachment) =>
attachment.eq(this.state.thread.mainAttachment)
);
this.threadService.setMainAttachmentFromIndex(
this.state.thread,
@@ -43,8 +43,8 @@ export class AttachmentView extends Component {
}
onClickPrevious() {
const index = this.state.thread.attachmentsInWebClientView.findIndex(
(attachment) => attachment.id === this.state.thread.mainAttachment.id
const index = this.state.thread.attachmentsInWebClientView.findIndex((attachment) =>
attachment.eq(this.state.thread.mainAttachment)
);
this.threadService.setMainAttachmentFromIndex(
this.state.thread,
@@ -1,6 +1,8 @@
/* @odoo-module */
export class CannedResponse {
import { Record } from "@mail/core/common/record";
export class CannedResponse extends Record {
/** @type {number} */
id;
/** @type {string} */
@@ -1,5 +1,6 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { createLocalId } from "@mail/utils/common/misc";
/**
@@ -9,7 +10,7 @@ import { createLocalId } from "@mail/utils/common/misc";
* @property {string} personaLocalId
* @property {number} threadId
*/
export class ChannelMember {
export class ChannelMember extends Record {
/** @type {number} */
id;
personaLocalId;
@@ -54,7 +54,7 @@ export class ChannelMemberService {
switch (command) {
case "insert":
{
if (member.thread && !member.thread.channelMembers.includes(member)) {
if (member.thread && member.notIn(member.thread.channelMembers)) {
member.thread.channelMembers.push(member);
}
}
@@ -103,8 +103,8 @@ export class ChatWindow extends Component {
this.close({ escape: true });
break;
case "Tab": {
const index = this.chatWindowService.visible.findIndex(
(cw) => cw === this.props.chatWindow
const index = this.chatWindowService.visible.findIndex((cw) =>
cw.eq(this.props.chatWindow)
);
if (index === 0) {
this.chatWindowService.visible[this.chatWindowService.visible.length - 1]
@@ -1,10 +1,12 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { _t } from "@web/core/l10n/translation";
/** @typedef {{ thread?: import("@mail/core/common/thread_model").Thread, folded?: boolean, replaceNewMessageChatWindow?: boolean }} ChatWindowData */
export class ChatWindow {
export class ChatWindow extends Record {
/** @type {import("@mail/core/common/store_service").Store} */
_store;
@@ -20,6 +22,7 @@ export class ChatWindow {
* @returns {ChatWindow}
*/
constructor(store, data) {
super();
Object.assign(this, {
thread: data.thread,
_store: store,
@@ -158,7 +158,7 @@ export class ChatWindowService {
swaped.hidden = false;
swaped.folded = false;
}
const index = this.store.chatWindows.findIndex((c) => c === chatWindow);
const index = this.store.chatWindows.findIndex((c) => c.eq(chatWindow));
if (index > -1) {
this.store.chatWindows.splice(index, 1);
}
@@ -175,7 +175,7 @@ export class Composer extends Component {
);
useEffect(
(rThread, cThread) => {
if (cThread && rThread === cThread) {
if (cThread && cThread.eq(rThread)) {
this.state.autofocus++;
}
},
@@ -17,11 +17,11 @@
<div class="o-mail-Composer-sidebarMain flex-shrink-0" t-if="!compact and props.sidebar">
<img class="o-mail-Composer-avatar o_avatar rounded" t-att-src="threadService.avatarUrl(store.self, props.composer.thread)" alt="Avatar of user"/>
</div>
<div class="o-mail-Composer-coreHeader text-truncate small p-2" t-if="props.composer.thread and props.messageToReplyTo?.thread === props.composer.thread">
<div class="o-mail-Composer-coreHeader text-truncate small p-2" t-if="props.composer.thread and props.messageToReplyTo?.thread?.eq(props.composer.thread)">
<span class="cursor-pointer" t-on-click="() => env.messageHighlight?.highlightMessage(props.messageToReplyTo.message, props.composer.thread)">
Replying to <b t-esc="props.messageToReplyTo.message.author.name"/>
</span>
<span t-if="props.messageToReplyTo.message.originThread !== props.composer.thread">
<span t-if="props.messageToReplyTo.message.originThread.notEq(props.composer.thread)">
on: <b><t t-esc="props.messageToReplyTo.message.originThread.displayName"/></b>
</span>
<i class="fa fa-lg fa-times-circle rounded-circle p-0 ms-1 cursor-pointer" title="Stop replying" t-on-click="() => props.messageToReplyTo.cancel()"/>
@@ -1,10 +1,12 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
/**
* @typedef {{partnerIds: Set<number>, threadIds: Set<number>}} RawMentions
*/
export class Composer {
export class Composer extends Record {
/** @type {import("@mail/core/common/attachment_model").Attachment[]} */
attachments = [];
/** @type {import("@mail/core/common/message_model").Message} */
@@ -33,6 +35,7 @@ export class Composer {
isFocused = false;
constructor(store, data) {
super();
const { message, thread } = data;
if (thread) {
this.thread = thread;
@@ -63,7 +63,7 @@
<div class="overflow-auto bg-view d-flex flex-grow-1" t-ref="core">
<div class="d-flex flex-column flex-grow-1">
<Thread thread="thread" t-key="thread.localId" jumpPresent="state.jumpThreadPresent" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo"/>
<Composer t-if="thread.type !== 'mailbox' or messageToReplyTo.thread === thread" t-key="thread.localId" composer="thread.composer" autofocus="true" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo" onDiscardCallback="() => messageToReplyTo.cancel()" onPostCallback.bind="() => this.state.jumpThreadPresent++" dropzoneRef="contentRef" type="messageToReplyTo?.message ? (messageToReplyTo.message.isNote ? 'note' : 'message') : undefined"/>
<Composer t-if="thread.type !== 'mailbox' or thread.eq(messageToReplyTo.thread)" t-key="thread.localId" composer="thread.composer" autofocus="true" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo" onDiscardCallback="() => messageToReplyTo.cancel()" onPostCallback.bind="() => this.state.jumpThreadPresent++" dropzoneRef="contentRef" type="messageToReplyTo?.message ? (messageToReplyTo.message.isNote ? 'note' : 'message') : undefined"/>
</div>
<div t-if="threadActions.activeAction?.componentCondition" t-attf-class="{{ threadActions.activeAction.panelOuterClass }}" class="h-100 border-start o-mail-Discuss-inspector">
<t t-component="threadActions.activeAction.component" thread="thread" t-props="threadActions.activeAction.componentProps"/>
@@ -1,5 +1,7 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
/**
* @typedef Data
* @property {import("@mail/core/common/thread_model").Thread} followedThread
@@ -8,7 +10,7 @@
* @property {import("@mail/core/common/partner_model").Data} partner
*/
export class Follower {
export class Follower extends Record {
/** @type {import("@mail/core/common/thread_model").Thread} */
followedThread;
/** @type {number} */
@@ -25,7 +27,7 @@ export class Follower {
*/
get isEditable() {
const hasWriteAccess = this.followedThread ? this.followedThread.hasWriteAccess : false;
return this._store.user === this.partner
return this.partner.eq(this._store.user)
? this.followedThread.hasReadAccess
: hasWriteAccess;
}
@@ -1,6 +1,8 @@
/* @odoo-module */
export class LinkPreview {
import { Record } from "@mail/core/common/record";
export class LinkPreview extends Record {
/** @type {number} */
id;
/** @type {Object} */
@@ -25,6 +27,7 @@ export class LinkPreview {
* @returns {LinkPreview}
*/
constructor(data) {
super();
Object.assign(this, data);
}
@@ -54,9 +54,8 @@ export class MailCoreCommon {
}
delete this.store.messages[messageId];
if (message.originThread) {
removeFromArrayWithPredicate(
message.originThread.messages,
({ id }) => id === message.id
removeFromArrayWithPredicate(message.originThread.messages, (msg) =>
msg.eq(message)
);
}
this.env.bus.trigger("mail.message/delete", { message });
@@ -107,7 +107,7 @@ export class Message extends Component {
});
useEffect(
(editingMessage) => {
if (editingMessage === this.props.message) {
if (this.props.message.eq(editingMessage)) {
this.enterEditMode();
}
},
@@ -125,7 +125,7 @@ export class Message extends Component {
const reaction = this.message.reactions.find(
({ content, personas }) =>
content === emoji &&
personas.find((persona) => persona === this.store.self)
personas.find((persona) => persona.eq(this.store.self))
);
if (!reaction) {
this.messageService.react(this.message, emoji);
@@ -260,7 +260,7 @@ export class Message extends Component {
if (!this.props.thread) {
return false;
}
return this.message.originThread === this.props.thread;
return this.props.thread.eq(this.message.originThread);
}
get isInInbox() {
@@ -57,7 +57,7 @@
<i class="fa fa-calendar-o"/>
</span>
</div>
<div t-if="message.originThread === props.thread and message.notifications.length > 0" t-att-class="{ 'ms-2': !isAlignedRight }">
<div t-if="message.originThread?.eq(props.thread) and message.notifications.length > 0" t-att-class="{ 'ms-2': !isAlignedRight }">
<span class="o-mail-Message-notification cursor-pointer" t-att-class="message.failureNotifications.length > 0 ? 'text-danger' : text-600" role="button" tabindex="0" t-on-click="onClickNotification">
<i t-att-class="message.notifications[0].icon" role="img" aria-label="Delivery failure"/> <span t-if="message.notifications[0].label" t-out="message.notifications[0].label"/>
</span>
@@ -140,7 +140,7 @@
<button t-if="canReplyTo" class="btn px-1 py-0 rounded-0" t-on-click.stop="onClickReplyTo" tabindex="1" title="Reply" aria-label="Reply"><i class="fa fa-lg fa-reply"/></button>
<button t-if="canToggleStar" class="btn px-1 py-0 rounded-0" t-on-click="() => messageService.toggleStar(props.message)" tabindex="0" title="Mark as Todo" aria-label="Mark as Todo"><i class="fa fa-lg" t-att-class="message.isStarred ? 'fa-star o-mail-Message-starred' : 'fa-star-o'"/></button>
<button t-if="isInInbox" class="btn px-1 py-0 rounded-0" t-on-click.stop="() => messageService.setDone(props.message)" title="Mark as Read" aria-label="Mark as Read"><i class="fa fa-lg fa-check"/></button>
<Dropdown t-if="message.reactions.length or editable or deletable or (canAddReaction and isInInbox) or (props.thread?.model === 'discuss.channel' and store.user)" onStateChanged="state => this.state.expandOptions = state.open" position="props.thread?.newestMessage === props.message ? 'top-start' : 'bottom-start'" togglerClass="`btn p-0 ${ state.expandOptions ? 'bg-200' : '' }`" menuClass="'d-flex flex-column py-0 o-mail-Message-moreMenu'" class="'d-flex rounded-0'" title="expandText">
<Dropdown t-if="message.reactions.length or editable or deletable or (canAddReaction and isInInbox) or (props.thread?.model === 'discuss.channel' and store.user)" onStateChanged="state => this.state.expandOptions = state.open" position="props.message.eq(props.thread?.newestMessage) ? 'top-start' : 'bottom-start'" togglerClass="`btn p-0 ${ state.expandOptions ? 'bg-200' : '' }`" menuClass="'d-flex flex-column py-0 o-mail-Message-moreMenu'" class="'d-flex rounded-0'" title="expandText">
<t t-set-slot="toggler">
<i class="btn px-1 py-0 fa fa-lg fa-ellipsis-h rounded-0" t-att-class="{ 'order-1': props.isInChatWindow }" tabindex="1"/>
</t>
@@ -1,5 +1,6 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { htmlToTextContentInline } from "@mail/utils/common/format";
import { createLocalId } from "@mail/utils/common/misc";
@@ -12,7 +13,7 @@ import { url } from "@web/core/utils/urls";
const { DateTime } = luxon;
export class Message {
export class Message extends Record {
/** @type {Object[]} */
attachments = [];
/** @type {import("@mail/core/common/persona_model").Persona} */
@@ -114,7 +115,7 @@ export class Message {
}
get isSelfMentioned() {
return this.recipients.some((recipient) => recipient === this._store.self);
return this._store.self?.in(this.recipients);
}
get isHighlightedFromMention() {
@@ -125,7 +126,7 @@ export class Message {
if (!this.author || !this._store.self) {
return false;
}
return this.author.id === this._store.self.id && this.author.type === this._store.self.type;
return this.author.eq(this._store.self);
}
get isNeedaction() {
@@ -6,9 +6,9 @@
<div class="d-flex h-100" t-on-keydown="onKeydown" t-ref="root">
<div class="d-flex overflow-auto flex-column bg-100 p-2 h-100 border-end">
<t t-foreach="props.message.reactions" t-as="reaction" t-key="reaction.content">
<button class="btn p-1 rounded-2 mx-2 py-0 d-flex align-items-center" t-att-class="{ 'bg-200 border-primary': reaction === state.reaction }" t-att-title="getEmojiShortcode(reaction)" t-att-data-reaction="reaction" t-on-click="() => state.reaction = reaction">
<button class="btn p-1 rounded-2 mx-2 py-0 d-flex align-items-center" t-att-class="{ 'bg-200 border-primary': reaction.eq(state.reaction) }" t-att-title="getEmojiShortcode(reaction)" t-att-data-reaction="reaction" t-on-click="() => state.reaction = reaction">
<span class="mx-1 fs-2" t-esc="reaction.content"/>
<span class="mx-1 pe-2" t-att-class="{ 'text-primary': reaction === state.reaction }" t-esc="reaction.count"/>
<span class="mx-1 pe-2" t-att-class="{ 'text-primary': reaction.eq(state.reaction) }" t-esc="reaction.count"/>
</button>
</t>
</div>
@@ -18,7 +18,7 @@
<span class="d-flex flex-grow-1 align-items-center">
<span class="mx-2 text-truncate fs-6" t-esc="persona.name"/>
<div class="flex-grow-1"/>
<button t-if="store.self === persona" class="btn btn-light fa fa-trash rounded-pill o-bg-inherit border-0" title="Remove" t-on-click.stop="() => this.messageService.removeReaction(state.reaction)"/>
<button t-if="persona.eq(store.self)" class="btn btn-light fa fa-trash rounded-pill o-bg-inherit border-0" title="Remove" t-on-click.stop="() => this.messageService.removeReaction(state.reaction)"/>
</span>
</div>
</div>
@@ -62,7 +62,7 @@ export class MessageReactions extends Component {
}
hasSelfReacted(reaction) {
return reaction.personas.includes(this.store.self);
return this.store.self.in(reaction.personas);
}
onClickReaction(reaction) {
@@ -1,6 +1,8 @@
/* @odoo-module */
export class MessageReactions {
import { Record } from "@mail/core/common/record";
export class MessageReactions extends Record {
/** @type {string} */
content;
/** @type {number} */
@@ -64,9 +64,8 @@ export class MessageService {
async delete(message) {
if (message.isStarred) {
this.store.discuss.starred.counter--;
removeFromArrayWithPredicate(
this.store.discuss.starred.messages,
({ id }) => id === message.id
removeFromArrayWithPredicate(this.store.discuss.starred.messages, (msg) =>
msg.eq(message)
);
}
message.body = "";
@@ -201,12 +200,12 @@ export class MessageService {
const starred = this.store.discuss.starred;
if (isStarred) {
starred.counter++;
if (!starred.messages.includes(message)) {
if (message.notIn(starred.messages)) {
starred.messages.push(message);
}
} else {
starred.counter--;
removeFromArrayWithPredicate(starred.messages, ({ id }) => id === message.id);
removeFromArrayWithPredicate(starred.messages, (msg) => msg.eq(message));
}
}
@@ -458,7 +457,7 @@ export class MessageService {
})
: undefined,
});
if (notification.message.author !== this.store.self) {
if (!notification.message.author?.eq(this.store.self)) {
return;
}
const thread = notification.message.originThread;
@@ -487,7 +486,7 @@ export class MessageService {
}
this.updateNotificationGroup(group, data);
if (group.notifications.length === 0) {
removeFromArrayWithPredicate(this.store.notificationGroups, (gr) => gr.id === group.id);
removeFromArrayWithPredicate(this.store.notificationGroups, (gr) => gr.eq(group));
}
return group;
}
@@ -1,9 +1,11 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { _t } from "@web/core/l10n/translation";
let nextId = 1;
export class NotificationGroup {
export class NotificationGroup extends Record {
/** @type {import("@mail/core/common/notification_model").Notification[]} */
notifications = [];
/** @type {string} */
@@ -20,11 +22,12 @@ export class NotificationGroup {
_store;
constructor(store) {
super();
this._store = store;
this._store.notificationGroups.push(this);
this.id = nextId++;
// return reactive
return store.notificationGroups.find((group) => group === this);
return store.notificationGroups.find((group) => group.eq(this));
}
get iconSrc() {
@@ -1,8 +1,10 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { _t } from "@web/core/l10n/translation";
export class Notification {
export class Notification extends Record {
/** @type {number} */
id;
/** @type {number} */
@@ -19,6 +21,7 @@ export class Notification {
_store;
constructor(store, data) {
super();
Object.assign(this, {
id: data.id,
_store: store,
@@ -31,8 +31,8 @@ partnerCompareRegistry.add(
(p1, p2, { thread }) => {
if (thread) {
const followerList = [...thread.followers];
const isFollower1 = followerList.some((follower) => follower.partner === p1);
const isFollower2 = followerList.some((follower) => follower.partner === p2);
const isFollower1 = followerList.some((follower) => p1.eq(follower.partner));
const isFollower2 = followerList.some((follower) => p2.eq(follower.partner));
if (isFollower1 && !isFollower2) {
return -1;
}
@@ -1,5 +1,7 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
/**
* @typedef {'offline' | 'bot' | 'online' | 'away' | 'im_partner' | undefined} ImStatus
* @typedef Data
@@ -10,7 +12,7 @@
* @property {ImStatus} im_status
*/
export class Persona {
export class Persona extends Record {
/** @type {string} */
localId;
/** @type {number} */
@@ -0,0 +1,25 @@
/* @odoo-module */
import { toRaw } from "@odoo/owl";
export class Record {
/** @param {Record} record */
eq(record) {
return toRaw(this) === toRaw(record);
}
/** @param {Record} record */
notEq(record) {
return !this.eq(record);
}
/** @param {Record[]} list */
in(list) {
return list.some((record) => record.eq(this));
}
/** @param {Record[]} list */
notIn(list) {
return !this.in(list);
}
}
@@ -1,12 +1,15 @@
/* @odoo-module */
export class ScrollPosition {
import { Record } from "@mail/core/common/record";
export class ScrollPosition extends Record {
/** @type {number|undefined} */
top;
/** @type {number|undefined} */
left;
constructor(top, left) {
super();
this.top = top;
this.left = left;
}
@@ -130,7 +130,7 @@ export class SuggestionService {
const mainSuggestionList = [];
const extraSuggestionList = [];
for (const partner of partners) {
if (partner === this.store.odoobot) {
if (partner.eq(this.store.odoobot)) {
// ignore archived partners (except OdooBot)
continue;
}
+2 -2
View File
@@ -159,7 +159,7 @@ export class Thread extends Component {
}
});
onWillUpdateProps((nextProps) => {
if (nextProps.thread !== this.props.thread) {
if (nextProps.thread.notEq(this.props.thread)) {
this.lastJumpPresent = nextProps.jumpPresent;
}
this.threadService.fetchNewMessages(nextProps.thread);
@@ -217,7 +217,7 @@ export class Thread extends Component {
return false;
}
if (msg.author !== prevMsg.author) {
if (!msg.author?.eq(prevMsg.author)) {
return false;
}
if (msg.resModel !== prevMsg.resModel || msg.resId !== prevMsg.resId) {
@@ -1,5 +1,6 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { ScrollPosition } from "@mail/core/common/scroll_position_model";
import { createLocalId } from "@mail/utils/common/misc";
@@ -20,7 +21,7 @@ import { Deferred } from "@web/core/utils/concurrency";
* @property {boolean} checked
*/
export class Thread {
export class Thread extends Record {
/** @type {number} */
id;
/** @type {string} */
@@ -135,6 +136,7 @@ export class Thread {
is_editable;
constructor(store, data) {
super();
Object.assign(this, {
id: data.id,
model: data.model,
@@ -194,7 +196,7 @@ export class Thread {
get allowCalls() {
return (
["chat", "channel", "group"].includes(this.type) &&
this.correspondent !== this._store.odoobot
!this.correspondent?.eq(this._store.odoobot)
);
}
@@ -317,8 +319,8 @@ export class Thread {
}
get hasSelfAsMember() {
return this.channelMembers.some(
(channelMember) => channelMember.persona === this._store.self
return this.channelMembers.some((channelMember) =>
channelMember.persona?.eq(this._store.self)
);
}
@@ -326,7 +328,7 @@ export class Thread {
* @param {import("@mail/core/common/message_model").Message} message
*/
hasMessage(message) {
return this.messages.some(({ id }) => id === message.id);
return message.in(this.messages);
}
get invitationLink() {
@@ -377,7 +379,7 @@ export class Thread {
}
const lastMessageSeenByAllId = Math.min(...otherLastSeenMessageIds);
const orderedSelfSeenMessages = this.persistentMessages.filter((message) => {
return message.author === this._store.self && message.id <= lastMessageSeenByAllId;
return message.author?.eq(this._store.self) && message.id <= lastMessageSeenByAllId;
});
if (!orderedSelfSeenMessages || orderedSelfSeenMessages.length === 0) {
return false;
@@ -273,10 +273,10 @@ export class ThreadService {
// feed needactions
// same for needaction messages, special case for mailbox:
// kinda "fetch new/more" with needactions on many origin threads at once
if (thread === this.store.discuss.inbox) {
if (thread.eq(this.store.discuss.inbox)) {
for (const message of fetched) {
const thread = message.originThread;
if (!thread.needactionMessages.includes(message)) {
if (message.notIn(thread.needactionMessages)) {
thread.needactionMessages.unshift(message);
}
}
@@ -367,7 +367,7 @@ export class ThreadService {
});
if (!thread.isLoaded) {
thread.messages.push(message);
if (message.isNeedaction && !thread.needactionMessages.includes(message)) {
if (message.isNeedaction && message.notIn(thread.needactionMessages)) {
thread.needactionMessages.push(message);
}
}
@@ -741,9 +741,9 @@ export class ThreadService {
continue;
}
if (
member.persona.id !== thread._store.user?.id ||
member.persona.notEq(thread._store.user) ||
(serverData.channel.channelMembers[0][1].length === 1 &&
member.persona.id === thread._store.user?.id)
member.persona?.eq(thread._store.user))
) {
thread.chatPartnerId = member.persona.id;
}
@@ -944,7 +944,7 @@ export class ThreadService {
}
const data = await this.rpc(this.getMessagePostRoute(thread), params);
if (thread.type !== "chatter") {
removeFromArrayWithPredicate(thread.messages, ({ id }) => id === tmpMsg.id);
removeFromArrayWithPredicate(thread.messages, (msg) => msg.eq(tmpMsg));
delete this.store.messages[tmpMsg.id];
}
if (!data) {
@@ -961,7 +961,7 @@ export class ThreadService {
const message = this.messageService.insert(
Object.assign(data, { body: markup(data.body) })
);
if (!thread.messages.some(({ id }) => id === message.id)) {
if (message.notIn(thread.messages)) {
thread.messages.push(message);
}
if (!message.isEmpty && this.store.hasLinkPreviewFeature) {
@@ -1,5 +1,7 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
/**
* @typedef Data
* @property {string} activity_category
@@ -30,7 +32,7 @@
* @property {[number, string]} write_uid
*/
export class Activity {
export class Activity extends Record {
/** @type {string} */
activity_category;
/** @type {[number, string]} */
@@ -94,6 +96,7 @@ export class Activity {
* @returns {Activity}
*/
constructor(store, id) {
super();
Object.assign(this, {
id,
_store: store,
+1 -1
View File
@@ -228,7 +228,7 @@ export class Chatter extends Component {
}
allFollowers.push(...this.state.thread.followers);
const followers = allFollowers.slice(0, 5).map(({ partner }) => {
if (partner === this.store.self) {
if (partner.eq(this.store.self)) {
return `<span class="text-muted" title="${escape(partner.email)}">me</span>`;
}
const text = partner.email ? partner.emailWithoutDomain : partner.name;
@@ -73,12 +73,12 @@ export class MailCoreWeb {
const data = Object.assign(payload, { body: markup(payload.body) });
const message = this.messageService.insert(data);
const inbox = this.store.discuss.inbox;
if (!inbox.messages.includes(message)) {
if (message.notIn(inbox.messages)) {
inbox.messages.push(message);
inbox.counter++;
}
const thread = message.originThread;
if (!thread.needactionMessages.includes(message)) {
if (message.notIn(thread.needactionMessages)) {
thread.needactionMessages.push(message);
thread.message_needaction_counter++;
}
@@ -112,7 +112,7 @@ export class MailCoreWeb {
removeFromArray(message.needaction_partner_ids, partnerIndex);
removeFromArrayWithPredicate(inbox.messages, ({ id }) => id === messageId);
const history = this.store.discuss.history;
if (!history.messages.includes(message)) {
if (message.notIn(history.messages)) {
history.messages.push(message);
}
}
@@ -118,10 +118,16 @@ export class MessagingMenu extends Component {
*
* In each group, thread with most recent message comes first
*/
if (a.correspondent === this.store.odoobot && b.correspondent !== this.store.odoobot) {
if (
a.correspondent?.eq(this.store.odoobot) &&
!b.correspondent?.eq(this.store.odoobot)
) {
return 1;
}
if (b.correspondent === this.store.odoobot && a.correspondent !== this.store.odoobot) {
if (
b.correspondent?.eq(this.store.odoobot) &&
!a.correspondent?.eq(this.store.odoobot)
) {
return -1;
}
if (a.hasNeedactionMessages && !b.hasNeedactionMessages) {
@@ -233,7 +239,7 @@ export class MessagingMenu extends Component {
});
// Close the related chat window as having both the form view
// and the chat window does not look good.
this.store.chatWindows.find(({ thr }) => thr === thread)?.close();
this.store.chatWindows.find(({ thr }) => thr?.eq(thread))?.close();
} else {
this.threadService.open(thread);
}
@@ -74,7 +74,7 @@
<t t-if="message.isSelfAuthored">
<i class="fa fa-mail-reply me-1"/>You:
</t>
<t t-elif="message.author and (message.author.id !== thread.correspondent?.id or message.author.type !== thread.correspondent?.type)">
<t t-elif="message.author and !message.author?.eq(thread.correspondent)">
<t t-esc="message.author.name"/>:
</t>
</t>
@@ -5,7 +5,7 @@ import { ThreadService, threadService } from "@mail/core/common/thread_service";
import { parseEmail } from "@mail/js/utils";
import { createLocalId } from "@mail/utils/common/misc";
import { markup, toRaw } from "@odoo/owl";
import { markup } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
@@ -93,7 +93,7 @@ patch(ThreadService.prototype, {
followedThread: thread,
...followerData,
});
if (toRaw(follower) !== toRaw(thread.selfFollower)) {
if (follower.notEq(thread.selfFollower)) {
thread.followers.add(follower);
}
}
@@ -195,7 +195,7 @@ patch(ThreadService.prototype, {
followedThread: thread,
...data,
});
if (toRaw(follower) !== toRaw(thread.selfFollower)) {
if (follower.notEq(thread.selfFollower)) {
thread.followers.add(follower);
}
}
@@ -229,7 +229,7 @@ patch(ThreadService.prototype, {
[follower.partner.id],
]);
const thread = follower.followedThread;
if (toRaw(follower) === toRaw(thread.selfFollower)) {
if (follower.eq(thread.selfFollower)) {
thread.selfFollower = undefined;
} else {
thread.followers.delete(follower);
@@ -56,7 +56,7 @@ export class Call extends Component {
}
get isActiveCall() {
return Boolean(this.props.thread.id === this.rtc.state?.channel?.id);
return Boolean(this.props.thread.eq(this.rtc.state?.channel));
}
get minimized() {
@@ -57,7 +57,7 @@ export class CallActionList extends Component {
}
get isOfActiveCall() {
return Boolean(this.props.thread.id === this.rtc.state?.channel?.id);
return Boolean(this.props.thread.eq(this.rtc.state?.channel));
}
get isSmall() {
@@ -75,7 +75,7 @@
<i class="fa fa-times fa-stack-1x" t-att-class="{ 'fa-lg': !isSmall }"/>
</div>
</button>
<t t-if="props.thread === rtc.state.channel" t-set="callText">Disconnect</t>
<t t-if="props.thread.eq(rtc.state.channel)" t-set="callText">Disconnect</t>
<t t-else="" t-set="callText">Join Call</t>
<button class="btn d-flex m-1 border-0 rounded-circle shadow-none"
t-att-aria-label="callText"
@@ -49,7 +49,7 @@ export class CallParticipantCard extends Component {
if (!this.rtcSession) {
return false;
}
return this.rtcSession?.id !== this.rtc.state.selfSession?.id;
return !this.rtcSession?.eq(this.rtc.state.selfSession);
}
get rtcSession() {
@@ -67,7 +67,7 @@ export class CallParticipantCard extends Component {
get showConnectionState() {
return Boolean(
this.isOfActiveCall &&
!(this.rtcSession.channelMember?.persona.localId === this.store.self?.localId) &&
!this.rtcSession.channelMember?.persona.eq(this.store.self) &&
!HIDDEN_CONNECTION_STATES.has(this.rtcSession.connectionState)
);
}
@@ -97,7 +97,7 @@ export class CallParticipantCard extends Component {
}
if (this.rtcSession) {
const channel = this.rtcSession.channel;
if (channel.activeRtcSession === this.rtcSession) {
if (this.rtcSession.eq(channel.activeRtcSession)) {
channel.activeRtcSession = undefined;
} else {
channel.activeRtcSession = this.rtcSession;
@@ -349,7 +349,7 @@ export class Rtc {
*/
endCall(channel = this.state.channel) {
channel.rtcInvitingSessionId = undefined;
if (this.state.channel === channel) {
if (channel.eq(this.state.channel)) {
this.clear();
this.soundEffectsService.play("channel-leave");
}
@@ -539,7 +539,7 @@ export class Rtc {
return;
}
this.state.hasPendingRequest = true;
const isActiveCall = Boolean(this.state.channel && this.state.channel === channel);
const isActiveCall = channel.eq(this.state.channel);
if (this.state.channel) {
await this.leaveCall(this.state.channel);
}
@@ -623,7 +623,7 @@ export class Rtc {
return;
}
for (const session of Object.values(this.state.channel.rtcSessions)) {
if (session.peerConnection || session.id === this.state.selfSession.id) {
if (session.peerConnection || session.eq(this.state.selfSession)) {
continue;
}
this.log(session, "init call", { step: "init call" });
@@ -788,7 +788,7 @@ export class Rtc {
this.state.logs.set("selfSessionId", this.state.selfSession?.id);
this.state.logs.set("hasTURN", hasTurn(this.state.iceServers));
const channelProxy = reactive(this.state.channel, () => {
if (channel !== this.state.channel) {
if (channel.notEq(this.state.channel)) {
throw new Error("channel has changed");
}
if (this.state.channel) {
@@ -1150,7 +1150,7 @@ export class Rtc {
}
}
for (const session of Object.values(this.state.channel.rtcSessions)) {
if (session.id === this.state.selfSession.id) {
if (session.eq(this.state.selfSession)) {
continue;
}
await this.updateRemote(session, "video");
@@ -1353,7 +1353,7 @@ export class Rtc {
this.state.audioTrack = audioTrack;
await this.linkVoiceActivation();
for (const session of Object.values(this.state.channel.rtcSessions)) {
if (session.id === this.state.selfSession.id) {
if (session.eq(this.state.selfSession)) {
continue;
}
await this.updateRemote(session, "audio");
@@ -1438,7 +1438,7 @@ export class Rtc {
deleteSession(id) {
const session = this.store.rtcSessions[id];
if (session) {
if (this.state.selfSession && session.id === this.state.selfSession.id) {
if (this.state.selfSession && session.eq(this.state.selfSession)) {
this.endCall();
}
delete this.store.threads[createLocalId("discuss.channel", session.channelId)]
@@ -1,8 +1,9 @@
/* @odoo-module */
import { Record } from "@mail/core/common/record";
import { createLocalId } from "@mail/utils/common/misc";
export class RtcSession {
export class RtcSession extends Record {
// Server data
channelId;
channelMemberId;
@@ -13,7 +13,7 @@ threadActionsRegistry
condition(component) {
return (
component.thread?.allowCalls &&
component.thread !== component.rtc.state.channel &&
!component.thread?.eq(component.rtc.state.channel) &&
!component.props.chatWindow?.hidden
);
},
@@ -43,7 +43,7 @@ threadActionsRegistry
name: _t("Show Call Settings"),
nameActive: _t("Hide Call Settings"),
sequence(component) {
return component.props.chatWindow && component.thread === component.rtc.state.channel
return component.props.chatWindow && component.thread?.eq(component.rtc.state.channel)
? 6
: 60;
},
@@ -4,7 +4,7 @@
<div
t-if="Object.keys(props.thread.rtcSessions).length > 0"
class="fa fa-volume-up ms-1 me-3"
t-att-class="{ 'text-danger': rtc.state.channel?.id === props.thread.id }"
t-att-class="{ 'text-danger': props.thread.eq(rtc.state.channel) }"
/>
</t>
</templates>
@@ -79,7 +79,7 @@ export class ChannelInvitation extends Component {
}
onClickSelectablePartner(partner) {
if (this.state.selectedPartners.includes(partner)) {
if (partner.in(this.state.selectedPartners)) {
const index = this.state.selectedPartners.indexOf(partner);
if (index !== -1) {
this.state.selectedPartners.splice(index, 1);
@@ -19,7 +19,7 @@
<t name="selectablePartnerDetail">
<span class="flex-grow-1 mx-2 text-truncate text-start fs-6" t-esc="selectablePartner.name"/>
</t>
<input class="form-check-input flex-shrink-0" type="checkbox" t-att-checked="state.selectedPartners.includes(selectablePartner) ? 'checked' : undefined"/>
<input class="form-check-input flex-shrink-0" type="checkbox" t-att-checked="selectablePartner.in(state.selectedPartners) ? 'checked' : undefined"/>
</div>
</t>
<div t-if="state.selectablePartners.length === 0">No user found that is not already a member of this channel.</div>
@@ -31,7 +31,7 @@ export class ChannelMemberList extends Component {
if (this.store.inPublicPage) {
return false;
}
if (member.persona === this.store.self) {
if (member.persona?.eq(this.store.self)) {
return false;
}
if (member.persona.type === "guest") {
@@ -240,7 +240,7 @@ export class DiscussCoreCommon {
res_id: channel.id,
model: channel.model,
});
if (!channel.messages.includes(message)) {
if (message.notIn(channel.messages)) {
if (!channel.loadNewer) {
channel.messages.push(message);
} else if (channel.state === "loading") {
@@ -254,13 +254,13 @@ export class DiscussCoreCommon {
}
if (message.isNeedaction) {
const inbox = this.store.discuss.inbox;
if (!inbox.messages.includes(message)) {
if (message.notIn(inbox.messages)) {
inbox.messages.push(message);
if (notif.id > this.store.initBusId) {
inbox.counter++;
}
}
if (!channel.needactionMessages.includes(message)) {
if (message.notIn(channel.needactionMessages)) {
channel.needactionMessages.push(message);
if (notif.id > this.store.initBusId) {
channel.message_needaction_counter++;
@@ -288,9 +288,8 @@ export class DiscussCoreCommon {
!channel.loadNewer &&
!message.isSelfAuthored &&
channel.composer.isFocused &&
channel.newestPersistentMessage &&
!this.store.guest &&
channel.newestPersistentMessage === channel.newestMessage
channel.newestPersistentMessage?.eq(channel.newestMessage)
) {
this.threadService.markAsRead(channel);
}
@@ -29,8 +29,8 @@ partnerCompareRegistry.add(
"discuss.members",
(p1, p2, { thread }) => {
if (thread?.model === "discuss.channel") {
const isMember1 = thread.channelMembers.some((member) => member.persona === p1);
const isMember2 = thread.channelMembers.some((member) => member.persona === p2);
const isMember1 = thread.channelMembers.some((member) => p1.eq(member.persona));
const isMember2 = thread.channelMembers.some((member) => p2.eq(member.persona));
if (isMember1 && !isMember2) {
return -1;
}
@@ -51,7 +51,7 @@ export class DiscussCoreWeb {
if (this.ui.isSmall || message.isSelfAuthored) {
return;
}
if (channel.correspondent === this.store.odoobot && this.store.odoobotOnboarding) {
if (channel.correspondent?.eq(this.store.odoobot) && this.store.odoobotOnboarding) {
// this cancels odoobot onboarding auto-opening of chat window
this.store.odoobotOnboarding = false;
return;
@@ -28,7 +28,7 @@ export class PinnedMessagesPanel extends Component {
this.messagePinService.fetchPinnedMessages(this.props.thread);
});
onWillUpdateProps(async (nextProps) => {
if (nextProps.thread.id !== this.props.thread.id) {
if (nextProps.thread.notEq(this.props.thread)) {
this.messagePinService.fetchPinnedMessages(nextProps.thread);
}
});
@@ -62,7 +62,7 @@ export class Typing {
getTypingMembers(channel) {
return [...(this.memberIdsByChannelId.get(channel.id) ?? new Set())]
.map((id) => this.channelMemberService.insert({ id }))
.filter((member) => member.persona !== this.storeService.self);
.filter((member) => !member.persona?.eq(this.storeService.self));
}
/**
+4 -4
View File
@@ -245,7 +245,7 @@ export function useMessageHighlight(duration = 2000) {
* @param {import("@mail/core/thread_model").Thread} thread
*/
async highlightMessage(message, thread) {
if (message.originThread.localId !== thread.localId) {
if (thread.notEq(message.originThread)) {
return;
}
await threadService.loadAround(thread, message.id);
@@ -407,7 +407,7 @@ export function useMessageToReplyTo() {
* @returns {boolean}
*/
isNotSelected(thread, message) {
return this.thread === thread && this.message !== message;
return thread.eq(this.thread) && message.notEq(this.message);
},
/**
* @param {import("@mail/core/common/thread_model").Thread} thread
@@ -415,7 +415,7 @@ export function useMessageToReplyTo() {
* @returns {boolean}
*/
isSelected(thread, message) {
return this.thread === thread && this.message === message;
return thread.eq(this.thread) && message.eq(this.message);
},
/** @type {import("@mail/core/common/message_model").Message|null} */
message: null,
@@ -426,7 +426,7 @@ export function useMessageToReplyTo() {
* @param {import("@mail/core/common/message_model").Message} message
*/
toggle(thread, message) {
if (this.message === message) {
if (message.eq(this.message)) {
this.cancel();
} else {
Object.assign(this, { message, thread });