[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:
committed by
Alexandre Kühn
parent
f0b7838f95
commit
69fe16d42d
@@ -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",
|
||||
|
||||
@@ -46,7 +46,7 @@ export function useAutofocus({ refName, selectAll, mobile } = {}) {
|
||||
return ref;
|
||||
}
|
||||
// LEGACY
|
||||
if (isMobileOS()) {
|
||||
if (!mobile && isMobileOS()) {
|
||||
return ref;
|
||||
}
|
||||
// LEGACY
|
||||
|
||||
Reference in New Issue
Block a user