diff --git a/addons/mail/static/src/core/common/composer.js b/addons/mail/static/src/core/common/composer.js
index db67322f070..b5e58f80594 100644
--- a/addons/mail/static/src/core/common/composer.js
+++ b/addons/mail/static/src/core/common/composer.js
@@ -7,7 +7,7 @@ import { Picker, usePicker } from "@mail/core/common/picker";
import { MessageConfirmDialog } from "@mail/core/common/message_confirm_dialog";
import { NavigableList } from "@mail/core/common/navigable_list";
import { useSuggestion } from "@mail/core/common/suggestion_hook";
-import { escapeAndCompactTextContent } from "@mail/utils/common/format";
+import { prettifyMessageContent } from "@mail/utils/common/format";
import { useSelection } from "@mail/utils/common/hooks";
import { isDragSourceExternalFile } from "@mail/utils/common/misc";
import { isEventHandled, markEventHandled } from "@web/core/utils/misc";
@@ -416,9 +416,16 @@ export class Composer extends Component {
}
}
const attachmentIds = this.props.composer.attachments.map((attachment) => attachment.id);
+ const body = this.props.composer.textInputContent;
+ const validMentions = this.store.user
+ ? this.messageService.getMentionsFromText(body, {
+ mentionedChannels: this.props.composer.mentionedChannels,
+ mentionedPartners: this.props.composer.mentionedPartners,
+ })
+ : undefined;
const context = {
default_attachment_ids: attachmentIds,
- default_body: escapeAndCompactTextContent(this.props.composer.textInputContent),
+ default_body: await prettifyMessageContent(body, validMentions),
default_model: this.thread.model,
default_partner_ids:
this.props.type === "note"
diff --git a/addons/mail/static/src/core/common/core.scss b/addons/mail/static/src/core/common/core.scss
index 09a3d35795f..b1b7351c2d4 100644
--- a/addons/mail/static/src/core/common/core.scss
+++ b/addons/mail/static/src/core/common/core.scss
@@ -41,3 +41,14 @@
.o-z-index-1 {
z-index: 1;
}
+
+a.o_mail_redirect, a.o_channel_redirect {
+ background: rgba($primary, .2) !important;
+ @extend .btn;
+ @extend .btn-sm;
+ @extend .btn-link;
+ @extend .align-baseline;
+ @extend .fw-normal;
+ @extend .px-1;
+ @extend .py-0;
+}
diff --git a/addons/mail/static/src/core/common/message.scss b/addons/mail/static/src/core/common/message.scss
index a56e722bb05..5e2dc979210 100644
--- a/addons/mail/static/src/core/common/message.scss
+++ b/addons/mail/static/src/core/common/message.scss
@@ -21,20 +21,13 @@
}
.o-mail-Message-body > p {
- margin-bottom: 0;
+ margin-bottom: 0 !important;
&:last-of-type:has(~ .o-mail-Message-edited) {
display: inline-block;
}
}
-.o-mail-Message-body {
- a.o_mail_redirect {
- padding: map-get($spacers, 1);
- border-radius: $border-radius;
- background: rgba($primary, .2);
- }
-}
.o-mail-ChatWindow .o-mail-Message.o-selfAuthored {
flex-direction: row-reverse;
diff --git a/addons/mail/static/src/core/common/suggestion_hook.js b/addons/mail/static/src/core/common/suggestion_hook.js
index 5afc2466cc4..7444ace3939 100644
--- a/addons/mail/static/src/core/common/suggestion_hook.js
+++ b/addons/mail/static/src/core/common/suggestion_hook.js
@@ -133,11 +133,10 @@ export function useSuggestion() {
if (!self.search.delimiter) {
return;
}
- const suggestions = suggestionService.searchSuggestions(
- self.search,
- { thread: self.thread },
- true
- );
+ const suggestions = suggestionService.searchSuggestions(self.search, {
+ thread: self.thread,
+ sort: true,
+ });
const { type, mainSuggestions, extraSuggestions = [] } = suggestions;
if (!mainSuggestions.length && !extraSuggestions.length) {
self.state.items = undefined;
diff --git a/addons/mail/static/src/core/common/suggestion_service.js b/addons/mail/static/src/core/common/suggestion_service.js
index 094eb7a7889..8dbb99ffe78 100644
--- a/addons/mail/static/src/core/common/suggestion_service.js
+++ b/addons/mail/static/src/core/common/suggestion_service.js
@@ -35,16 +35,16 @@ export class SuggestionService {
/**
* @param {string} term
- * @param {import("models").Thread} thread
+ * @param {import("models").Thread} [thread]
*/
async fetchPartners(term, thread) {
const kwargs = { search: term };
- if (thread.model === "discuss.channel") {
+ if (thread?.model === "discuss.channel") {
kwargs.channel_id = thread.id;
}
const suggestedPartners = await this.orm.silent.call(
"res.partner",
- thread.model === "discuss.channel"
+ thread?.model === "discuss.channel"
? "get_mention_suggestions_from_channel"
: "get_mention_suggestions",
[],
@@ -85,9 +85,9 @@ export class SuggestionService {
* @param {Object} [options={}]
* @param {Integer} [options.thread] prioritize and/or restrict
* result in the context of given thread
- * @returns {[mainSuggestion[], extraSuggestion[]]}
+ * @returns {{ type: String, mainSuggestions: Array, extraSuggestions: Array }}
*/
- searchSuggestions({ delimiter, term }, { thread } = {}, sort = false) {
+ searchSuggestions({ delimiter, term }, { thread, sort = false } = {}) {
const cleanedSearchTerm = cleanTerm(term);
switch (delimiter) {
case "@": {
@@ -242,6 +242,7 @@ export class SuggestionService {
return {
type: "Thread",
mainSuggestions: sort ? suggestionList.sort(sortFunc) : suggestionList,
+ extraSuggestions: [],
};
}
}
diff --git a/addons/mail/static/src/core/common/thread_service.js b/addons/mail/static/src/core/common/thread_service.js
index 28d760fcb61..82c528a12bc 100644
--- a/addons/mail/static/src/core/common/thread_service.js
+++ b/addons/mail/static/src/core/common/thread_service.js
@@ -697,7 +697,13 @@ export class ThreadService {
if (parentId) {
tmpData.parentMessage = this.store.Message.get(parentId);
}
- const prettyContent = await prettifyMessageContent(body, params.validMentions);
+ const prettyContent = await prettifyMessageContent(
+ body,
+ this.messageService.getMentionsFromText(body, {
+ mentionedChannels,
+ mentionedPartners,
+ })
+ );
const { emojis } = await loadEmoji();
const recentEmojis = JSON.parse(
browser.localStorage.getItem("web.emoji.frequent") || "{}"
diff --git a/addons/mail/static/src/core/web/mention_list.js b/addons/mail/static/src/core/web/mention_list.js
new file mode 100644
index 00000000000..80032eadb6b
--- /dev/null
+++ b/addons/mail/static/src/core/web/mention_list.js
@@ -0,0 +1,102 @@
+/** @odoo-module */
+
+import { _t } from "@web/core/l10n/translation";
+import { Component, useEffect, useState } from "@odoo/owl";
+import { useService, useAutofocus } from "@web/core/utils/hooks";
+
+import { NavigableList } from "@mail/core/common/navigable_list";
+import { useSequential } from "@mail/utils/common/hooks";
+import { markEventHandled } from "@web/core/utils/misc";
+
+export class MentionList extends Component {
+ static template = "mail.MentionList";
+ static components = { NavigableList };
+ static props = {
+ onSelect: { type: Function },
+ close: { type: Function, optional: true },
+ type: { type: String },
+ };
+ setup() {
+ this.state = useState({
+ searchTerm: "",
+ options: [],
+ });
+ this.orm = useService("orm");
+ this.store = useState(useService("mail.store"));
+ this.suggestionService = useService("mail.suggestion");
+ this.sequential = useSequential();
+ this.ref = useAutofocus({ mobile: true });
+
+ useEffect(
+ () => {
+ if (!this.state.searchTerm) {
+ this.state.options = [];
+ return;
+ }
+ this.sequential(async () => {
+ await this.suggestionService.fetchSuggestions({
+ delimiter: this.props.type === "partner" ? "@" : "#",
+ term: this.state.searchTerm,
+ });
+ const suggestions = this.suggestionService.searchSuggestions(
+ {
+ delimiter: this.props.type === "partner" ? "@" : "#",
+ term: this.state.searchTerm,
+ },
+ { sort: true }
+ );
+ this.state.options = [
+ ...suggestions.mainSuggestions,
+ ...suggestions.extraSuggestions,
+ ];
+ });
+ },
+ () => [this.state.searchTerm]
+ );
+ }
+
+ get placeholder() {
+ switch (this.props.type) {
+ case "channel":
+ return _t("Search for a channel...");
+ case "partner":
+ return _t("Search for an user...");
+ default:
+ return _t("Search...");
+ }
+ }
+
+ get navigableListProps() {
+ const props = {
+ anchorRef: this.ref.el,
+ position: "bottom-fit",
+ placeholder: _t("Loading"),
+ onSelect: this.props.onSelect,
+ options: [],
+ };
+ switch (this.props.type) {
+ case "partner":
+ this.state.options.forEach((option) => {
+ props.options.push({
+ label: option.name,
+ partner: option,
+ });
+ });
+ break;
+ case "channel": {
+ this.state.options.forEach((option) => {
+ props.options.push({
+ label: option.name,
+ channel: option,
+ });
+ });
+ break;
+ }
+ }
+ return props;
+ }
+
+ onKeydown(ev) {
+ markEventHandled(ev, "MentionList.onKeydown");
+ }
+}
diff --git a/addons/mail/static/src/core/web/mention_list.xml b/addons/mail/static/src/core/web/mention_list.xml
new file mode 100644
index 00000000000..64823cb4e7f
--- /dev/null
+++ b/addons/mail/static/src/core/web/mention_list.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mail/static/src/core/web/wysiwyg.js b/addons/mail/static/src/core/web/wysiwyg.js
new file mode 100644
index 00000000000..b6f76a26caa
--- /dev/null
+++ b/addons/mail/static/src/core/web/wysiwyg.js
@@ -0,0 +1,86 @@
+/** @odoo-module **/
+
+import { renderToElement } from "@web/core/utils/render";
+import { Wysiwyg } from "@web_editor/js/wysiwyg/wysiwyg";
+import { closestBlock, setCursorEnd } from "@web_editor/js/editor/odoo-editor/src/OdooEditor";
+import { patch } from "@web/core/utils/patch";
+import { MentionList } from "@mail/core/web/mention_list";
+import { url } from "@web/core/utils/urls";
+import { usePopover } from "@web/core/popover/popover_hook";
+import { isEventHandled } from "@web/core/utils/misc";
+
+patch(Wysiwyg.prototype, {
+ setup() {
+ super.setup();
+ if (this.inDiscuss) {
+ this.mentionList = usePopover(MentionList, {
+ position: `bottom-start`,
+ onClose: () => this.focus(),
+ });
+ this.triggerMentionList = this.triggerMentionList.bind(this);
+ }
+ },
+ get inDiscuss() {
+ return this.props.options.recordInfo?.res_model === "mail.compose.message";
+ },
+ async startEdition() {
+ const res = await super.startEdition(...arguments);
+ // Only enable mention list in full chatter mode web editor
+ if (this.inDiscuss) {
+ this.odooEditor.document.addEventListener("keydown", this.triggerMentionList, true);
+ this.odooEditor.document.addEventListener("click", this.triggerMentionList, true);
+ }
+ return res;
+ },
+ destroy() {
+ super.destroy();
+ if (this.inDiscuss && this.odooEditor) {
+ this.odooEditor.document.removeEventListener("keydown", this.triggerMentionList, true);
+ this.odooEditor.document.removeEventListener("click", this.triggerMentionList, true);
+ }
+ },
+ async triggerMentionList(ev) {
+ if (!this.inDiscuss) {
+ return;
+ }
+ // Let event be handled by bubbling handlers and other handlers from Odoo Editor first.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ const selection = this.odooEditor.document.getSelection();
+ if (
+ this.isSelectionInEditable() &&
+ selection.isCollapsed &&
+ selection.rangeCount &&
+ !this.mentionList.isOpen &&
+ (ev.key === "@" || ev.key === "#")
+ ) {
+ this.stepBeforeMention = this.odooEditor._historySteps.length - 2;
+ const closest = closestBlock(this.odooEditor.document.getSelection().anchorNode);
+ this.mentionList.open(closest, {
+ type: ev.key === "@" ? "partner" : "channel",
+ onSelect: this.selectMention.bind(this),
+ });
+ } else if (!isEventHandled(ev, "MentionList.onKeydown")) {
+ this.mentionList.close();
+ }
+ },
+ selectMention(ev, option) {
+ if (!this.inDiscuss) {
+ return;
+ }
+ this.mentionList.close();
+ const mentionBlock = renderToElement("mail.Wysiwyg.mentionLink", {
+ option,
+ href: `${url("/web")}#model=${option.partner ? "res.partner" : "discuss.channel"}&id=${
+ option.partner ? option.partner.id : option.channel.id
+ }`,
+ });
+ const nameNode = document.createTextNode(`${option.partner ? "@" : "#"}${option.label}`);
+ const space = document.createTextNode("\u00A0");
+ mentionBlock.appendChild(nameNode);
+ this.odooEditor.historyRevertUntil(this.stepBeforeMention);
+ this.odooEditor.execCommand("insert", mentionBlock);
+ this.odooEditor.execCommand("insert", space);
+ setCursorEnd(space, false);
+ this.odooEditor.historyStep();
+ },
+});
diff --git a/addons/mail/static/src/discuss/core/common/suggestion_service_patch.js b/addons/mail/static/src/discuss/core/common/suggestion_service_patch.js
index 02cd9a14b54..ce38f37e9c0 100644
--- a/addons/mail/static/src/discuss/core/common/suggestion_service_patch.js
+++ b/addons/mail/static/src/discuss/core/common/suggestion_service_patch.js
@@ -16,7 +16,7 @@ patch(SuggestionService.prototype, {
/**
* @override
*/
- searchSuggestions({ delimiter, term }, { thread } = {}, sort = false) {
+ searchSuggestions({ delimiter, term }, { thread, sort = false } = {}) {
if (delimiter === "/") {
return this.searchChannelCommand(cleanTerm(term), thread, sort);
}
diff --git a/addons/mail/static/src/utils/common/format.js b/addons/mail/static/src/utils/common/format.js
index cc208da6cac..6a3134211b7 100644
--- a/addons/mail/static/src/utils/common/format.js
+++ b/addons/mail/static/src/utils/common/format.js
@@ -196,7 +196,7 @@ function generateMentionsLinks(body, { partners = [], threads = [] }) {
const dataOeId = `data-oe-id='${mention.id}'`;
const dataOeModel = `data-oe-model='${mention.model}'`;
const target = "target='_blank'";
- const link = `${mention.text}`;
+ const link = `${mention.text}`;
body = body.replace(mention.placeholder, link);
}
return body;
diff --git a/addons/mail/static/src/views/web/form/form_controller.js b/addons/mail/static/src/views/web/form/form_controller.js
index fd1ca50356d..b0389e682f6 100644
--- a/addons/mail/static/src/views/web/form/form_controller.js
+++ b/addons/mail/static/src/views/web/form/form_controller.js
@@ -2,6 +2,7 @@
import { patch } from "@web/core/utils/patch";
import { FormController } from "@web/views/form/form_controller";
+import { x2ManyCommands } from "@web/core/orm_service";
patch(FormController.prototype, {
onWillLoadRoot(nextConfiguration) {
@@ -15,4 +16,21 @@ patch(FormController.prototype, {
this.env.bus.trigger("MAIL:RELOAD-THREAD", { resModel, resId });
}
},
+
+ async onWillSaveRecord(record, changes) {
+ if (record.resModel === "mail.compose.message") {
+ const parser = new DOMParser();
+ const htmlBody = parser.parseFromString(changes.body, "text/html");
+ const partnerElements = htmlBody.querySelectorAll('[data-oe-model="res.partner"]');
+ const partnerIds = Array.from(partnerElements).map((element) =>
+ parseInt(element.dataset.oeId)
+ );
+ if (partnerIds.length) {
+ if (changes.partner_ids[0] && changes.partner_ids[0][0] === x2ManyCommands.SET) {
+ partnerIds.push(...changes.partner_ids[0][2]);
+ }
+ changes.partner_ids = [x2ManyCommands.set(partnerIds)];
+ }
+ }
+ },
});
diff --git a/addons/mail/static/tests/tours/mail_full_composer_test_tour.js b/addons/mail/static/tests/tours/mail_full_composer_test_tour.js
index 3e3938ebef9..f04ffe57dfd 100644
--- a/addons/mail/static/tests/tours/mail_full_composer_test_tour.js
+++ b/addons/mail/static/tests/tours/mail_full_composer_test_tour.js
@@ -19,7 +19,11 @@ registry.category("web_tour.tours").add("mail/static/tests/tours/mail_full_compo
{
content: "Write something in composer",
trigger: ".o-mail-Composer-input",
- run: "text blahblah",
+ run: "text blahblah @",
+ },
+ {
+ content: "Mention a partner",
+ trigger: ".o-mail-Composer-suggestion:contains(Marc Demo)",
},
{
content: "Add one file in composer",
@@ -75,9 +79,17 @@ registry.category("web_tour.tours").add("mail/static/tests/tours/mail_full_compo
const bodyContent = document.querySelector(
'.o_field_html[name="body"]'
).textContent;
- if (!bodyContent.includes("blahblah")) {
+ if (!bodyContent.includes("blahblah @Marc Demo")) {
console.error(
- `Full composer should contain text from small composer ("blahblah") in body input (actual: ${bodyContent})`
+ `Full composer should contain text from small composer ("blahblah @Marc Demo") in body input (actual: ${bodyContent})`
+ );
+ }
+ const mentionLink = document.querySelector(
+ '.o_field_html[name="body"] a'
+ ).textContent;
+ if (!mentionLink.includes("@Marc Demo")) {
+ console.error(
+ `Full composer should contain mention link from small composer ("@Marc Demo") in body input)`
);
}
},
@@ -98,7 +110,7 @@ registry.category("web_tour.tours").add("mail/static/tests/tours/mail_full_compo
},
{
content: "Check message is shown",
- trigger: '.o-mail-Message-body:contains("blahblah")',
+ trigger: '.o-mail-Message-body:contains("blahblah @Marc Demo")',
},
{
content: "Check message contains the attachment",
diff --git a/addons/web/static/src/core/utils/hooks.js b/addons/web/static/src/core/utils/hooks.js
index 272b178529f..135acc90f33 100644
--- a/addons/web/static/src/core/utils/hooks.js
+++ b/addons/web/static/src/core/utils/hooks.js
@@ -46,7 +46,7 @@ export function useAutofocus({ refName, selectAll, mobile } = {}) {
return ref;
}
// LEGACY
- if (isMobileOS()) {
+ if (!mobile && isMobileOS()) {
return ref;
}
// LEGACY