Files
odoo_source/addons/mail/static/src/composer/composer.js
T
Sébastien Theys 90cb44e1e1 [REF] mail, *: rename mail.channel to discuss.channel
* = bus, calendar, crm_livechat, hr, hr_holidays, im_livechat, mail_bot,
    mass_mailing, privacy_lookup, test_discuss_full, test_mail,
    test_mail_full, website_crm_livechat, website_livechat, base

In preparation of splitting discuss and mail modules.

Part of task-3265211

closes odoo/odoo#118354

Related: odoo/upgrade#4553
Related: odoo/enterprise#39661
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
2023-04-21 02:21:53 +02:00

521 lines
18 KiB
JavaScript

/* @odoo-module */
import { AttachmentList } from "@mail/attachments/attachment_list";
import { useAttachmentUploader } from "@mail/attachments/attachment_uploader_hook";
import { onExternalClick, useSelection } from "@mail/utils/hooks";
import { isDragSourceExternalFile, isEventHandled, markEventHandled } from "@mail/utils/misc";
import { Component, onMounted, useChildSubEnv, useEffect, useRef, useState } from "@odoo/owl";
import { useDropzone } from "../dropzone/dropzone_hook";
import { useMessaging, useStore } from "../core/messaging_hook";
import { useEmojiPicker } from "../emoji_picker/emoji_picker";
import { sprintf } from "@web/core/utils/strings";
import { escapeAndCompactTextContent } from "../utils/format.js";
import { FileUploader } from "@web/views/fields/file_handler";
import { Typing } from "./typing";
import { NavigableList } from "@mail/composer/navigable_list";
import { useDebounced } from "@web/core/utils/timing";
import { useSuggestion } from "@mail/composer/suggestion_hook";
import { browser } from "@web/core/browser/browser";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { MessageConfirmDialog } from "../core_ui/message_confirm_dialog";
export const SHORT_TYPING = 5000;
export const LONG_TYPING = 50000;
/**
* @typedef {Object} Props
* @property {import("@mail/composer/composer_model").Composer} composer
* @property {import("@mail/utils/hooks").MessageToReplyTo} messageToReplyTo
* @extends {Component<Props, Env>}
*/
export class Composer extends Component {
static components = {
AttachmentList,
FileUploader,
Typing,
NavigableList,
};
static defaultProps = {
mode: "normal",
className: "",
}; // mode = compact, normal, extended
static props = [
"composer",
"autofocus?",
"messageToReplyTo?",
"onDiscardCallback?",
"onPostCallback?",
"mode?",
"placeholder?",
"dropzoneRef?",
"messageEdition?",
"messageComponent?",
"className?",
];
static template = "mail.Composer";
setup() {
this.messaging = useMessaging();
this.store = useStore();
if (this.allowUpload) {
this.attachmentUploader = useAttachmentUploader(
this.props.messageToReplyTo?.message?.originThread ??
this.props.composer.thread ??
this.props.composer.message.originThread,
{ composer: this.props.composer }
);
}
this.messageService = useState(useService("mail.message"));
/** @type {import("@mail/core/thread_service").ThreadService} */
this.threadService = useService("mail.thread");
this.ref = useRef("textarea");
this.fakeTextarea = useRef("fakeTextarea");
this.typingNotified = false;
this.state = useState({
autofocus: 0,
active: true,
});
this.stopTyping = useDebounced(() => {
this.notifyIsTyping(false);
this.typingNotified = false;
}, SHORT_TYPING);
this.selection = useSelection({
refName: "textarea",
model: this.props.composer.selection,
preserveOnClickAwayPredicate: async (ev) => {
// Let event be handled by bubbling handlers first.
await new Promise(setTimeout);
return (
!this.isEventTrusted(ev) ||
isEventHandled(ev, "sidebar.openThread") ||
isEventHandled(ev, "emoji.selectEmoji") ||
isEventHandled(ev, "composer.clickOnAddEmoji") ||
isEventHandled(ev, "composer.clickOnAddAttachment") ||
isEventHandled(ev, "composer.selectSuggestion")
);
},
});
this.suggestion = this.store.user ? useSuggestion() : undefined;
this.markEventHandled = markEventHandled;
if (this.props.dropzoneRef && this.allowUpload) {
useDropzone(
this.props.dropzoneRef,
(ev) => {
if (isDragSourceExternalFile(ev.dataTransfer)) {
for (const file of ev.dataTransfer.files) {
this.attachmentUploader.uploadFile(file);
}
}
},
"o-mail-Composer-dropzone"
);
}
if (this.props.messageEdition) {
this.props.messageEdition.composerOfThread = this;
}
useChildSubEnv({
inComposer: true,
});
useEmojiPicker(useRef("emoji-picker"), {
onSelect: (str) => this.addEmoji(str),
onClose: () => this.state.autofocus++,
});
useEffect(
(focus) => {
if (focus && this.ref.el) {
this.selection.restore();
this.ref.el.focus();
}
},
() => [this.props.autofocus + this.state.autofocus, this.props.placeholder]
);
useEffect(
(rThread, cThread) => {
if (cThread && rThread === cThread) {
this.state.autofocus++;
}
},
() => [this.props.messageToReplyTo?.thread, this.props.composer.thread]
);
useEffect(
() => {
this.ref.el.style.height = this.fakeTextarea.el.scrollHeight + "px";
},
() => [this.props.composer.textInputContent, this.ref.el]
);
useEffect(
() => {
if (!this.props.composer.forceCursorMove) {
return;
}
this.selection.restore();
this.props.composer.forceCursorMove = false;
},
() => [this.props.composer.forceCursorMove]
);
onMounted(() => {
this.ref.el.scrollTo({ top: 0, behavior: "instant" });
});
onExternalClick("composer", async (ev) => {
// Let event be handled by bubbling handlers first.
await new Promise(setTimeout);
if (isEventHandled(ev, "message.replyTo") || isEventHandled(ev, "emoji.selectEmoji")) {
return;
}
this.props.messageToReplyTo?.cancel();
});
}
onInput(ev) {
if (!this.typingNotified && !ev.target.value.startsWith("/")) {
this.notifyIsTyping();
this.typingNotified = true;
browser.setTimeout(() => {
this.typingNotified = false;
}, LONG_TYPING);
}
this.stopTyping();
}
get placeholder() {
if (this.props.placeholder) {
return this.props.placeholder;
}
if (this.thread) {
if (this.thread.type === "channel") {
return sprintf(_t("Message #%(thread name)s…"), {
"thread name": this.thread.displayName,
});
}
return sprintf(_t("Message %(thread name)s…"), {
"thread name": this.thread.displayName,
});
}
return "";
}
get thread() {
return this.props.composer.thread ?? null;
}
get allowUpload() {
return true;
}
get message() {
return this.props.composer.message ?? null;
}
get isSendButtonDisabled() {
const attachments = this.props.composer.attachments;
return (
!this.state.active ||
(!this.props.composer.textInputContent && attachments.length === 0) ||
attachments.some(({ uploading }) => Boolean(uploading))
);
}
get hasSuggestions() {
return Boolean(this.suggestion?.state.items);
}
get navigableListProps() {
const props = {
anchorRef: this.ref.el,
position: this.thread?.type === "chatter" ? "bottom-fit" : "top-fit",
placeholder: _t("Loading"),
onSelect: (ev, option) => {
this.suggestion.insert(option);
markEventHandled(ev, "composer.selectSuggestion");
},
options: [],
};
if (!this.hasSuggestions) {
return props;
}
const suggestions = Array(
...this.suggestion.state.items.mainSuggestions,
...this.suggestion.state.items.extraSuggestions
);
switch (this.suggestion.state.items.type) {
case "Partner":
return {
...props,
optionTemplate: "mail.Composer.suggestionPartner",
options: suggestions.map((suggestion) => {
return {
label: suggestion.name,
partner: suggestion,
classList: "o-mail-Composer-suggestion",
};
}),
};
case "Thread":
return {
...props,
optionTemplate: "mail.Composer.suggestionThread",
options: suggestions.map((suggestion) => {
return {
label: suggestion.displayName,
thread: suggestion,
classList: "o-mail-Composer-suggestion",
};
}),
};
case "ChannelCommand":
return {
...props,
optionTemplate: "mail.Composer.suggestionChannelCommand",
options: suggestions.map((suggestion) => {
return {
label: suggestion.name,
help: suggestion.help,
classList: "o-mail-Composer-suggestion",
};
}),
};
case "CannedResponse":
return {
...props,
optionTemplate: "mail.Composer.suggestionCannedResponse",
options: suggestions.map((suggestion) => {
return {
name: suggestion.name,
label: suggestion.substitution,
classList: "o-mail-Composer-suggestion",
};
}),
};
default:
return props;
}
}
/**
* This doesn't work on firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1699743
*/
onPaste(ev) {
if (!this.allowUpload) {
return;
}
if (!ev.clipboardData?.items) {
return;
}
if (ev.clipboardData.files.length === 0) {
return;
}
ev.preventDefault();
for (const file of ev.clipboardData.files) {
this.attachmentUploader.uploadFile(file);
}
}
onKeydown(ev) {
switch (ev.key) {
case "ArrowUp":
if (this.hasSuggestions) {
return;
}
if (this.props.messageEdition) {
const messageToEdit = this.props.composer.thread.lastEditableMessageOfSelf;
if (messageToEdit) {
this.props.messageEdition.editingMessage = messageToEdit;
}
}
break;
case "Enter": {
if (isEventHandled(ev, "NavigableList.select") || !this.state.active) {
ev.preventDefault();
return;
}
const shouldPost = this.props.mode === "extended" ? ev.ctrlKey : !ev.shiftKey;
if (!shouldPost) {
return;
}
ev.preventDefault(); // to prevent useless return
if (this.props.composer.message) {
this.editMessage();
} else {
this.sendMessage();
}
break;
}
case "Escape":
if (isEventHandled(ev, "NavigableList.close")) {
return;
}
if (this.props.onDiscardCallback) {
this.props.onDiscardCallback();
markEventHandled(ev, "Composer.discard");
}
break;
}
}
onClickAddAttachment(ev) {
markEventHandled(ev, "composer.clickOnAddAttachment");
this.state.autofocus++;
}
async onClickFullComposer(ev) {
const attachmentIds = this.props.composer.attachments.map((attachment) => attachment.id);
const context = {
default_attachment_ids: attachmentIds,
default_body: escapeAndCompactTextContent(this.props.composer.textInputContent),
default_model: this.props.composer.thread.model,
default_partner_ids: this.props.composer.thread.suggestedRecipients.map(
(partner) => partner.id
),
default_res_ids: [this.props.composer.thread.id],
default_subtype_xmlid:
this.props.composer.type === "note" ? "mail.mt_note" : "mail.mt_comment",
mail_post_autofollow: this.props.composer.thread.hasWriteAccess,
};
const action = {
name: this.props.composer.type === "note" ? _t("Log note") : _t("Compose Email"),
type: "ir.actions.act_window",
res_model: "mail.compose.message",
view_mode: "form",
views: [[false, "form"]],
target: "new",
context: context,
};
const options = {
onClose: () => {
this.clear();
if (this.props.composer.thread) {
this.threadService.fetchNewMessages(this.props.composer.thread);
}
},
};
await this.env.services.action.doAction(action, options);
}
clear() {
this.attachmentUploader?.clear();
this.threadService.clearComposer(this.props.composer);
}
onClickAddEmoji(ev) {
markEventHandled(ev, "composer.clickOnAddEmoji");
}
isEventTrusted(ev) {
// Allow patching during tests
return ev.isTrusted;
}
async processMessage(cb) {
const el = this.ref.el;
const attachments = this.props.composer.attachments;
if (
el.value.trim() ||
(attachments.length > 0 && attachments.every(({ uploading }) => !uploading)) ||
(this.message && this.message.attachments.length > 0)
) {
if (!this.state.active) {
return;
}
this.state.active = false;
await cb(el.value);
if (this.props.onPostCallback) {
this.props.onPostCallback();
}
this.clear();
this.state.active = true;
el.focus();
} else if (attachments.some(({ uploading }) => Boolean(uploading))) {
this.env.services.notification.add(
this.env._t("Please wait while the file is uploading."),
{ type: "warning" }
);
}
}
async sendMessage() {
return this.processMessage(async (value) => {
const thread =
this.props.messageToReplyTo?.message?.originThread ?? this.props.composer.thread;
const postData = {
attachments: this.props.composer.attachments,
isNote:
this.props.composer.type === "note" ||
this.props.messageToReplyTo?.message?.isNote,
rawMentions: this.props.composer.rawMentions,
parentId: this.props.messageToReplyTo?.message?.id,
};
const message = await this.threadService.post(thread, value, postData);
if (this.props.composer.thread.type === "mailbox") {
this.env.services.notification.add(
sprintf(_t('Message posted on "%s"'), message.originThread.displayName),
{ type: "info" }
);
}
this.suggestion?.clearRawMentions();
this.props.messageToReplyTo?.cancel();
if (this.typingNotified) {
this.typingNotified = false;
this.notifyIsTyping(false);
}
});
}
/**
* Notify the server of the current typing status
*
* @param {boolean} [is_typing=true]
*/
notifyIsTyping(is_typing = true) {
if (["chat", "channel", "group"].includes(this.thread?.type)) {
this.messaging.rpc(
"/discuss/channel/notify_typing",
{
channel_id: this.thread.id,
is_typing,
},
{ silent: true }
);
}
}
async editMessage() {
if (this.ref.el.value || this.props.composer.message.attachments.length > 0) {
await this.processMessage(async (value) =>
this.messageService.edit(
this.props.composer.message,
value,
this.props.composer.attachments,
this.props.composer.rawMentions
)
);
} else {
this.env.services.dialog.add(MessageConfirmDialog, {
message: this.props.composer.message,
messageComponent: this.props.messageComponent,
onConfirm: () => this.messageService.delete(this.message),
prompt: _t("Are you sure you want to delete this message?"),
});
}
this.suggestion?.clearRawMentions();
}
addEmoji(str) {
const textContent = this.ref.el.value;
const firstPart = textContent.slice(0, this.props.composer.selection.start);
const secondPart = textContent.slice(this.props.composer.selection.end, textContent.length);
this.props.composer.textInputContent = firstPart + str + secondPart;
this.selection.moveCursor((firstPart + str).length);
this.state.autofocus++;
}
onFocusin() {
this.props.composer.isFocused = true;
if (this.props.composer.thread) {
this.threadService.markAsRead(this.props.composer.thread);
}
}
}