diff --git a/addons/mail/static/src/core/common/composer.js b/addons/mail/static/src/core/common/composer.js index b8a80bdb854..2af4adf264d 100644 --- a/addons/mail/static/src/core/common/composer.js +++ b/addons/mail/static/src/core/common/composer.js @@ -3,7 +3,7 @@ import { AttachmentList } from "@mail/core/common/attachment_list"; import { useAttachmentUploader } from "@mail/core/common/attachment_uploader_hook"; import { useDropzone } from "@mail/core/common/dropzone_hook"; -import { EmojiPicker, useEmojiPicker } from "@web/core/emoji_picker/emoji_picker"; +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"; @@ -18,12 +18,10 @@ import { onMounted, useChildSubEnv, useEffect, - useExternalListener, useRef, useState, } from "@odoo/owl"; -import { browser } from "@web/core/browser/browser"; import { _t } from "@web/core/l10n/translation"; import { useService } from "@web/core/utils/hooks"; import { FileUploader } from "@web/views/fields/file_handler"; @@ -52,7 +50,7 @@ const EDIT_CLICK_TYPE = { export class Composer extends Component { static components = { AttachmentList, - EmojiPicker, + Picker, FileUploader, NavigableList, }; @@ -82,10 +80,6 @@ export class Composer extends Component { this.SEND_KEYBIND_TO_SEND = markup( _t("%(send_keybind)s to send", { send_keybind: this.sendKeybind }) ); - this.KEYBOARD = { - NONE: "None", - EMOJI: "Emoji", - }; this.store = useState(useService("mail.store")); if (this.allowUpload) { this.attachmentUploader = useAttachmentUploader( @@ -97,12 +91,13 @@ export class Composer extends Component { this.threadService = useService("mail.thread"); this.ui = useState(useService("ui")); this.rpc = useService("rpc"); + this.mainActionsRef = useRef("main-actions"); this.ref = useRef("textarea"); this.fakeTextarea = useRef("fakeTextarea"); + this.emojiButton = useRef("emoji-button"); this.state = useState({ autofocus: 0, active: true, - keyboard: this.KEYBOARD.NONE, }); this.selection = useSelection({ refName: "textarea", @@ -132,26 +127,7 @@ export class Composer extends Component { useChildSubEnv({ inComposer: true, }); - if (!this.ui.isSmall) { - useEmojiPicker(useRef("emoji-button"), { - onSelect: (str) => this.addEmoji(str), - onClose: () => this.state.autofocus++, - }); - } - useExternalListener( - browser, - "click", - async (ev) => { - if (this.state.keyboard === this.KEYBOARD.NONE) { - return; - } - await new Promise(setTimeout); // let bubbling to catch marked event handled - if (!this.isEventHandledByPicker(ev)) { - this.state.keyboard = this.KEYBOARD.NONE; - } - }, - true - ); + this.picker = usePicker(this.pickerSettings); useEffect( (focus) => { if (focus && this.ref.el) { @@ -190,6 +166,20 @@ export class Composer extends Component { }); } + get pickerSettings() { + return { + anchor: this.props.mode === "extended" ? undefined : this.mainActionsRef, + buttons: [this.emojiButton], + close: () => { + if (!this.ui.isSmall) { + this.state.autofocus++; + } + }, + pickers: { emoji: (emoji) => this.addEmoji(emoji) }, + position: this.props.mode === "extended" ? "bottom-start" : "top-end", + }; + } + get placeholder() { if (this.props.placeholder) { return this.props.placeholder; @@ -234,17 +224,6 @@ export class Composer extends Component { return this.props.mode === "extended" ? _t("CTRL-Enter") : _t("Enter"); } - /** - * @param {Event} ev - * @returns {boolean} - */ - isEventHandledByPicker(ev) { - return ( - isEventHandled(ev, "Composer.onClickAddEmoji") || - isEventHandled(ev, "EmojiPicker.onClick") - ); - } - get thread() { return ( this.props.messageToReplyTo?.message?.originThread ?? this.props.composer.thread ?? null @@ -492,8 +471,6 @@ export class Composer extends Component { onClickAddEmoji(ev) { markEventHandled(ev, "Composer.onClickAddEmoji"); - this.state.keyboard = - this.state.keyboard === this.KEYBOARD.EMOJI ? this.KEYBOARD.NONE : this.KEYBOARD.EMOJI; } isEventTrusted(ev) { @@ -593,7 +570,9 @@ export class Composer extends Component { 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++; + if (!this.ui.isSmall) { + this.state.autofocus++; + } } onFocusin() { diff --git a/addons/mail/static/src/core/common/composer.scss b/addons/mail/static/src/core/common/composer.scss index 35321ab6fb7..1c04322c361 100644 --- a/addons/mail/static/src/core/common/composer.scss +++ b/addons/mail/static/src/core/common/composer.scss @@ -40,10 +40,6 @@ } } -.o-mail-Composer-bottomPicker { - height: 60vh; -} - .o-mail-Composer-input { padding-top: 10px; // carefully crafted to have the text in the middle in chat window padding-bottom: 10px; diff --git a/addons/mail/static/src/core/common/composer.xml b/addons/mail/static/src/core/common/composer.xml index 664867d6219..4c79bf2a0dc 100644 --- a/addons/mail/static/src/core/common/composer.xml +++ b/addons/mail/static/src/core/common/composer.xml @@ -70,11 +70,11 @@ 'mx-3 border-top p-1': extended, }" > -
- -
@@ -93,7 +93,7 @@ attachments="props.composer.attachments" unlinkAttachment.bind="(...args) => attachmentUploader.unlink(...args)" imagesHeight="50"/> - + diff --git a/addons/mail/static/src/core/common/core.scss b/addons/mail/static/src/core/common/core.scss index 05605518b57..2279e093411 100644 --- a/addons/mail/static/src/core/common/core.scss +++ b/addons/mail/static/src/core/common/core.scss @@ -22,6 +22,10 @@ font-size: 0.7em !important; } +.o-min-height-0 { + min-height: 0; +} + .o-min-width-0 { min-width: 0; } diff --git a/addons/mail/static/src/core/common/message_reaction_button.xml b/addons/mail/static/src/core/common/message_reaction_button.xml index 8f0049b7376..cc051d7cb9b 100644 --- a/addons/mail/static/src/core/common/message_reaction_button.xml +++ b/addons/mail/static/src/core/common/message_reaction_button.xml @@ -1,7 +1,7 @@ - + diff --git a/addons/mail/static/src/core/common/picker.js b/addons/mail/static/src/core/common/picker.js new file mode 100644 index 00000000000..e49f5f81725 --- /dev/null +++ b/addons/mail/static/src/core/common/picker.js @@ -0,0 +1,157 @@ +/* @odoo-module */ + +import { Component, useExternalListener, useState } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; +import { isEventHandled } from "@web/core/utils/misc"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { useService } from "@web/core/utils/hooks"; +import { PickerContent } from "@mail/core/common/picker_content"; +import { useLazyExternalListener } from "@mail/utils/common/hooks"; + +export function usePicker(setting) { + const storeScroll = { + scrollValue: 0, + set: (value) => (storeScroll.scrollValue = value), + get: () => storeScroll.scrollValue, + }; + const PICKERS = { + NONE: "none", + EMOJI: "emoji", + GIF: "gif", + }; + return useState({ + PICKERS, + anchor: setting.anchor, + buttons: setting.buttons, + close: setting.close, + pickers: setting.pickers, + position: setting.position, + state: { + picker: PICKERS.NONE, + searchTerm: "", + }, + storeScroll, + }); +} + +/** + * Picker/usePicker is a component hook that can be used to display the emoji picker/gif picker (if it is enabled). + * It can be used in two ways: + * - with a popover when in large screen: the picker will be displayed in a popover triggered by provided buttons. + * - with a keyboard when in mobile view: the picker will be displayed in place where the Picker component is placed. + * The switch between the two modes is done automatically based on the screen size. + */ + +export class Picker extends Component { + static components = { + PickerContent, + }; + static props = [ + "PICKERS", + "anchor?", + "buttons", + "close?", + "state", + "pickers", + "position?", + "storeScroll", + ]; + static template = "mail.Picker"; + + setup() { + this.ui = useState(useService("ui")); + this.popover = usePopover(PickerContent, { + position: this.props.position, + fixedPosition: true, + onClose: () => this.close(), + closeOnClickAway: false, + popoverClass: "o-fast-popover", + }); + useExternalListener( + browser, + "click", + async (ev) => { + if (this.props.state.picker === this.props.PICKERS.NONE) { + return; + } + await new Promise(setTimeout); // let bubbling to catch marked event handled + if (!this.isEventHandledByPicker(ev)) { + this.close(); + } + }, + true + ); + for (const button of this.props.buttons) { + useLazyExternalListener( + () => button.el, + "click", + async (ev) => this.toggle(this.props.anchor?.el ?? button.el, ev) + ); + } + } + + get contentProps() { + const pickers = {}; + for (const [name, fn] of Object.entries(this.props.pickers)) { + pickers[name] = (str, resetOnSelect) => { + fn(str); + if (resetOnSelect) { + this.close(); + } + }; + } + return { + PICKERS: this.props.PICKERS, + close: () => this.close(), + pickers, + state: this.props.state, + storeScroll: this.props.storeScroll, + }; + } + + /** + * @param {Event} ev + * @returns {boolean} + */ + isEventHandledByPicker(ev) { + return ( + isEventHandled(ev, "Composer.onClickAddEmoji") || + isEventHandled(ev, "PickerContent.onClick") + ); + } + + async toggle(el, ev) { + // Let event be handled by bubbling handlers first. + await new Promise(setTimeout); + // In small screen, we toggle keyboard picker. + if (this.ui.isSmall) { + if (this.props.state.picker === this.props.PICKERS.NONE) { + this.props.state.picker = this.props.PICKERS.EMOJI; + } else { + this.props.state.picker = this.props.PICKERS.NONE; + } + return; + } + // In large screen, we toggle popover. + if (isEventHandled(ev, "Composer.onClickAddEmoji")) { + if (this.popover.isOpen) { + if (this.props.state.picker === this.props.PICKERS.EMOJI) { + this.props.state.picker = this.props.PICKERS.NONE; + this.popover.close(); + return; + } + this.props.state.picker = this.props.PICKERS.EMOJI; + } else { + this.props.state.picker = this.props.PICKERS.EMOJI; + this.popover.open(el, this.contentProps); + } + } + } + + close() { + this.props.close?.(); + this.popover.close(); + this.props.state.picker = this.props.PICKERS.NONE; + this.props.state.searchTerm = ""; + } +} diff --git a/addons/mail/static/src/core/common/picker.scss b/addons/mail/static/src/core/common/picker.scss new file mode 100644 index 00000000000..2a9b70b3cb0 --- /dev/null +++ b/addons/mail/static/src/core/common/picker.scss @@ -0,0 +1,17 @@ +// o-mail-Picker handles the styling of picker in mobile view only +.o-mail-Picker { + height: 55vh ; + + .o-EmojiPicker { + width: 100% !important; + + .o-Emoji { + width: 40px; + font-size: 1.5rem !important; + } + } + + .o-EmojiPicker-navbar { + gap: 0.5rem !important; + } +} diff --git a/addons/mail/static/src/core/common/picker.xml b/addons/mail/static/src/core/common/picker.xml new file mode 100644 index 00000000000..46bb8e96165 --- /dev/null +++ b/addons/mail/static/src/core/common/picker.xml @@ -0,0 +1,10 @@ + + + + +
+ +
+
+ +
diff --git a/addons/mail/static/src/core/common/picker_content.js b/addons/mail/static/src/core/common/picker_content.js new file mode 100644 index 00000000000..71541c44459 --- /dev/null +++ b/addons/mail/static/src/core/common/picker_content.js @@ -0,0 +1,19 @@ +/* @odoo-module */ + +import { Component } from "@odoo/owl"; +import { markEventHandled } from "@web/core/utils/misc"; +import { EmojiPicker } from "@web/core/emoji_picker/emoji_picker"; + +/** + * PickerContent is the content displayed in the popover/Picker. + * It is used to display the emoji picker/gif picker (if it is enabled). + */ +export class PickerContent extends Component { + static components = { EmojiPicker }; + static props = ["PICKERS", "close", "pickers", "state", "storeScroll"]; + static template = "mail.PickerContent"; + + onClick(ev) { + markEventHandled(ev, "PickerContent.onClick"); + } +} diff --git a/addons/mail/static/src/core/common/picker_content.scss b/addons/mail/static/src/core/common/picker_content.scss new file mode 100644 index 00000000000..c926e22ef34 --- /dev/null +++ b/addons/mail/static/src/core/common/picker_content.scss @@ -0,0 +1,9 @@ +.popover .o-mail-PickerContent { + width: 300px; + height: 365px; + + .o-EmojiPicker { + width: 100% !important; + height: 100% !important; + } +} diff --git a/addons/mail/static/src/core/common/picker_content.xml b/addons/mail/static/src/core/common/picker_content.xml new file mode 100644 index 00000000000..cc162df5079 --- /dev/null +++ b/addons/mail/static/src/core/common/picker_content.xml @@ -0,0 +1,14 @@ + + + + +
+
+
+ +
+
+
+
+ +
diff --git a/addons/mail/static/src/core/web/command_palette.xml b/addons/mail/static/src/core/web/command_palette.xml index bbde9149532..8aa8ae03149 100644 --- a/addons/mail/static/src/core/web/command_palette.xml +++ b/addons/mail/static/src/core/web/command_palette.xml @@ -1,7 +1,7 @@ - +
diff --git a/addons/mail/static/src/discuss/gif_picker/common/composer_patch.js b/addons/mail/static/src/discuss/gif_picker/common/composer_patch.js index 1845c6d049b..7f1e3377c39 100644 --- a/addons/mail/static/src/discuss/gif_picker/common/composer_patch.js +++ b/addons/mail/static/src/discuss/gif_picker/common/composer_patch.js @@ -1,50 +1,43 @@ /* @odoo-module */ -import { GifPicker, useGifPicker } from "@mail/discuss/gif_picker/common/gif_picker"; import { Composer } from "@mail/core/common/composer"; -import { onExternalClick } from "@mail/utils/common/hooks"; -import { isEventHandled, markEventHandled } from "@web/core/utils/misc"; +import { markEventHandled } from "@web/core/utils/misc"; -import { useState } from "@odoo/owl"; +import { useRef, useState } from "@odoo/owl"; import { useService } from "@web/core/utils/hooks"; import { patch } from "@web/core/utils/patch"; -Object.assign(Composer.components, { GifPicker }); /** @type {Composer} */ const composerPatch = { setup() { - super.setup(); - Object.assign(this.KEYBOARD, { GIF: "Gif" }); - onExternalClick("gif-picker", () => (this.state.keyboard = this.KEYBOARD.NONE)); - this.ui = useState(useService("ui")); this.gifPickerService = useState(useService("discuss.gifPicker")); - this.gifPicker = useGifPicker("gif-button", { - onSelected: this.sendGifMessage.bind(this), - }); + this.gifButton = useRef("gif-button"); + super.setup(); + this.ui = useState(useService("ui")); }, - /** - * @param {Event} ev - * @returns {boolean} - */ - isEventHandledByPicker(ev) { + get pickerSettings() { + const setting = super.pickerSettings; + if (this.hasGifPicker) { + setting.pickers.gif = (gif) => this.sendGifMessage(gif); + if (this.hasGifPickerButton) { + setting.buttons.push(this.gifButton); + } + } + return setting; + }, + get hasGifPicker() { return ( - super.isEventHandledByPicker(ev) || - isEventHandled(ev, "Composer.onClickAddGif") || - isEventHandled(ev, "GifPicker.onClick") + (this.gifPickerService.hasGifPickerFeature || this.store.user?.isAdmin) && + !this.env.inChatter && + !this.props.composer.message ); }, + get hasGifPickerButton() { + return this.hasGifPicker && !this.ui.isSmall && !this.env.inChatWindow; + }, onClickAddGif(ev) { markEventHandled(ev, "Composer.onClickAddGif"); - if (!this.ui.isSmall) { - this.gifPicker.toggle(); - } else { - if (this.state.keyboard !== this.KEYBOARD.GIF) { - this.state.keyboard = this.KEYBOARD.GIF; - } else { - this.state.keyboard = this.KEYBOARD.NONE; - } - } }, async sendGifMessage(gif) { await this._sendMessage(gif.url, { diff --git a/addons/mail/static/src/discuss/gif_picker/common/composer_patch.xml b/addons/mail/static/src/discuss/gif_picker/common/composer_patch.xml index a0d38b44303..e92cec1e07c 100644 --- a/addons/mail/static/src/discuss/gif_picker/common/composer_patch.xml +++ b/addons/mail/static/src/discuss/gif_picker/common/composer_patch.xml @@ -1,11 +1,13 @@ - - diff --git a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.dark.scss b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.dark.scss new file mode 100644 index 00000000000..7f5213f5d60 --- /dev/null +++ b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.dark.scss @@ -0,0 +1,3 @@ +.o-discuss-GifPicker { + --o-gif-picker-active: #{$white}; +} diff --git a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.js b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.js index a84b77ccc09..8ecac012240 100644 --- a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.js +++ b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.js @@ -1,12 +1,9 @@ /* @odoo-module */ +import { Component, onWillStart, useState, useEffect } from "@odoo/owl"; import { removeFromArrayWithPredicate } from "@mail/utils/common/arrays"; import { useOnBottomScrolled, useSequential } from "@mail/utils/common/hooks"; -import { markEventHandled } from "@web/core/utils/misc"; -import { Component, onWillStart, useRef, useState } from "@odoo/owl"; - -import { usePopover } from "@web/core/popover/popover_hook"; import { useService, useAutofocus } from "@web/core/utils/hooks"; import { useDebounced } from "@web/core/utils/timing"; @@ -41,36 +38,17 @@ import { useDebounced } from "@web/core/utils/timing"; * @property {{ tinygif: TenorMediaFormat }} media_formats */ -/** - * @param {import("@web/core/utils/common/hooks").Ref} ref - * @param {{ onSelected: function, className: String }} options - */ -export function useGifPicker(refName, options) { - const ref = useRef(refName); - const popover = usePopover(GifPicker, { - position: "top", - popoverClass: "o-fast-popover", - }); - function toggle() { - if (popover.isOpen) { - popover.close(); - } else { - popover.open(ref.el, options); - } - } - return { toggle }; -} - /** * @typedef {Object} Props - * @property {function} onSelected Callback to use when the gif is selected + * @property {function} onSelect Callback to use when the gif is selected * @property {string} [className] * @property {function} [close] + * @property {Object} [state] * @extends {Component} */ export class GifPicker extends Component { static template = "discuss.GifPicker"; - static props = ["onSelected", "className?", "close?"]; + static props = ["PICKERS?", "className?", "close?", "onSelect", "state?"]; setup() { this.rpc = useService("rpc"); @@ -130,6 +108,34 @@ export class GifPicker extends Component { this.loadFavorites(); }); } + useEffect( + () => { + if (this.props.state?.picker !== this.props.PICKERS?.GIF) { + return; + } + this.clear(); + this.state.loadingGif = true; + this.search(); + if (this.searchTerm) { + this.closeCategories(); + } else { + this.openCategories(); + } + }, + () => [this.searchTerm, this.props.state?.picker] + ); + } + + get searchTerm() { + return this.props.state ? this.props.state.searchTerm : this.state.searchTerm; + } + + set searchTerm(value) { + if (this.props.state) { + this.props.state.searchTerm = value; + } else { + this.state.searchTerm = value; + } } async loadCategories() { @@ -152,7 +158,7 @@ export class GifPicker extends Component { openCategories() { this.state.showCategories = true; - this.state.searchTerm = ""; + this.searchTerm = ""; this.clear(); } @@ -161,14 +167,14 @@ export class GifPicker extends Component { } async search() { - if (!this.state.searchTerm) { + if (!this.searchTerm) { return; } try { const params = { country: this.userService.lang.slice(3, 5), locale: this.userService.lang, - search_term: this.state.searchTerm, + search_term: this.searchTerm, }; if (this.next) { params.position = this.next; @@ -207,27 +213,12 @@ export class GifPicker extends Component { } } - onInput() { - this.clear(); - this.state.loadingGif = true; - this.search(); - if (this.state.searchTerm) { - this.closeCategories(); - } else { - this.openCategories(); - } - } - - onClick(ev) { - markEventHandled(ev, "GifPicker.onClick"); - } - /** * @param {TenorGif} gif */ onClickGif(gif) { - this.props.onSelected(gif); - this.props.close(); + this.props.onSelect(gif, true); + this.props.close?.(); } clear() { @@ -242,8 +233,7 @@ export class GifPicker extends Component { */ async onClickCategory(category) { this.clear(); - this.state.searchTerm = category.searchterm; - await this.search(); + this.props.state.searchTerm = category.searchterm; this.closeCategories(); } diff --git a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.scss b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.scss index f932e66f009..9bf17903047 100644 --- a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.scss +++ b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.scss @@ -4,33 +4,12 @@ } .o-discuss-GifPicker { - display: grid; - grid-template-areas: - "search" - "content"; - grid-template-rows: 40px 1fr; -} - -.o-discuss-GifPicker-search { - grid-area: search; - background-color: $input-bg; - - input { - &:focus, &:hover { - & + .oi-search { - @extend .bg-view !optional; - } - } - &:not(:focus) + .oi-search { - opacity: 60%; - } + --o-gif-picker-active: #{$gray-200}; + .o-active { + background-color: var(--o-gif-picker-active) !important; } } -.o-discuss-GifPicker-content { - grid-area: content; -} - .o-discuss-GifPickerCategory-img { object-fit: cover; } diff --git a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.xml b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.xml index 1984a72fbbd..7466e861dbc 100644 --- a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.xml +++ b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.xml @@ -1,20 +1,25 @@ -
-