From 69fe16d42d20156ebec3edc44415ddc7089d2b30 Mon Sep 17 00:00:00 2001 From: Zelong Lin Date: Thu, 5 Oct 2023 11:20:53 +0200 Subject: [PATCH] [IMP] mail: make mentions in chatter full composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit, it was not possible to send messages in full composer containing user mentions. Even whe the mentions were added in the small composer and then continuing composing the message in the full composer. With this commit, we can now make user and channel mentions in the full composer, by typing `@` and `#` like in the small composer of Discuss. When composing a message in small composer and then switching to full composer, the mentions are preserved. task-3470060 closes odoo/odoo#132701 Signed-off-by: Alexandre Kühn (aku) --- .../mail/static/src/core/common/composer.js | 11 +- addons/mail/static/src/core/common/core.scss | 11 ++ .../mail/static/src/core/common/message.scss | 9 +- .../static/src/core/common/suggestion_hook.js | 9 +- .../src/core/common/suggestion_service.js | 11 +- .../static/src/core/common/thread_service.js | 8 +- .../mail/static/src/core/web/mention_list.js | 102 ++++++++++++++++++ .../mail/static/src/core/web/mention_list.xml | 22 ++++ addons/mail/static/src/core/web/wysiwyg.js | 86 +++++++++++++++ .../core/common/suggestion_service_patch.js | 2 +- addons/mail/static/src/utils/common/format.js | 2 +- .../src/views/web/form/form_controller.js | 18 ++++ .../tours/mail_full_composer_test_tour.js | 20 +++- addons/web/static/src/core/utils/hooks.js | 2 +- 14 files changed, 285 insertions(+), 28 deletions(-) create mode 100644 addons/mail/static/src/core/web/mention_list.js create mode 100644 addons/mail/static/src/core/web/mention_list.xml create mode 100644 addons/mail/static/src/core/web/wysiwyg.js 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