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