[IMP] mail: make mentions in chatter full composer

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) <aku@odoo.com>
This commit is contained in:
Zelong Lin
2023-10-05 16:49:04 +00:00
committed by Alexandre Kühn
parent f0b7838f95
commit 69fe16d42d
14 changed files with 285 additions and 28 deletions
@@ -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"
@@ -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;
}
@@ -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;
@@ -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;
@@ -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: [],
};
}
}
@@ -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") || "{}"
@@ -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");
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.Wysiwyg.mentionLink">
<a
t-att-href="href"
t-att-class="option.partner ? 'o_mail_redirect' : 'o_channel_redirect'"
t-att-data-oe-id="option.partner ? option.partner.id : option.channel.id"
t-att-data-oe-model="option.partner ? 'res.partner' : 'discuss.channel'"
target="_blank"
data-oe-protected="true"
contenteditable="false"
/>
</t>
<t t-name="mail.MentionList">
<div class="search d-flex">
<input type="text" class="form-control border-0 flex-grow-1 rounded-end-0" t-ref="autofocus" t-att-placeholder="placeholder" t-model="state.searchTerm" t-on-keydown="onKeydown"/>
<i class="oi oi-search p-2 fs-5 rounded-end" title="Search..." role="img" aria-label="Search..."/>
</div>
<NavigableList t-props="navigableListProps"/>
</t>
</templates>
@@ -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();
},
});
@@ -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);
}
@@ -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 = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target}>${mention.text}</a>`;
const link = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target} contenteditable="false">${mention.text}</a>`;
body = body.replace(mention.placeholder, link);
}
return body;
@@ -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)];
}
}
},
});
@@ -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",
+1 -1
View File
@@ -46,7 +46,7 @@ export function useAutofocus({ refName, selectAll, mobile } = {}) {
return ref;
}
// LEGACY
if (isMobileOS()) {
if (!mobile && isMobileOS()) {
return ref;
}
// LEGACY