[IMP] mail: search a discuss thread

See https://github.com/odoo/enterprise/pull/43980

task-2359037

closes odoo/odoo#118794

Related: odoo/enterprise#43980
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Didier (did)
2023-10-03 19:19:50 +00:00
parent 7c4a8f6118
commit 8fa2a6fe11
43 changed files with 1254 additions and 237 deletions
+4 -4
View File
@@ -52,17 +52,17 @@ class ChannelController(http.Controller):
@http.route("/discuss/channel/messages", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def discuss_channel_messages(self, channel_id, before=None, after=None, limit=30, around=None):
def discuss_channel_messages(self, channel_id, search_term=None, before=None, after=None, limit=30, around=None):
channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_context_or_raise(channel_id=int(channel_id))
domain = [
("res_id", "=", channel_id),
("model", "=", "discuss.channel"),
("message_type", "!=", "user_notification"),
]
messages = channel_member_sudo.env["mail.message"]._message_fetch(domain, before, after, around, limit)
res = channel_member_sudo.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
if not request.env.user._is_public() and not around:
messages.set_message_done()
return messages.message_format()
res["messages"].set_message_done()
return {**res, "messages": res["messages"].message_format()}
@http.route("/discuss/channel/pinned_messages", methods=["POST"], type="json", auth="public")
@add_guest_to_context
+9 -8
View File
@@ -6,19 +6,20 @@ from odoo.http import request
class MailboxController(http.Controller):
@http.route("/mail/inbox/messages", methods=["POST"], type="json", auth="user")
def discuss_inbox_messages(self, before=None, after=None, limit=30, around=None):
def discuss_inbox_messages(self, search_term=None, before=None, after=None, limit=30, around=None):
partner_id = request.env.user.partner_id.id
domain = [("needaction", "=", True)]
return (request.env["mail.message"]
._message_fetch(domain, before, after, around, limit)
._message_format_personalize(partner_id))
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
return {**res, "messages": res["messages"]._message_format_personalize(partner_id)}
@http.route("/mail/history/messages", methods=["POST"], type="json", auth="user")
def discuss_history_messages(self, before=None, after=None, limit=30):
def discuss_history_messages(self, search_term=None, before=None, after=None, limit=30, around=None):
domain = [("needaction", "=", False)]
return request.env["mail.message"]._message_fetch(domain, before, after, limit=limit).message_format()
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
return {**res, "messages": res["messages"].message_format()}
@http.route("/mail/starred/messages", methods=["POST"], type="json", auth="user")
def discuss_starred_messages(self, before=None, after=None, limit=30):
def discuss_starred_messages(self, search_term=None, before=None, after=None, limit=30, around=None):
domain = [("starred_partner_ids", "in", [request.env.user.partner_id.id])]
return request.env["mail.message"]._message_fetch(domain, before, after, limit=limit).message_format()
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
return {**res, "messages": res["messages"].message_format()}
+4 -4
View File
@@ -16,16 +16,16 @@ class ThreadController(http.Controller):
return thread._get_mail_thread_data(request_list)
@http.route("/mail/thread/messages", methods=["POST"], type="json", auth="user")
def mail_thread_messages(self, thread_model, thread_id, before=None, after=None, around=None, limit=30):
def mail_thread_messages(self, thread_model, thread_id, search_term=None, before=None, after=None, around=None, limit=30):
domain = [
("res_id", "=", int(thread_id)),
("model", "=", thread_model),
("message_type", "!=", "user_notification"),
]
messages = request.env["mail.message"]._message_fetch(domain, before, after, around, limit)
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
if not request.env.user._is_public():
messages.set_message_done()
return messages.message_format()
res["messages"].set_message_done()
return {**res, "messages": res["messages"].message_format()}
@http.route("/mail/partner/from_email", methods=["POST"], type="json", auth="user")
def mail_thread_partner_from_email(self, emails):
@@ -898,7 +898,8 @@ class Channel(models.Model):
domain = ["&", ("model", "=", "discuss.channel"), ("res_id", "in", self.ids)]
if last_id:
domain.append(("id", "<", last_id))
return self.env['mail.message']._message_fetch(domain=domain, limit=limit).message_format()
res = self.env['mail.message']._message_fetch(domain=domain, limit=limit)
return res["messages"].message_format()
def _channel_format(self, fields=None):
if not fields:
+17 -14
View File
@@ -933,28 +933,31 @@ class Message(models.Model):
return {}
@api.model
def _message_fetch(self, domain, before=None, after=None, around=None, limit=30):
""" Get a limited amount of formatted messages with provided domain.
:param domain: the domain to filter messages;
:param before: fetch messages before this message id.
:param after: fetch messages after this message id.
:param around: fetch messages around this message id
i.e. limit//2 before and limit//2 after.
:param limit: the maximum amount of messages to get;
:returns: record set of mail.message sorted in descending order.
"""
def _message_fetch(self, domain, search_term=None, before=None, after=None, around=None, limit=30):
res = {}
if search_term:
# we replace every space by a % to avoid hard spacing matching
search_term = search_term.replace(" ", "%")
domain = expression.AND([domain, expression.OR([
[("attachment_ids.name", "ilike", search_term)],
[("body", "ilike", search_term)],
[("subject", "ilike", search_term)],
[("subtype_id.description", "ilike", search_term)],
])])
domain = expression.AND([domain, [("message_type", "not in", ["user_notification", "notification"])]])
res["count"] = self.search_count(domain)
if around:
messages_before = self.search(domain=[*domain, ('id', '<=', around)], limit=limit // 2, order="id DESC")
messages_after = self.search(domain=[*domain, ('id', '>', around)], limit=limit // 2, order='id ASC')
return (messages_after + messages_before).sorted('id', reverse=True)
return {**res, "messages": (messages_after + messages_before).sorted('id', reverse=True)}
if before:
domain = expression.AND([domain, [('id', '<', before)]])
if after:
domain = expression.AND([domain, [('id', '>', after)]])
message_ids = self.search(domain, limit=limit, order='id ASC' if after else 'id DESC')
res["messages"] = self.search(domain, limit=limit, order='id ASC' if after else 'id DESC')
if after:
message_ids = message_ids.sorted('id', reverse=True)
return message_ids
res["messages"] = res["messages"].sorted('id', reverse=True)
return res
def message_format(self, format_reply=True, msg_vals=None):
""" Get the message values in the format for web client. Since message
@@ -13,10 +13,11 @@ import { url } from "@web/core/utils/urls";
* @property {import("models").Attachment[]} attachments
* @property {function} unlinkAttachment
* @property {number} imagesHeight
* @property {ReturnType<import('@mail/core/common/message_search_hook').useMessageSearch>} [messageSearch]
* @extends {Component<Props, Env>}
*/
export class AttachmentList extends Component {
static props = ["attachments", "unlinkAttachment", "imagesHeight"];
static props = ["attachments", "unlinkAttachment", "imagesHeight", "messageSearch?"];
static template = "mail.AttachmentList";
setup() {
@@ -65,7 +65,7 @@
<t t-ref="nonImageMain">
<div class="o-mail-AttachmentCard-image o_image flex-shrink-0 m-1" role="menuitem" aria-label="Preview" t-att-tabindex="-1" t-att-aria-disabled="false" t-att-data-mimetype="attachment.mimetype"/>
<div class="overflow-auto d-flex justify-content-center flex-column px-1">
<div t-if="attachment.name" class="text-truncate" t-esc="attachment.name"/>
<div t-if="attachment.name" class="text-truncate" t-out="props.messageSearch?.highlight(attachment.name) ?? attachment.name"/>
<small t-if="attachment.extension" class="text-uppercase" t-esc="attachment.extension"/>
</div>
</t>
@@ -37,3 +37,7 @@
.o-yellow {
color: $yellow;
}
.o-z-index-1 {
z-index: 1;
}
@@ -44,6 +44,8 @@ import { useMessageActions } from "./message_actions";
* @property {import("@mail/utils/common/hooks").MessageToReplyTo} [messageToReplyTo]
* @property {boolean} [squashed]
* @property {import("models").Thread} [thread]
* @property {ReturnType<import('@mail/core/common/message_search_hook').useMessageSearch>} [messageSearch]
* @property {String} [className]
* @extends {Component<Props, Env>}
*/
export class Message extends Component {
@@ -78,6 +80,8 @@ export class Message extends Component {
"messageToReplyTo?",
"squashed?",
"thread?",
"messageSearch?",
"className?",
];
static template = "mail.Message";
@@ -180,16 +184,17 @@ export class Message extends Component {
get attClass() {
return {
[this.props.className]: true,
"o-highlighted bg-view shadow-lg": this.props.highlighted,
"o-selfAuthored": this.message.isSelfAuthored,
"o-selfAuthored": this.message.isSelfAuthored && !this.env.messageCard,
"o-selected": this.props.messageToReplyTo?.isSelected(
this.props.thread,
this.props.message
),
"o-squashed pb-1": this.props.squashed,
"py-1": !this.props.squashed,
"mt-2": !this.props.squashed && this.props.thread,
"px-3": !this.props.isInChatWindow,
"mt-2": !this.props.squashed && this.props.thread && !this.env.messageCard,
"px-3": !this.props.isInChatWindow && !this.env.messageCard,
"px-2": this.props.isInChatWindow,
"opacity-50": this.props.messageToReplyTo?.isNotSelected(
this.props.thread,
@@ -36,7 +36,7 @@
}
}
.o-mail-ChatWindow .o-mail-Message:not(.o-cancelSelfAuthored).o-selfAuthored {
.o-mail-ChatWindow .o-mail-Message.o-selfAuthored {
flex-direction: row-reverse;
.o-mail-Message-core, .o-mail-Message-textContent {
@@ -74,6 +74,10 @@
}
}
.o-mail-Message-searchHighlight {
background: rgba($warning, 0.75);
}
.o-mail-Message-starred {
color: $o-main-favorite-color;
}
@@ -69,7 +69,7 @@
'justify-content-end': isAlignedRight,
'ps-4': env.inChatWindow and isAlignedRight and !state.isEditing,
'ps-2': env.inChatWindow and isAlignedRight and state.isEditing,
'pe-4': env.inChatWindow and !isAlignedRight and !state.isEditing,
'pe-4': env.inChatWindow and !isAlignedRight and !state.isEditing and !env.messageCard,
'pe-2': env.inChatWindow and !isAlignedRight and composerViewInEditing,
}"
>
@@ -94,11 +94,11 @@
<Composer t-if="state.isEditing" autofocus="true" composer="message.composer" messageComponent="constructor" onDiscardCallback.bind="exitEditMode" onPostCallback.bind="exitEditMode" mode="env.inChatter ? 'extended' : 'compact'" sidebar="false"/>
<t t-else="">
<button t-if="message.type === 'email'" class="o-mail-Message-originalEmailText d-block float-end p-1 mb-1 btn btn-link opacity-75 fst-italic" t-att-class="env.inChatWindow and isAlignedRight ? 'me-3' : 'ms-3'" t-esc="originalEmailText" t-on-click="() => this.state.originalEmail = !this.state.originalEmail"/>
<em t-if="message.subject and !message.isSubjectSimilarToOriginThreadName and !message.isSubjectDefault" class="mb-1 me-2">Subject: <t t-esc="message.subject"/></em>
<em t-if="message.subject and !message.isSubjectSimilarToOriginThreadName and !message.isSubjectDefault" class="mb-1 me-2">Subject: <t t-out="props.messageSearch?.highlight(message.subject) ?? message.subject"/></em>
<div t-if="message.type === 'email'" t-ref="shadowBody"/>
<t t-elif="message.body" t-out="message.body"/>
<t t-if="message.body" t-out="props.messageSearch?.highlight(message.body) ?? message.body"/>
<!-- <small t-if="message.editDate" class="o-mail-Message-edited fst-italic text-muted" t-att-class="{ 'ms-2': !message.isBodyEmpty }" t-att-title="message.editDatetimeHuge">(edited)</small> --> <!-- DISABLED because new messages sent by email are wrongly flagged as (edited) -->
<t t-if="showSubtypeDescription" t-out="message.subtypeDescription"/>
<t t-if="showSubtypeDescription" t-out="props.messageSearch?.highlight(message.subtypeDescription) ?? message.subtypeDescription"/>
</t>
</div>
</div>
@@ -110,7 +110,8 @@
t-if="message.attachments.length > 0"
attachments="message.attachments.map((a) => a)"
unlinkAttachment.bind="onClickAttachmentUnlink"
imagesHeight="message.attachments.length === 1 ? 300 : 75"/>
imagesHeight="message.attachments.length === 1 ? 300 : 75"
messageSearch="props.messageSearch"/>
<LinkPreviewList t-if="message.linkPreviews.length > 0 and store.hasLinkPreviewFeature and !message.linkPreviewSquash" linkPreviews="message.linkPreviews" deletable="deletable"/>
</div>
<t t-if="!message.hasTextContent or env.inChatWindow" t-call="mail.Message.actions"/>
@@ -0,0 +1,66 @@
/* @odoo-module */
import { Message } from "@mail/core/common/message";
import { useVisible } from "@mail/utils/common/hooks";
import { Component, useState, useSubEnv } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
/**
* @typedef {Object} Props
* @property {string} [emptyText]
* @property {import("@mail/core/common/message_model").Message[]} messages
* @property {ReturnType<import('@mail/core/common/message_search_hook').useMessageSearch>} [messageSearch]
* @property {function} [loadMore]
* @property {string} mode
* @property {function} [onClickJump]
* @property {function} [onClickUnpin]
* @property {function} [onLoadMoreVisible]
* @property {boolean} [showEmpty]
* @property {import("@mail/core/common/thread_model").Thread} thread
* @extends {Component<Props, Env>}
*/
export class MessageCardList extends Component {
static components = { Message };
static props = [
"emptyText?",
"messages",
"messageSearch?",
"loadMore?",
"mode",
"onClickJump?",
"onClickUnpin?",
"onLoadMoreVisible?",
"showEmpty?",
"thread",
];
static template = "mail.MessageCardList";
setup() {
this.ui = useState(useService("ui"));
useSubEnv({ messageCard: true });
this.loadMore = useVisible("load-more", () => this.props.onLoadMoreVisible?.());
}
/**
* Highlight the given message and scrolls to it. In small mode, the
* pin/search menus are closed beforewards
*
* @param {import('@mail/core/common/message_model').Message} message
*/
async onClickJump(message) {
this.props.onClickJump?.();
if (this.ui.isSmall || this.env.inChatWindow) {
this.env.pinMenu?.close();
this.env.searchMenu?.close();
}
// Give the time for menus to close before scrolling to the message.
await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve)));
await this.env.messageHighlight?.highlightMessage(message, this.props.thread);
}
get emptyText() {
return this.props.emptyText ?? _t("No messages found");
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.MessageCardList">
<div class="o-mail-MessageCardList d-flex flex-column" t-att-class="{ 'justify-content-center flex-grow-1': props.messages.length === 0 }" t-ref="message-list">
<div class="card mb-2" t-foreach="props.messages" t-as="message" t-key="message.id">
<div class="card-body py-2">
<div class="d-flex justify-content-end position-absolute top-0 end-0 o-z-index-1 m-2">
<button class="o-mail-MessageCard-jump btn rounded bg-400 badge opacity-0 flex-shrink-0" t-att-class="{ 'opacity-100 py-1 px-2': ui.isSmall }" t-on-click="() => this.onClickJump(message)">Jump</button>
<button t-if="props.mode === 'pin'" class="btn ms-2 p-0 text-700 shadow-none" t-att-class="{ 'fs-5 align-self-end': ui.isSmall }" title="Unpin" t-on-click="() => this.props.onClickUnpin(message)">
<i class="d-block fa fa-times"/>
</button>
</div>
<Message hasActions="false" message="message" thread="props.thread" messageSearch="props.messageSearch"/>
</div>
</div>
<span t-if="props.loadMore" t-ref="load-more"/>
<p t-if="props.showEmpty !== undefined ? props.showEmpty : props.messages.length === 0" t-esc="emptyText" class="text-center fst-italic text-500 fs-6"/>
</div>
</t>
</templates>
@@ -0,0 +1,102 @@
/* @odoo-module */
import { useSequential } from "@mail/utils/common/hooks";
import { useState, onWillUnmount, markup } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export const HIGHLIGHT_CLASS = "o-mail-Message-searchHighlight";
/**
* @param {string} searchTerm
* @param {string} target
*/
export function searchHighlight(searchTerm, target) {
if (!searchTerm) {
return target;
}
const htmlDoc = new DOMParser().parseFromString(target, "text/html");
for (const term of searchTerm.split(" ")) {
const regexp = new RegExp(`(${term})`, "gi");
const matchs = htmlDoc.evaluate(
`//*[text()[contains(translate(., '${term.toUpperCase()}', '${term.toLowerCase()}'), '${term.toLowerCase()}')]]`,
htmlDoc,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
);
for (let i = 0; i < matchs.snapshotLength; i++) {
const element = matchs.snapshotItem(i);
const newNode = [];
for (const node of element.childNodes) {
const match = node.textContent.match(regexp);
if (node.nodeType === Node.TEXT_NODE && match?.length > 0) {
let curIndex = 0;
for (const match of node.textContent.matchAll(regexp)) {
const start = htmlDoc.createTextNode(
node.textContent.slice(curIndex, match.index)
);
newNode.push(start);
const span = htmlDoc.createElement("span");
span.setAttribute("class", HIGHLIGHT_CLASS);
span.textContent = match[0];
newNode.push(span);
curIndex = match.index + match[0].length;
}
const end = htmlDoc.createTextNode(node.textContent.slice(curIndex));
newNode.push(end);
} else {
newNode.push(node);
}
}
element.replaceChildren(...newNode);
}
}
return markup(htmlDoc.body.innerHTML);
}
/** @param {import('@mail/core/common/thread_model').Thread} thread */
export function useMessageSearch(thread) {
const threadService = useService("mail.thread");
const sequential = useSequential();
const state = useState({
thread,
async search(before = false) {
if (this.searchTerm) {
this.searching = true;
const { count, loadMore, messages } = await sequential(() =>
threadService.search(this.searchTerm, this.thread, before)
);
this.searched = true;
this.searching = false;
this.count = count;
this.loadMore = loadMore;
if (before) {
this.messages.push(...messages);
} else {
this.messages = messages;
}
} else {
this.clear();
}
},
count: 0,
clear() {
this.messages = [];
this.searched = false;
this.searching = false;
this.searchTerm = undefined;
},
loadMore: false,
/** @type {import('@mail/core/common/message_model').Message[]} */
messages: [],
/** @type {string|undefined} */
searchTerm: undefined,
searched: false,
searching: false,
/** @param {string} target */
highlight: (target) => searchHighlight(state.searchTerm, target),
});
onWillUnmount(() => {
state.clear();
});
return state;
}
@@ -0,0 +1,91 @@
/* @odoo-module */
import { Component, onWillUpdateProps, useExternalListener, useState } from "@odoo/owl";
import { useAutofocus } from "@web/core/utils/hooks";
import { useMessageSearch } from "@mail/core/common/message_search_hook";
import { browser } from "@web/core/browser/browser";
import { ActionPanel } from "@mail/discuss/core/common/action_panel";
import { MessageCardList } from "./message_card_list";
import { _t } from "@web/core/l10n/translation";
/**
* @typedef {Object} Props
* @property {import("@mail/core/common/thread_model").Thread} thread
* @property {string} [className]
* @property {funtion} [closeSearch]
* @property {funtion} [onClickJump]
* @extends {Component<Props, Env>}
*/
export class SearchMessagesPanel extends Component {
static components = {
MessageCardList,
ActionPanel,
};
static props = ["thread", "className?", "closeSearch?", "onClickJump?"];
static template = "mail.SearchMessagesPanel";
setup() {
useAutofocus();
this.state = useState({ searchTerm: "", searchedTerm: "" });
this.messageSearch = useMessageSearch(this.props.thread);
useAutofocus();
useExternalListener(
browser,
"keydown",
(ev) => {
if (ev.key === "Escape") {
this.props.closeSearch?.();
}
},
{ capture: true }
);
onWillUpdateProps((nextProps) => {
if (nextProps.thread) {
this.messageSearch.thread = nextProps.thread;
}
});
}
get title() {
return _t("Search messages");
}
get MESSAGES_FOUND() {
if (this.messageSearch.messages.length === 0) {
return false;
}
return _t("%s messages found", this.messageSearch.count);
}
search() {
this.messageSearch.searchTerm = this.state.searchTerm;
this.messageSearch.search();
this.state.searchedTerm = this.state.searchTerm;
}
clear() {
this.state.searchTerm = "";
this.state.searchedTerm = this.state.searchTerm;
this.messageSearch.clear();
this.props.closeSearch?.();
}
/** @param {KeyboardEvent} ev */
onKeydownSearch(ev) {
if (ev.key !== "Enter") {
return;
}
if (!this.state.searchTerm) {
this.clear();
} else {
this.search();
}
}
onLoadMoreVisible() {
const before = this.messageSearch.messages
? Math.min(...this.messageSearch.messages.map((message) => message.id))
: false;
this.messageSearch.search(before);
}
}
@@ -0,0 +1,15 @@
.o-mail-SearchMessagesPanel {
flex-basis: 425px;
}
.o-mail-MessageCardList .card-body:hover .o-mail-MessageCard-jump {
opacity: 100 !important;
}
.o-mail-MessageCard-jump {
color: white;
}
.o_touch_device .o-mail-MessageCard-jump.btn {
font-size: 0.9rem;
}
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.SearchMessagesPanel">
<ActionPanel title="env.inChatter ? undefined : title">
<div class="d-flex pb-2">
<div class="input-group">
<div class="o_searchview form-control d-flex align-items-center py-1" role="search" aria-autocomplete="list">
<div class="o_searchview_input_container d-flex flex-grow-1 flex-wrap gap-1">
<input type="text" class="o_searchview_input flex-grow-1 w-auto border-0" accesskey="Q" placeholder="Search" t-model="state.searchTerm" t-on-keydown="onKeydownSearch" t-ref="autofocus" role="searchbox"/>
</div>
</div>
<button class="btn" t-att-class="state.searchedTerm === state.searchTerm ? 'btn-outline-primary' : 'btn-primary'" t-on-click="() => this.search()" aria-label="Search button">
<i t-if="!messageSearch.searching" class="o_searchview_icon oi oi-search" role="img" aria-label="Search Messages" title="Search Messages"/>
<i t-else="" class="fa fa-spin fa-spinner" aria-label="Search in progress" title="Search in progress"/>
</button>
</div>
<button t-if="env.inChatter" class="btn btn-outline-secondary ms-3" t-on-click="() => this.clear()" aria-label="Close button">
<i class="o_searchview_icon oi oi-close cursor-pointer" role="img" aria-label="Close search" title="close"/>
</button>
</div>
<p t-if="MESSAGES_FOUND" class="o-mail-SearchMessagesPanel-title py-1 mb-0 fw-bolder text-center text-uppercase text-700">
<t t-out="MESSAGES_FOUND"/>
</p>
<MessageCardList messages="messageSearch.messages" thread="props.thread" mode="'search'" messageSearch="messageSearch" showEmpty="messageSearch.messages.length === 0 and messageSearch.searched" onClickJump="() => this.props.onClickJump?.()" loadMore="messageSearch.loadMore" onLoadMoreVisible="onLoadMoreVisible"/>
</ActionPanel>
</t>
</templates>
+43 -42
View File
@@ -25,24 +25,25 @@ export const PRESENT_THRESHOLD = 2500;
* @property {import("@mail/utils/common/hooks").MessageToReplyTo} [messageToReplyTo]
* @property {"asc"|"desc"} [order="asc"]
* @property {import("models").Thread} thread
* @property {string} [searchTerm]
* @property {import("@web/core/utils/hooks").Ref} [scrollRef]
* @extends {Component<Props, Env>}
*/
export class Thread extends Component {
static components = { Message, Transition, DateSection };
static props = [
"isInChatWindow?",
"hasScrollAdjust?",
"jumpPresent?",
"thread",
"messageEdition?",
"messageToReplyTo?",
"order?",
"scrollRef?",
];
static defaultProps = {
isInChatWindow: false,
hasScrollAdjust: true,
jumpPresent: 0,
order: "asc", // 'asc' or 'desc'
order: "asc",
};
static template = "mail.Thread";
@@ -55,17 +56,16 @@ export class Thread extends Component {
showJumpPresent: false,
});
this.threadService = useState(useService("mail.thread"));
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
useAutoScroll("messages", () => {
if (this.env.messageHighlight?.highlightedMessageId) {
return false;
}
if (this.props.thread.scrollPosition.isSaved) {
return false;
}
return true;
});
}
useAutoScroll("messages", () => {
if (this.env.messageHighlight?.highlightedMessageId) {
return false;
}
if (this.props.thread.scrollPosition.isSaved) {
return false;
}
return true;
});
/** @type {ReturnType<import('@mail/utils/common/hooks').useMessageHighlight>|null} */
this.messageHighlight = this.env.messageHighlight
? useState(this.env.messageHighlight)
: null;
@@ -92,34 +92,35 @@ export class Thread extends Component {
this.props.thread.scrollPosition,
"bottom"
);
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
useScrollSnapshot("messages", {
onWillPatch: () => {
return {
hasMoreMsgsAbove:
this.props.thread.oldestPersistentMessage?.id <
this.oldestPersistentMessage && this.props.order === "asc",
hasMoreMsgBelow:
this.props.thread.loadNewer &&
this.props.thread.newestPersistentMessage?.id >
this.newestPersistentMessage &&
this.props.order === "asc",
};
},
onPatched: ({ hasMoreMsgsAbove, hasMoreMsgBelow, scrollTop, scrollHeight }) => {
const el = this.messagesRef.el;
if (!this.isJumpingRecent) {
if (hasMoreMsgsAbove) {
el.scrollTop = scrollTop + el.scrollHeight - scrollHeight;
} else if (hasMoreMsgBelow) {
el.scrollTop = scrollTop;
}
useScrollSnapshot(this.env.inChatter ? this.props.scrollRef : this.messagesRef, {
onWillPatch: () => {
return {
hasMoreMsgsAbove:
this.props.thread.oldestPersistentMessage?.id <
this.oldestPersistentMessage,
hasMoreMsgBelow:
this.props.thread.loadNewer &&
this.props.thread.newestPersistentMessage?.id >
this.newestPersistentMessage,
};
},
onPatched: ({ hasMoreMsgsAbove, hasMoreMsgBelow, scrollTop, scrollHeight }) => {
const el = this.messagesRef.el;
if (!this.isJumpingRecent) {
if (hasMoreMsgsAbove) {
el.scrollTop = this.env.inChatter
? scrollTop
: scrollTop + el.scrollHeight - scrollHeight;
} else if (hasMoreMsgBelow) {
el.scrollTop = this.env.inChatter
? scrollTop + el.scrollHeight - scrollHeight
: scrollTop;
}
this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id;
this.newestPersistentMessage = this.props.thread.newestPersistentMessage?.id;
},
});
}
}
this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id;
this.newestPersistentMessage = this.props.thread.newestPersistentMessage?.id;
},
});
useEffect(
() => this.updateShowJumpPresent(),
() => [this.props.thread.loadNewer]
@@ -142,7 +143,7 @@ export class Thread extends Component {
return;
}
this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id;
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
if (!this.env.inChatter) {
this.scrollPosition.restore();
this.updateShowJumpPresent();
}
@@ -3,7 +3,7 @@
<t t-name="mail.Thread">
<t t-if="env.inChatter" t-call="mail.Thread.jumpPresent"/> <!-- chatter has its own scrollable, this ensures proper sticky showing -->
<div class="o-mail-Thread position-relative flex-grow-1 d-flex flex-column" t-att-class="{ 'overflow-auto': props.hasScrollAdjust, 'pb-4': !state.showJumpPresent }" t-ref="messages" tabindex="-1">
<div class="o-mail-Thread position-relative flex-grow-1 d-flex flex-column overflow-auto" t-att-class="{ 'pb-4': !state.showJumpPresent }" t-ref="messages" tabindex="-1">
<t t-if="!props.thread.isEmpty or props.thread.loadOlder or props.thread.hasLoadingFailed" name="content">
<div class="d-flex flex-column position-relative flex-grow-1" t-att-class="{'justify-content-end': !env.inChatter and props.thread.type !== 'mailbox'}">
<span class="position-absolute w-100 invisible" t-att-class="props.order === 'asc' ? 'bottom-0' : 'top-0'" t-ref="present-treshold" t-att-style="`height: Min(${PRESENT_THRESHOLD}px, 100%)`"/>
@@ -1,9 +1,10 @@
/* @odoo-module */
import { useComponent, useState } from "@odoo/owl";
import { useSubEnv, useComponent, useState } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { SearchMessagesPanel } from "@mail/core/common/search_messages_panel";
export const threadActionsRegistry = registry.category("mail.thread/actions");
@@ -49,6 +50,37 @@ threadActionsRegistry
component.close();
},
sequence: 100,
})
.add("search-messages", {
component: SearchMessagesPanel,
condition(component) {
return (
["discuss.channel", "mail.box"].includes(component.thread?.model) &&
(!component.props.chatWindow || component.props.chatWindow.isOpen)
);
},
panelOuterClass: "o-mail-SearchMessagesPanel",
icon: "oi oi-fw oi-search",
iconLarge: "oi oi-fw oi-search",
name: _t("Search Messages"),
nameActive: _t("Close Search"),
sequence: (component) => {
const res = component.env.inDiscussApp ? 8 : 16;
return res;
},
setup(action) {
useSubEnv({
searchMenu: {
open: () => action.open(),
close: () => {
if (action.isActive) {
action.close();
}
},
},
});
},
toggle: true,
});
function transformAction(component, id, action) {
@@ -11,6 +11,7 @@ import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { memoize } from "@web/core/utils/functions";
import { url } from "@web/core/utils/urls";
import { escapeHTML } from "@web/core/utils/strings";
const FETCH_LIMIT = 30;
@@ -185,7 +186,7 @@ export class ThreadService {
}
try {
// ordered messages received: newest to oldest
const rawMessages = await this.rpc(this.getFetchRoute(thread), {
const { messages: rawMessages } = await this.rpc(this.getFetchRoute(thread), {
...this.getFetchParams(thread),
limit: FETCH_LIMIT,
after,
@@ -295,7 +296,7 @@ export class ThreadService {
*/
async loadAround(thread, messageId) {
if (!thread.messages.some(({ id }) => id === messageId)) {
const messages = await this.rpc(this.getFetchRoute(thread), {
const { messages } = await this.rpc(this.getFetchRoute(thread), {
...this.getFetchParams(thread),
around: messageId,
});
@@ -926,6 +927,32 @@ export class ThreadService {
}
}
}
/**
* @param {string} searchTerm
* @param {Thread} thread
* @param {number|false} [before]
*/
async search(searchTerm, thread, before = false) {
const { messages, count } = await this.rpc(this.getFetchRoute(thread), {
...this.getFetchParams(thread),
search_term: escapeHTML(searchTerm),
before,
});
return {
count,
loadMore: messages.length === FETCH_LIMIT,
messages: messages.map((message) => {
message.body = markup(message.body);
if (message.parentMessage) {
message.parentMessage.body = message.parentMessage.body
? markup(message.parentMessage.body)
: message.parentMessage.body;
}
return this.store.Message.insert(message);
}),
};
}
}
export const threadService = {
+25 -4
View File
@@ -7,10 +7,11 @@ import { useDropzone } from "@mail/core/common/dropzone_hook";
import { Thread } from "@mail/core/common/thread";
import { Activity } from "@mail/core/web/activity";
import { SuggestedRecipientsList } from "@mail/core/web/suggested_recipient_list";
import { useHover, useScrollPosition } from "@mail/utils/common/hooks";
import { useHover, useMessageHighlight, useScrollPosition } from "@mail/utils/common/hooks";
import { isDragSourceExternalFile } from "@mail/utils/common/misc";
import { RecipientList } from "./recipient_list";
import { FollowerList } from "./follower_list";
import { SearchMessagesPanel } from "@mail/core/common/search_messages_panel";
import {
Component,
@@ -51,6 +52,7 @@ export class Chatter extends Component {
FileUploader,
FollowerList,
SuggestedRecipientsList,
SearchMessagesPanel,
};
static props = [
"close?",
@@ -59,7 +61,7 @@ export class Chatter extends Component {
"hasActivities?",
"hasFollowers?",
"hasMessageList?",
"hasMessageListScrollAdjust?",
"isChatterAside?",
"hasParentReloadOnAttachmentsChanged?",
"hasParentReloadOnFollowersUpdate?",
"hasParentReloadOnMessagePosted?",
@@ -75,7 +77,7 @@ export class Chatter extends Component {
hasActivities: true,
hasFollowers: true,
hasMessageList: true,
hasMessageListScrollAdjust: false,
isChatterAside: false,
hasParentReloadOnAttachmentsChanged: false,
hasParentReloadOnFollowersUpdate: false,
hasParentReloadOnMessagePosted: false,
@@ -103,6 +105,7 @@ export class Chatter extends Component {
showAttachmentLoading: false,
/** @type {import("models").Thread} */
thread: undefined,
isSearchOpen: false,
});
this.unfollowHover = useHover("unfollow");
this.attachmentUploader = useAttachmentUploader(
@@ -112,7 +115,11 @@ export class Chatter extends Component {
this.rootRef = useRef("root");
this.onScrollDebounced = useThrottleForAnimation(this.onScroll);
this.recipientsPopover = usePopover(RecipientList);
useChildSubEnv({ inChatter: true });
this.messageHighlight = useMessageHighlight();
useChildSubEnv({
inChatter: true,
messageHighlight: this.messageHighlight,
});
useDropzone(
this.rootRef,
async (ev) => {
@@ -164,6 +171,9 @@ export class Chatter extends Component {
this.onNextUpdate = null;
}
}
if (nextProps.threadId) {
this.closeSearch();
}
});
useEffect(
() => {
@@ -335,6 +345,7 @@ export class Chatter extends Component {
}
toggleComposer(mode = false) {
this.closeSearch();
const toggle = () => {
if (this.state.composerType === mode) {
this.state.composerType = false;
@@ -364,6 +375,7 @@ export class Chatter extends Component {
}
async scheduleActivity() {
this.closeSearch();
const schedule = async (threadId) => {
await this.activityService.schedule(this.props.threadModel, threadId);
this.load(this.props.threadId, ["activities", "messages"]);
@@ -412,6 +424,15 @@ export class Chatter extends Component {
}
}
onClickSearch() {
this.state.composerType = false;
this.state.isSearchOpen = !this.state.isSearchOpen;
}
closeSearch() {
this.state.isSearchOpen = false;
}
async onClickAttachFile(ev) {
if (this.props.threadId) {
return;
@@ -50,3 +50,7 @@
.btn.o-mail-Chatter-follow:hover {
color: $black !important;
}
.o-mail-Chatter button.o-active {
color: $o-action !important;
}
+44 -29
View File
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="mail.Chatter">
<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 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.isChatterAside, '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="{
@@ -26,6 +26,9 @@
<span>Activities</span>
</button>
<span class="o-mail-Chatter-topbarGrow flex-grow-1 pe-2"/>
<button class="btn btn-link text-action" t-att-class="{ 'o-active': state.isSearchOpen }" aria-label="Search Messages" title="Search Messages" t-on-click="onClickSearch">
<i class="oi oi-search" role="img"/>
</button>
<FileUploader t-if="attachments.length === 0" fileUploadClass="'o-mail-Chatter-fileUploader'" multiUpload="true" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
<t t-set-slot="toggler">
<t t-call="mail.Chatter.attachFiles"/>
@@ -83,34 +86,46 @@
</t>
</div>
<div class="o-mail-Chatter-content">
<t t-if="props.hasActivities and activities.length">
<t t-call="mail.ActivityList"/>
</t>
<div t-if="state.isAttachmentBoxOpened" class="o-mail-AttachmentBox position-relative" t-ref="attachment-box">
<div class="d-flex align-items-center">
<hr class="flex-grow-1"/>
<span class="p-3 fw-bold">
Files
</span>
<hr class="flex-grow-1"/>
</div>
<div class="d-flex flex-column">
<AttachmentList
attachments="attachments"
unlinkAttachment.bind="unlinkAttachment"
imagesHeight="100"
/>
<FileUploader multiUpload="true" fileUploadClass="'o-mail-Chatter-fileUploader'" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
<t t-set-slot="toggler">
<button class="btn btn-link" type="button" t-att-disabled="!state.thread.hasWriteAccess">
<i class="fa fa-plus-square"/>
Attach files
</button>
</t>
</FileUploader>
</div>
<div t-if="state.isSearchOpen" class="o-mail-Chatter-search">
<SearchMessagesPanel
closeSearch.bind="closeSearch"
thread="state.thread"
onClickJump.bind="closeSearch"
/>
</div>
<Thread t-if="props.hasMessageList" thread="state.thread" t-key="state.thread.localId" order="'desc'" hasScrollAdjust="!props.hasMessageListScrollAdjust" jumpPresent="state.jumpThreadPresent"/>
<t t-else="">
<t t-if="props.hasActivities and activities.length and !state.isSearchOpen">
<t t-call="mail.ActivityList"/>
</t>
<div t-if="state.isAttachmentBoxOpened" class="o-mail-AttachmentBox position-relative" t-ref="attachment-box">
<div class="d-flex align-items-center">
<hr class="flex-grow-1"/>
<span class="p-3 fw-bold">
Files
</span>
<hr class="flex-grow-1"/>
</div>
<div class="d-flex flex-column">
<AttachmentList
attachments="attachments"
unlinkAttachment.bind="unlinkAttachment"
imagesHeight="100"
/>
<FileUploader multiUpload="true" fileUploadClass="'o-mail-Chatter-fileUploader'" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
<t t-set-slot="toggler">
<button class="btn btn-link" type="button" t-att-disabled="!state.thread.hasWriteAccess">
<i class="fa fa-plus-square"/>
Attach files
</button>
</t>
</FileUploader>
</div>
</div>
<t t-if="props.hasMessageList">
<Thread t-if="!state.isSearchOpen" thread="state.thread" t-key="state.thread.localId" order="'desc'" scrollRef="rootRef" jumpPresent="state.jumpThreadPresent"
/>
</t>
</t>
</div>
</div>
</t>
@@ -135,7 +150,7 @@
</t>
<t t-name="mail.Chatter.attachFiles">
<button class="o-mail-Chatter-attachFiles btn btn-link text-action px-1 d-flex align-items-center" aria-label="Attach files" t-att-class="{ 'my-2': !props.compactHeight }" t-on-click="onClickAddAttachments">
<button class="o-mail-Chatter-attachFiles btn btn-link text-action px-1 d-flex align-items-center" aria-label="Attach files" t-att-class="{ 'my-2': !props.compactHeight }" t-on-click="onClickAddAttachments" t-att-disabled="state.isSearchOpen">
<i class="fa fa-paperclip fa-lg me-1"/>
<span t-if="attachments.length > 0" t-esc="attachments.length"/>
<i t-if="!state.thread.areAttachmentsLoaded and state.thread.isLoadingAttachments and state.showAttachmentLoading" class="fa fa-circle-o-notch fa-spin" aria-label="Attachment counter loading..."/>
@@ -6,7 +6,7 @@
<div>
<t t-if="message.subtypeDescription">
<p class="mb-0">
<t t-esc="message.subtypeDescription"/>
<t t-out="messageSearch?.highlight(message.subtypeDescription) ?? message.subtypeDescription"/>
</p>
</t>
<t t-if="message.trackingValues.length">
@@ -22,7 +22,7 @@
</ul>
</t>
<div t-if="message.body" class="o-mail-Message-body text-break mb-0 w-100" t-ref="body">
<t t-out="message.body"/>
<t t-out="messageSearch?.highlight(message.body) ?? message.body"/>
</div>
</div>
</t>
@@ -11,7 +11,7 @@ import { Component } from "@odoo/owl";
export class ActionPanel extends Component {
static template = "mail.ActionPanel";
static props = {
title: String,
title: { type: String, optional: true },
slots: { type: Object, optional: true },
};
}
@@ -1,3 +1,7 @@
.o-mail-ActionPanel-header {
z-index: $o-mail-NavigableList-zIndex - 1;
}
.o-mail-ActionPanel-backButton {
color: $o-brand-odoo;
}
@@ -2,12 +2,12 @@
<templates xml:space="preserve">
<t t-name="mail.ActionPanel">
<div class="overflow-auto d-flex flex-column flex-shrink-0 bg-view position-relative p-3 pt-0 h-100">
<div class="position-sticky top-0 py-3 d-flex align-items-baseline bg-view" style="z-index: 1;" >
<div class="o-mail-ActionPanel overflow-auto d-flex flex-column flex-shrink-0 position-relative p-3 pt-0 h-100" t-att-class="{ 'bg-view': !env.inChatter }">
<div class="o-mail-ActionPanel-header position-sticky top-0 py-3 d-flex align-items-baseline" t-att-class="{ 'bg-view': !env.inChatter }">
<button t-if="env.closeActionPanel" class="o-mail-ActionPanel-backButton btn opacity-75 opacity-100-hover ps-0 py-0 fs-5" title="Close panel" t-on-click.stop="env.closeActionPanel">
<i class="oi oi-arrow-left"/>
</button>
<p class="fs-6 fw-bold text-uppercase m-0 text-700 flex-grow-1" t-esc="props.title"/>
<p t-if="props.title" class="fs-6 fw-bold text-uppercase m-0 text-700 flex-grow-1" t-esc="props.title"/>
</div>
<t t-slot="default"/>
</div>
@@ -24,22 +24,14 @@ patch(Message.prototype, {
this.messagePinService.pin(this.message);
}
},
get attClass() {
const res = super.attClass;
return Object.assign(res, {
"o-cancelSelfAuthored": this.env.pinnedPanel,
"mt-1": res["mt-1"] && !this.env.pinnedPanel,
"px-3": res["px-3"] && !this.env.pinnedPanel,
});
},
get isAlignedRight() {
return !this.env.pinnedPanel && super.isAlignedRight;
return !this.env.messageCard && super.isAlignedRight;
},
getPinOptionText() {
return this.messagePinService.getPinnedAt(this.message.id) ? _t("Unpin") : _t("Pin");
},
get shouldDisplayAuthorName() {
if (this.env.pinnedPanel) {
if (this.env.messageCard) {
return true;
}
return super.shouldDisplayAuthorName;
@@ -1,16 +1,22 @@
/* @odoo-module */
import { Message } from "@mail/core/common/message";
import { MessageCardList } from "@mail/core/common/message_card_list";
import { ActionPanel } from "@mail/discuss/core/common/action_panel";
import { Component, onWillStart, onWillUpdateProps, useState, useSubEnv } from "@odoo/owl";
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
/**
* @typedef {Object} Props
* @property {import("@mail/core/common/thread_model").Thread} thread
* @property {string} [className]
* @extends {Component<Props, Env>}
*/
export class PinnedMessagesPanel extends Component {
static components = {
Message,
MessageCardList,
ActionPanel,
};
static props = ["thread", "className?"];
@@ -19,7 +25,6 @@ export class PinnedMessagesPanel extends Component {
setup() {
this.store = useService("mail.store");
this.rpc = useService("rpc");
this.ui = useState(useService("ui"));
this.messagePinService = useState(useService("discuss.message.pin"));
onWillStart(() => {
this.messagePinService.fetchPinnedMessages(this.props.thread);
@@ -29,32 +34,13 @@ export class PinnedMessagesPanel extends Component {
this.messagePinService.fetchPinnedMessages(nextProps.thread);
}
});
useSubEnv({
pinnedPanel: true,
});
}
/**
* Highlight the given message and scrolls to it. In small mode, the
* pin menu is closed beforwards
*
* @param {Message} message
*/
async onClickJump(message) {
if (this.ui.isSmall || this.env.inChatWindow) {
this.env.pinMenu.close();
// Give the time to the pin menu to close before scrolling
// to the message.
await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve)));
}
await this.env.messageHighlight?.highlightMessage(message, this.props.thread);
}
/**
* Prompt the user for confirmation and unpin the given message if
* confirmed.
*
* @param {Message} message
* @param {import('@mail/core/common/message_model').Message} message
*/
onClickUnpin(message) {
this.messagePinService.unpin(message);
@@ -63,7 +49,7 @@ export class PinnedMessagesPanel extends Component {
/**
* Get the message to display when nothing is pinned on this thread.
*/
get emptyMessage() {
get emptyText() {
if (this.props.thread.type === "channel") {
return _t("This channel doesn't have any pinned messages.");
} else {
@@ -1,15 +1,3 @@
.o-discuss-PinnedMessagesPanel {
flex-basis: 425px;
}
.o-discuss-PinnedMessagesPanel-jump {
color: white;
}
.card-body:hover .o-discuss-PinnedMessagesPanel-jump {
opacity: 100 !important;
}
.o_touch_device .o-discuss-PinnedMessagesPanel-jump.btn {
font-size: 0.9rem;
}
@@ -2,20 +2,7 @@
<templates xml:space="preserve">
<t t-name="discuss.PinnedMessagesPanel">
<ActionPanel title="title">
<div class="d-flex flex-column" t-att-class="{ 'justify-content-center flex-grow-1': !messagePinService.hasPinnedMessages(props.thread) }" t-ref="message-list">
<div class="card mb-3" t-foreach="messagePinService.getPinnedMessages(props.thread)" t-as="message" t-key="message.id">
<div class="card-body">
<div class="d-flex justify-content-end">
<button class="o-discuss-PinnedMessagesPanel-jump btn rounded bg-400 badge opacity-0 flex-shrink-0" t-att-class="{ 'opacity-100 py-1 px-2': ui.isSmall }" t-on-click="() => this.onClickJump(message)">Jump</button>
<button class="btn ms-2 p-0 text-700 shadow-none" t-att-class="{ 'fs-5 align-self-end': ui.isSmall }" title="Unpin" t-on-click="() => this.onClickUnpin(message)">
<i class="d-block fa fa-times"/>
</button>
</div>
<Message hasActions="false" message="message" thread="props.thread"/>
</div>
</div>
<p t-if="!messagePinService.hasPinnedMessages(props.thread)" t-esc="emptyMessage" class="text-center fst-italic text-500 fs-6"/>
</div>
<MessageCardList emptyText="emptyText" messages="messagePinService.getPinnedMessages(props.thread)" thread="props.thread" mode="'pin'" onClickUnpin="(message) => this.onClickUnpin(message)"/>
</ActionPanel>
</t>
</templates>
@@ -14,7 +14,7 @@ registry.category("web_tour.tours").add("mail.load_message_order", {
run: "click",
},
{
trigger: ".o-discuss-PinnedMessagesPanel-jump",
trigger: ".o-mail-MessageCard-jump",
run: "click",
},
{
+6 -3
View File
@@ -157,6 +157,10 @@ export function useAutoScroll(refName, shouldScrollPredicate = () => true) {
onPatched(applyScroll);
}
/**
* @param {string} refName
* @param {function} cb
*/
export function useVisible(refName, cb, { init = false } = {}) {
const ref = useRef(refName);
const state = { isVisible: init };
@@ -198,12 +202,11 @@ export function useVisible(refName, cb, { init = false } = {}) {
* This hook eases adjusting scroll position by snapshotting scroll
* properties of scrollable in onWillPatch / onPatched hooks.
*
* @param {string} refName
* @param {import("@web/core/utils/hooks").Ref} ref
* @param {function} param1.onWillPatch
* @param {function} param1.onPatched
*/
export function useScrollSnapshot(refName, { onWillPatch: p_onWillPatch, onPatched: p_onPatched }) {
const ref = useRef(refName);
export function useScrollSnapshot(ref, { onWillPatch: p_onWillPatch, onPatched: p_onPatched }) {
const snapshot = {
scrollHeight: null,
scrollTop: null,
@@ -92,7 +92,7 @@ patch(FormCompiler.prototype, {
"t[t-component='__comp__.mailComponents.Chatter']"
);
setAttributes(chatterContainerXml, {
hasMessageListScrollAdjust: "false",
isChatterAside: "false",
isInFormSheetBg: "false",
saveRecord: "__comp__.props.saveButtonClicked",
});
@@ -122,7 +122,7 @@ patch(FormCompiler.prototype, {
);
setAttributes(sheetBgChatterContainerXml, {
isInFormSheetBg: "true",
hasMessageListScrollAdjust: "false",
isChatterAside: "false",
});
}
// after sheet bg (standard position, either aside or below)
@@ -133,12 +133,12 @@ patch(FormCompiler.prototype, {
});
setAttributes(chatterContainerXml, {
isInFormSheetBg: "__comp__.hasFileViewer()",
hasMessageListScrollAdjust: `__comp__.uiService.size >= ${SIZES.XXL} and !(__comp__.hasFileViewer() and __comp__.uiService.size >= ${SIZES.XXL})`,
isChatterAside: `__comp__.uiService.size >= ${SIZES.XXL} and !(__comp__.hasFileViewer() and __comp__.uiService.size >= ${SIZES.XXL})`,
});
} else {
setAttributes(chatterContainerXml, {
isInFormSheetBg: "false",
hasMessageListScrollAdjust: `__comp__.uiService.size >= ${SIZES.XXL}`,
isChatterAside: `__comp__.uiService.size >= ${SIZES.XXL}`,
});
setAttributes(chatterContainerHookXml, {
"t-attf-class": `{{ __comp__.uiService.size >= ${SIZES.XXL} ? "o-aside" : "mt-4 mt-md-0" }}`,
@@ -145,7 +145,8 @@ QUnit.test("chat window: basic rendering", async () => {
text: "There are no messages in this conversation.",
});
await click("[title='Open Actions Menu']");
await contains(".o-mail-ChatWindow-command", { count: 10 });
await contains(".o-mail-ChatWindow-command", { count: 11 });
await contains("[title='Search Messages']");
await contains("[title='Pinned Messages']");
await contains("[title='Show Attachments']");
await contains("[title='Add Users']");
@@ -0,0 +1,191 @@
/* @odoo-module */
import { HIGHLIGHT_CLASS, searchHighlight } from "@mail/core/common/message_search_hook";
import { triggerHotkey } from "@web/../tests/helpers/utils";
import { click, contains, insertText } from "@web/../tests/utils";
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
import { start } from "../helpers/test_utils";
import { SIZES, patchUiSize } from "../helpers/patch_ui_size";
QUnit.module("Search highlight test", {});
QUnit.test("Search highlight", async (assert) => {
const testCases = [
{
input: "test odoo",
output: `test <span class="${HIGHLIGHT_CLASS}">odoo</span>`,
searchTerm: "odoo",
},
{
input: '<a href="https://www.odoo.com">https://www.odoo.com</a>',
output: `<a href="https://www.odoo.com">https://www.<span class="${HIGHLIGHT_CLASS}">odoo</span>.com</a>`,
searchTerm: "odoo",
},
{
input: '<a href="https://www.odoo.com">Odoo</a>',
output: `<a href="https://www.odoo.com"><span class="${HIGHLIGHT_CLASS}">Odoo</span></a>`,
searchTerm: "odoo",
},
{
input: '<a href="https://www.odoo.com">Odoo</a> Odoo is a free software',
output: `<a href="https://www.odoo.com"><span class="${HIGHLIGHT_CLASS}">Odoo</span></a> <span class="${HIGHLIGHT_CLASS}">Odoo</span> is a free software`,
searchTerm: "odoo",
},
{
input: "odoo is a free software",
output: `<span class="${HIGHLIGHT_CLASS}">odoo</span> is a free software`,
searchTerm: "odoo",
},
{
input: "software ODOO is a free",
output: `software <span class="${HIGHLIGHT_CLASS}">ODOO</span> is a free`,
searchTerm: "odoo",
},
{
input: `<ul>
<li>Odoo</li>
<li><a href="https://odoo.com">Odoo ERP</a> Best ERP</li>
</ul>`,
output: `<ul>
<li><span class="${HIGHLIGHT_CLASS}">Odoo</span></li>
<li><a href="https://odoo.com"><span class="${HIGHLIGHT_CLASS}">Odoo</span> ERP</a> Best ERP</li>
</ul>`,
searchTerm: "odoo",
},
{
input: "test <strong>Odoo</strong> test",
output: `<span class="${HIGHLIGHT_CLASS}">test</span> <strong><span class="${HIGHLIGHT_CLASS}">Odoo</span></strong> <span class="${HIGHLIGHT_CLASS}">test</span>`,
searchTerm: "odoo test",
},
{
input: "test <br> test",
output: `<span class="${HIGHLIGHT_CLASS}">test</span> <br> <span class="${HIGHLIGHT_CLASS}">test</span>`,
searchTerm: "odoo test",
},
{
input: "<strong>test</strong> test",
output: `<strong><span class="${HIGHLIGHT_CLASS}">test</span></strong> <span class="${HIGHLIGHT_CLASS}">test</span>`,
searchTerm: "test",
},
{
input: "<strong>a</strong> test",
output: `<strong><span class="${HIGHLIGHT_CLASS}">a</span></strong> <span class="${HIGHLIGHT_CLASS}">test</span>`,
searchTerm: "a test",
},
{
input: "&amp;amp;",
output: `<span class="${HIGHLIGHT_CLASS}">&amp;amp;</span>`,
searchTerm: "&amp;",
},
{
input: "&amp;amp;",
output: `<span class="${HIGHLIGHT_CLASS}">&amp;</span>amp;`,
searchTerm: "&",
},
{
input: "<strong>test</strong> hello",
output: `<strong><span class="${HIGHLIGHT_CLASS}">test</span></strong> <span class="${HIGHLIGHT_CLASS}">hello</span>`,
searchTerm: "test hello",
},
{
input: "<p>&lt;strong&gt;test&lt;/strong&gt; hello</p>",
output: `<p>&lt;strong&gt;<span class="${HIGHLIGHT_CLASS}">test</span>&lt;/strong&gt; <span class="${HIGHLIGHT_CLASS}">hello</span></p>`,
searchTerm: "test hello",
},
];
for (const { input, output, searchTerm } of testCases) {
assert.equal(searchHighlight(searchTerm, input), output);
}
});
QUnit.test("Display highligthed search in chatter", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
const { openFormView } = await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(`.o-mail-Chatter-search .o-mail-Message span.${HIGHLIGHT_CLASS}`);
});
QUnit.test("Display multiple highligthed search in chatter", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not test empty",
model: "res.partner",
res_id: partnerId,
});
const { openFormView } = await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "not empty");
triggerHotkey("Enter");
await contains(`.o-mail-Chatter-search .o-mail-Message span.${HIGHLIGHT_CLASS}`, { count: 2 });
});
QUnit.test("Display highligthed search in Discuss", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "not empty",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("button[title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessagesPanel .o-mail-Message span.${HIGHLIGHT_CLASS}`);
});
QUnit.test("Display multiple highligthed search in Discuss", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "not prout empty",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("button[title='Search Messages']");
await insertText(".o_searchview_input", "not empty");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessagesPanel .o-mail-Message span.${HIGHLIGHT_CLASS}`, {
count: 2,
});
});
QUnit.test("Display highligthed with escaped character must ignore them", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "<p>&lt;strong&gt;test&lt;/strong&gt; hello</p>",
model: "res.partner",
res_id: partnerId,
});
const { openFormView } = await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "test hello");
triggerHotkey("Enter");
await contains(`.o-mail-Chatter-search .o-mail-Message span.${HIGHLIGHT_CLASS}`, { count: 2 });
await contains(`.o-mail-Message-body`, { text: "<strong>test</strong> hello" });
});
@@ -0,0 +1,219 @@
/* @odoo-module */
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
import { start } from "@mail/../tests/helpers/test_utils";
import { HIGHLIGHT_CLASS } from "@mail/core/common/message_search_hook";
import { triggerHotkey } from "@web/../tests/helpers/utils";
import { click, contains, insertText, scroll } from "@web/../tests/utils";
QUnit.module("discuss search");
QUnit.test("Should have a search button", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const { openDiscuss } = await start();
await openDiscuss(channelId);
await contains("[title='Search Messages']");
});
QUnit.test("Should open the search panel when search button is clicked", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("[title='Search Messages']");
await contains(".o-mail-SearchMessagesPanel");
await contains(".o_searchview");
await contains(".o_searchview_input");
});
QUnit.test("Search a message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("button[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
});
QUnit.test("Search should be hightlighted", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(`.o-mail-SearchMessagesPanel .o-mail-Message .${HIGHLIGHT_CLASS}`);
});
QUnit.test("Search a starred message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
starred_partner_ids: [pyEnv.currentPartnerId],
});
const { openDiscuss } = await start();
await openDiscuss("mail.box_starred");
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
});
QUnit.test("Search a message in inbox", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
needaction: true,
});
const { openDiscuss } = await start();
await openDiscuss("mail.box_inbox");
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
});
QUnit.test("Search a message in history", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const messageId = pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
needaction: false,
});
pyEnv["mail.notification"].create({
is_read: true,
mail_message_id: messageId,
notification_status: "sent",
notification_type: "inbox",
res_partner_id: pyEnv.currentPartnerId,
});
const { openDiscuss } = await start();
await openDiscuss("mail.box_history");
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message");
});
QUnit.test("Should close the search panel when search button is clicked again", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("[title='Search Messages']");
await click("[title='Close Search']");
await contains(".o-mail-SearchMessagesPanel");
});
QUnit.test("Search a message in 60 messages should return 30 message first", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
for (let i = 0; i < 60; i++) {
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 30 });
});
QUnit.test("Scrolling to the bottom should load more searched message", async () => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
for (let i = 0; i < 60; i++) {
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 30 });
await scroll(".o-mail-SearchMessagesPanel .o-mail-ActionPanel", "bottom");
await contains(".o-mail-SearchMessagesPanel .o-mail-Message", { count: 60 });
});
QUnit.test(
"Editing the searched term should not edit the current searched term",
async (assert) => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "General" });
for (let i = 0; i < 60; i++) {
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "discuss.channel",
res_id: channelId,
});
}
const { openDiscuss } = await start({
async mockRPC(route, args) {
if (route === "/discuss/channel/messages" && args.search_term) {
const { search_term } = args;
assert.strictEqual(search_term, "message");
}
},
});
await openDiscuss(channelId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await insertText(".o_searchview_input", "test");
await scroll(".o-mail-SearchMessagesPanel .o-mail-ActionPanel", "bottom");
}
);
@@ -36,8 +36,15 @@ patch(MockServer.prototype, {
return this._mockRouteMailAttachmentRemove(attachment_id);
}
if (route === "/discuss/channel/messages") {
const { channel_id, after, around, before, limit } = args;
return this._mockRouteDiscussChannelMessages(channel_id, before, after, around, limit);
const { search_term, channel_id, after, around, before, limit } = args;
return this._mockRouteDiscussChannelMessages(
channel_id,
search_term,
before,
after,
around,
limit
);
}
if (route === "/discuss/channel/pinned_messages") {
const { channel_id } = args;
@@ -59,15 +66,15 @@ patch(MockServer.prototype, {
return this._mockDiscussChannelloadOlderMembers([channel_id], known_member_ids);
}
if (route === "/mail/history/messages") {
const { after, before, limit } = args;
return this._mockRouteMailMessageHistory(after, before, limit);
const { search_term, after, before, limit } = args;
return this._mockRouteMailMessageHistory(search_term, after, before, limit);
}
if (route === "/mail/init_messaging") {
return this._mockRouteMailInitMessaging();
}
if (route === "/mail/inbox/messages") {
const { after, around, before, limit } = args;
return this._mockRouteMailMessageInbox(after, before, around, limit);
const { search_term, after, around, before, limit } = args;
return this._mockRouteMailMessageInbox(search_term, after, before, around, limit);
}
if (route === "/mail/link_preview") {
return this._mockRouteMailLinkPreview(args.message_id, args.clear);
@@ -158,8 +165,8 @@ patch(MockServer.prototype, {
return;
}
if (route === "/mail/starred/messages") {
const { after, before, limit } = args;
return this._mockRouteMailMessageStarredMessages(after, before, limit);
const { search_term, after, before, limit } = args;
return this._mockRouteMailMessageStarredMessages(search_term, after, before, limit);
}
if (route === "/mail/thread/data") {
return this._mockRouteMailThreadData(
@@ -169,10 +176,11 @@ patch(MockServer.prototype, {
);
}
if (route === "/mail/thread/messages") {
const { after, around, before, limit, thread_model, thread_id } = args;
const { search_term, after, around, before, limit, thread_model, thread_id } = args;
return this._mockRouteMailThreadFetchMessages(
thread_model,
thread_id,
search_term,
before,
after,
around,
@@ -232,6 +240,7 @@ patch(MockServer.prototype, {
*/
async _mockRouteDiscussChannelMessages(
channel_id,
search_term = false,
before = false,
after = false,
around = false,
@@ -242,11 +251,21 @@ patch(MockServer.prototype, {
["model", "=", "discuss.channel"],
["message_type", "!=", "user_notification"],
];
const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit);
const res = this._mockMailMessage_MessageFetch(
domain,
search_term,
before,
after,
around,
limit
);
if (!around) {
this._mockMailMessageSetMessageDone(messages.map((message) => message.id));
this._mockMailMessageSetMessageDone(res.messages.map((message) => message.id));
}
return this._mockMailMessageMessageFormat(messages.map((message) => message.id));
return {
...res,
messages: this._mockMailMessageMessageFormat(res.messages.map((message) => message.id)),
};
},
/**
* Simulates the `/discuss/channel/notify_typing` route.
@@ -328,10 +347,17 @@ patch(MockServer.prototype, {
* @private
* @returns {Object}
*/
_mockRouteMailMessageHistory(after = false, before = false, limit = 30) {
_mockRouteMailMessageHistory(search_term = false, after = false, before = false, limit = 30) {
const domain = [["needaction", "=", false]];
const messages = this._mockMailMessage_MessageFetch(domain, before, after, false, limit);
const messagesWithNotification = messages.filter((message) => {
const res = this._mockMailMessage_MessageFetch(
domain,
search_term,
before,
after,
false,
limit
);
const messagesWithNotification = res.messages.filter((message) => {
const notifs = this.pyEnv["mail.notification"].searchRead([
["mail_message_id", "=", message.id],
["is_read", "=", true],
@@ -340,9 +366,12 @@ patch(MockServer.prototype, {
return notifs.length > 0;
});
return this._mockMailMessageMessageFormat(
messagesWithNotification.map((message) => message.id)
);
return {
...res,
messages: this._mockMailMessageMessageFormat(
messagesWithNotification.map((message) => message.id)
),
};
},
/**
* Simulates the `/mail/inbox/messages` route.
@@ -350,10 +379,28 @@ patch(MockServer.prototype, {
* @private
* @returns {Object}
*/
_mockRouteMailMessageInbox(after = false, before = false, around = false, limit = 30) {
_mockRouteMailMessageInbox(
search_term = false,
after = false,
before = false,
around = false,
limit = 30
) {
const domain = [["needaction", "=", true]];
const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit);
return this._mockMailMessageFormatPersonalize(messages.map((message) => message.id));
const res = this._mockMailMessage_MessageFetch(
domain,
search_term,
before,
after,
around,
limit
);
return {
...res,
messages: this._mockMailMessageFormatPersonalize(
res.messages.map((message) => message.id)
),
};
},
/**
* Simulates the `/mail/starred/messages` route.
@@ -361,10 +408,25 @@ patch(MockServer.prototype, {
* @private
* @returns {Object}
*/
_mockRouteMailMessageStarredMessages(after = false, before = false, limit = 30) {
_mockRouteMailMessageStarredMessages(
search_term = false,
after = false,
before = false,
limit = 30
) {
const domain = [["starred_partner_ids", "in", [this.pyEnv.currentPartnerId]]];
const messages = this._mockMailMessage_MessageFetch(domain, before, after, false, limit);
return this._mockMailMessageMessageFormat(messages.map((message) => message.id));
const res = this._mockMailMessage_MessageFetch(
domain,
search_term,
before,
after,
false,
limit
);
return {
...res,
messages: this._mockMailMessageMessageFormat(res.messages.map((message) => message.id)),
};
},
/**
* Simulates the `/mail/partner/from_email` route.
@@ -593,6 +655,7 @@ patch(MockServer.prototype, {
async _mockRouteMailThreadFetchMessages(
res_model,
res_id,
search_term = false,
before = false,
after = false,
around = false,
@@ -603,8 +666,18 @@ patch(MockServer.prototype, {
["model", "=", res_model],
["message_type", "!=", "user_notification"],
];
const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit);
this._mockMailMessageSetMessageDone(messages.map((message) => message.id));
return this._mockMailMessageMessageFormat(messages.map((message) => message.id));
const res = this._mockMailMessage_MessageFetch(
domain,
search_term,
before,
after,
around,
limit
);
this._mockMailMessageSetMessageDone(res.messages.map((message) => message.id));
return {
...res,
messages: this._mockMailMessageMessageFormat(res.messages.map((message) => message.id)),
};
},
});
@@ -147,7 +147,13 @@ patch(MockServer.prototype, {
* @param {integer} [limit=30]
* @returns {Object[]}
*/
_mockMailMessage_MessageFetch(domain, before, after, around, limit = 30) {
_mockMailMessage_MessageFetch(domain, search_term, before, after, around, limit = 30) {
const res = {};
if (search_term) {
search_term = search_term.replace(" ", "%");
domain.push(["body", "ilike", search_term]);
res.count = this.pyEnv["mail.message"].searchCount(domain);
}
if (around) {
const messagesBefore = this.getRecords(
"mail.message",
@@ -159,7 +165,7 @@ patch(MockServer.prototype, {
domain.concat([["id", ">", around]])
).sort((m1, m2) => m1.id - m2.id);
messagesAfter.length = Math.min(messagesAfter.length, limit / 2);
return messagesAfter.concat(messagesBefore.reverse());
return { ...res, messages: messagesAfter.concat(messagesBefore.reverse()) };
}
if (before) {
domain.push(["id", "<", before]);
@@ -174,7 +180,8 @@ patch(MockServer.prototype, {
});
// pick at most 'limit' messages
messages.length = Math.min(messages.length, limit);
return messages;
res.messages = messages;
return res;
},
/**
* Simulates `message_format` on `mail.message`.
@@ -0,0 +1,123 @@
/* @odoo-module */
import { startServer } from "@bus/../tests/helpers/mock_python_environment";
import { start } from "@mail/../tests/helpers/test_utils";
import { SIZES, patchUiSize } from "../helpers/patch_ui_size";
import { triggerHotkey } from "@web/../tests/helpers/utils";
import { click, contains, insertText, scroll } from "@web/../tests/utils";
import { HIGHLIGHT_CLASS } from "@mail/core/common/message_search_hook";
QUnit.module("chatter search");
QUnit.test("Chatter should display search icon", async () => {
patchUiSize({ size: SIZES.XXL });
const { openFormView, pyEnv } = await start();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await contains("[title='Search Messages']");
});
QUnit.test("Click on the search icon should open the search form", async () => {
patchUiSize({ size: SIZES.XXL });
const { openFormView, pyEnv } = await start();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await contains(".o_searchview");
await contains(".o_searchview_input");
});
QUnit.test("Click again on the search icon should close the search form", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
const { openFormView } = await start();
await openFormView("res.partner", partnerId);
await click(".o-mail-Chatter-topbar [title='Search Messages']");
await contains(".o_searchview");
await click(".o-mail-Chatter-topbar [title='Search Messages']");
await contains(".o_searchview", { count: 0 });
await contains(".o_searchview_input", { count: 0 });
});
QUnit.test("Search in chatter", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
const { openFormView } = await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(".o-mail-Chatter-search .o-mail-Message");
});
QUnit.test("Close button should close the search panel", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
const { openFormView } = await start();
await openFormView("res.partner", partnerId);
await click(".o-mail-Chatter-topbar [title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(".o-mail-Chatter-search .o-mail-Message");
await click(".o-mail-Chatter-topbar [title='Search Messages']");
await contains(".o-mail-Chatter-search", { count: 0 });
});
QUnit.test("Search in chatter should be hightligted", async () => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
pyEnv["mail.message"].create({
body: "not empty",
model: "res.partner",
res_id: partnerId,
});
const { openFormView } = await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "empty");
triggerHotkey("Enter");
await contains(`.o-mail-Chatter-search .o-mail-Message .${HIGHLIGHT_CLASS}`);
});
QUnit.test("Scrolling bottom in non-aside chatter should load more searched message", async () => {
patchUiSize({ size: SIZES.LG }); // non-aside
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
for (let i = 0; i < 60; i++) {
pyEnv["mail.message"].create({
author_id: pyEnv.currentPartnerId,
body: "This is a message",
attachment_ids: [],
message_type: "comment",
model: "res.partner",
res_id: partnerId,
});
}
const { openFormView } = await start();
await openFormView("res.partner", partnerId);
await click("[title='Search Messages']");
await insertText(".o_searchview_input", "message");
triggerHotkey("Enter");
await contains(".o-mail-Chatter-search .o-mail-Message", { count: 30 });
await scroll(".o_content", "bottom");
await contains(".o-mail-Chatter-search .o-mail-Message", { count: 60 });
});
@@ -849,7 +849,7 @@ class UnfollowFromInboxTest(MailCommon):
partner_id = self.env.user.partner_id.id
return list(filter(
lambda m: m['id'] == message_id,
MailMessage._message_fetch(domain=[('needaction', '=', True)])._message_format_personalize(partner_id)))
MailMessage._message_fetch(domain=[('needaction', '=', True)])["messages"]._message_format_personalize(partner_id)))
@users('employee')
@mute_logger('odoo.models')
@@ -304,18 +304,18 @@ class TestDiscuss(MailCommon, TestRecipients):
message2 = self.test_record.with_user(self.user_admin).message_post(body='Message 2', partner_ids=[user1.partner_id.id, user2.partner_id.id])
# both notified users should have the 2 messages in Inbox initially
messages = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(messages), 2)
messages = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(messages), 2)
res = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(res["messages"]), 2)
res = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(res["messages"]), 2)
# first user is marking one message as done: the other message is still Inbox, while the other user still has the 2 messages in Inbox
message1.with_user(user1).set_message_done()
messages = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(messages), 1)
self.assertEqual(messages[0].id, message2.id)
messages = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(messages), 2)
res = self.env['mail.message'].with_user(user1)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(res["messages"]), 1)
self.assertEqual(res["messages"][0].id, message2.id)
res = self.env['mail.message'].with_user(user2)._message_fetch(domain=[['needaction', '=', True]])
self.assertEqual(len(res["messages"]), 2)
def test_notification_has_error_filter(self):
"""Ensure message_has_error filter is only returning threads for which