[IMP] mail: Introduce gif picker in Discuss

This commit adds a GIF picker in discuss
app. GIF feature makes use of Tenor GIF API [1].
To enable GIF picker in discuss, you must provide
a Tenor GIF API key in the General Settings.
Then the GIF picker is visible in all channels
(chat window and discuss app), next to the emoji picker
button.

[1] https://tenor.com/gifapi/documentation

task-2365705

closes odoo/odoo#116060

Related: odoo/enterprise#41959
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Co-authored-by: Brieuc-brd <brd@odoo.com>
This commit is contained in:
Didier (did)
2023-06-30 23:38:01 +02:00
committed by Alexandre Kühn
co-authored by Brieuc-brd
parent ee963d1002
commit 771477b9a7
37 changed files with 1137 additions and 83 deletions
+1
View File
@@ -81,6 +81,7 @@ For more specific needs, you may also assign custom-defined actions
'views/discuss_channel_member_views.xml',
'views/discuss_channel_rtc_session_views.xml',
'views/mail_link_preview_views.xml',
'views/discuss/discuss_gif_favorite_views.xml',
'views/discuss_channel_views.xml',
'views/mail_shortcode_views.xml',
'views/mail_activity_views.xml',
@@ -2,5 +2,6 @@
from . import binary
from . import channel
from . import gif
from . import public_page
from . import rtc
+85
View File
@@ -0,0 +1,85 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import requests
import werkzeug.urls
from odoo.http import request, route, Controller
class DiscussGifController(Controller):
def _request_gifs(self, endpoint):
response = requests.get(
f"https://tenor.googleapis.com/v2/{endpoint}", timeout=3
)
response.raise_for_status()
return response
@route("/discuss/gif/search", type="json", auth="user")
def search(self, search_term, locale="en", country="US", position=None):
ir_config = request.env["ir.config_parameter"].sudo()
query_string = werkzeug.urls.url_encode(
{
"q": search_term,
"key": ir_config.get_param("discuss.tenor_api_key"),
"client_key": request.env.cr.dbname,
"limit": ir_config.get_param("discuss.tenor_gif_limit"),
"contentfilter": ir_config.get_param("discuss.tenor_content_filter"),
"locale": locale,
"country": country,
"media_filter": "tinygif",
"pos": position,
}
)
response = self._request_gifs(f"search?{query_string}")
if response:
return response.json()
@route("/discuss/gif/categories", type="json", auth="user")
def categories(self, locale="en", country="US"):
ir_config = request.env["ir.config_parameter"].sudo()
query_string = werkzeug.urls.url_encode(
{
"key": ir_config.get_param("discuss.tenor_api_key"),
"client_key": request.env.cr.dbname,
"limit": ir_config.get_param("discuss.tenor_gif_limit"),
"contentfilter": ir_config.get_param("discuss.tenor_content_filter"),
"locale": locale,
"country": country,
}
)
response = self._request_gifs(f"categories?{query_string}")
if response:
return response.json()
@route("/discuss/gif/add_favorite", type="json", auth="user")
def add_favorite(self, tenor_gif_id):
request.env["discuss.gif.favorite"].create({"tenor_gif_id": tenor_gif_id})
def _gif_posts(self, ids):
ir_config = request.env["ir.config_parameter"].sudo()
query_string = werkzeug.urls.url_encode(
{
"ids": ",".join(ids),
"key": ir_config.get_param("discuss.tenor_api_key"),
"client_key": request.env.cr.dbname,
"media_filter": "tinygif",
}
)
response = self._request_gifs(f"posts?{query_string}")
if response:
return response.json()["results"]
@route("/discuss/gif/favorites", type="json", auth="user")
def get_favorites(self, offset=0):
tenor_gif_ids = request.env["discuss.gif.favorite"].search(
[("create_uid", "=", request.env.user.id)], limit=20, offset=offset
)
return (self._gif_posts(tenor_gif_ids.mapped("tenor_gif_id")) or [],)
@route("/discuss/gif/remove_favorite", type="json", auth="user")
def remove_favorite(self, tenor_gif_id):
request.env["discuss.gif.favorite"].search(
[
("create_uid", "=", request.env.user.id),
("tenor_gif_id", "=", tenor_gif_id),
]
).unlink()
+1
View File
@@ -7,6 +7,7 @@ from . import mail_message
from . import discuss_channel_member
from . import discuss_channel_rtc_session
from . import discuss_channel
from . import discuss_gif_favorite
from . import mail_guest
# odoo models
@@ -0,0 +1,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class GifFavorite(models.Model):
_name = "discuss.gif.favorite"
_description = "Save favorite GIF from Tenor API"
tenor_gif_id = fields.Char("GIF id from Tenor", required=True)
_sql_constraints = [
(
"user_gif_favorite",
"unique(create_uid,tenor_gif_id)",
"User should not have duplicated favorite GIF",
),
]
+1
View File
@@ -112,6 +112,7 @@ class MailGuest(models.Model):
'current_partner': False,
'current_user_id': False,
'current_user_settings': False,
'hasGifPickerFeature': bool(self.env["ir.config_parameter"].sudo().get_param("discuss.tenor_api_key")),
'hasLinkPreviewFeature': self.env['mail.link.preview']._is_link_preview_enabled(),
'initBusId': self.env['bus.bus'].sudo()._bus_last_id(),
'menu_id': False,
+1
View File
@@ -52,5 +52,6 @@ class ResUsers(models.Model):
self.ensure_one()
return {
'channels': self.partner_id._get_channels_as_member().channel_info(),
'hasGifPickerFeature': bool(self.env["ir.config_parameter"].sudo().get_param("discuss.tenor_api_key")),
**super()._init_messaging(),
}
+21
View File
@@ -39,6 +39,27 @@ class ResConfigSettings(models.TransientModel):
primary_color = fields.Char(related='company_id.primary_color', string="Header Color", readonly=False)
secondary_color = fields.Char(related='company_id.secondary_color', string="Button Color", readonly=False)
tenor_api_key = fields.Char(
'Tenor API key',
config_parameter='discuss.tenor_api_key',
help="Add a Tenor GIF API key to enable GIFs support. https://developers.google.com/tenor/guides/quickstart#setup",
)
tenor_content_filter = fields.Selection(
[('high', 'High'),
('medium', 'Medium'),
('low', 'Low'),
('off', 'Off')],
string='Tenor content filter',
help="https://developers.google.com/tenor/guides/content-filtering",
config_parameter='discuss.tenor_content_filter',
default='low',
)
tenor_gif_limit = fields.Integer(
default=8,
config_parameter='discuss.tenor_gif_limit',
help="Fetch up to the specified number of GIF.",
)
def _compute_fail_counter(self):
previous_date = fields.Datetime.now() - datetime.timedelta(days=30)
+1
View File
@@ -64,3 +64,4 @@ access_res_users_settings_volumes_user,res.users.settings.volumes,model_res_user
access_mail_template_reset,access.mail.template.reset,model_mail_template_reset,mail.group_mail_template_editor,1,1,1,1
ir_actions_report_access_user,ir.actions.report.access.user,base.model_ir_actions_report,base.group_user,1,0,0,0
access_mail_link_preview_admin,mail.link.preview.admin,model_mail_link_preview,base.group_erp_manager,1,1,1,1
access_discuss_gif_favorite,discuss.gif.favorite,model_discuss_gif_favorite,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
64 access_mail_template_reset access.mail.template.reset model_mail_template_reset mail.group_mail_template_editor 1 1 1 1
65 ir_actions_report_access_user ir.actions.report.access.user base.model_ir_actions_report base.group_user 1 0 0 0
66 access_mail_link_preview_admin mail.link.preview.admin model_mail_link_preview base.group_erp_manager 1 1 1 1
67 access_discuss_gif_favorite discuss.gif.favorite model_discuss_gif_favorite base.group_user 1 1 1 1
+14
View File
@@ -27,6 +27,20 @@
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<record id="discuss_gif_favorite_user_rule" model="ir.rule">
<field name="name">Discuss.gif.favorite: User access</field>
<field name="model_id" ref="model_discuss_gif_favorite"/>
<field name="groups" eval="[Command.link(ref('base.group_user'))]"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
</record>
<record id="discuss_gif_favorite_admin_rule" model="ir.rule">
<field name="name">Discuss.gif.favorite: admin full access</field>
<field name="model_id" ref="model_discuss_gif_favorite"/>
<field name="groups" eval="[Command.link(ref('base.group_erp_manager'))]"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<record id="ir_rule_discuss_channel_member_group_user" model="ir.rule">
<field name="name">discuss.channel.member: write its own entries</field>
<field name="model_id" ref="mail.model_discuss_channel_member"/>
+72 -19
View File
@@ -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 { useEmojiPicker } from "@mail/core/common/emoji_picker";
import { EmojiPicker, useEmojiPicker } from "@mail/core/common/emoji_picker";
import { MessageConfirmDialog } from "@mail/core/common/message_confirm_dialog";
import { useMessaging, useStore } from "@mail/core/common/messaging_hook";
import { NavigableList } from "@mail/core/common/navigable_list";
@@ -22,10 +22,12 @@ 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 { sprintf } from "@web/core/utils/strings";
@@ -54,6 +56,7 @@ const EDIT_CLICK_TYPE = {
export class Composer extends Component {
static components = {
AttachmentList,
EmojiPicker,
FileUploader,
NavigableList,
};
@@ -84,6 +87,10 @@ export class Composer extends Component {
send_keybind: this.sendKeybind,
})
);
this.KEYBOARD = {
NONE: "None",
EMOJI: "Emoji",
};
this.messaging = useMessaging();
this.store = useStore();
if (this.allowUpload) {
@@ -103,6 +110,7 @@ export class Composer extends Component {
this.state = useState({
autofocus: 0,
active: true,
keyboard: this.KEYBOARD.NONE,
});
this.selection = useSelection({
refName: "textarea",
@@ -114,7 +122,7 @@ export class Composer extends Component {
!this.isEventTrusted(ev) ||
isEventHandled(ev, "sidebar.openThread") ||
isEventHandled(ev, "emoji.selectEmoji") ||
isEventHandled(ev, "composer.clickOnAddEmoji") ||
isEventHandled(ev, "Composer.onClickAddEmoji") ||
isEventHandled(ev, "composer.clickOnAddAttachment") ||
isEventHandled(ev, "composer.selectSuggestion")
);
@@ -141,10 +149,26 @@ export class Composer extends Component {
useChildSubEnv({
inComposer: true,
});
useEmojiPicker(useRef("emoji-picker"), {
onSelect: (str) => this.addEmoji(str),
onClose: () => this.state.autofocus++,
});
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
);
useEffect(
(focus) => {
if (focus && this.ref.el) {
@@ -233,6 +257,17 @@ 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.composer.thread ?? null;
}
@@ -434,7 +469,9 @@ export class Composer extends Component {
}
onClickAddEmoji(ev) {
markEventHandled(ev, "composer.clickOnAddEmoji");
markEventHandled(ev, "Composer.onClickAddEmoji");
this.state.keyboard =
this.state.keyboard === this.KEYBOARD.EMOJI ? this.KEYBOARD.NONE : this.KEYBOARD.EMOJI;
}
isEventTrusted(ev) {
@@ -471,8 +508,6 @@ export class Composer extends Component {
async sendMessage() {
await this.processMessage(async (value) => {
const thread =
this.props.messageToReplyTo?.message?.originThread ?? this.props.composer.thread;
const postData = {
attachments: this.props.composer.attachments,
isNote:
@@ -482,19 +517,37 @@ export class Composer extends Component {
cannedResponseIds: [...this.props.composer.cannedResponseIds],
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.suggestion?.clearCannedReponses();
this.props.messageToReplyTo?.cancel();
await this._sendMessage(value, postData);
});
}
/**
* @typedef postData
* @property {import('@mail/attachments/attachment_model').Attachment[]} attachments
* @property {boolean} isNote
* @property {number} parentId
* @property {import("@mail/composer/composer_model").RawMentions} rawMentions
*/
/**
* @param {string} value message body
* @param {postData} postData Message meta data info
*/
async _sendMessage(value, postData) {
const thread =
this.props.messageToReplyTo?.message?.originThread ?? this.props.composer.thread;
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.suggestion?.clearCannedReponses();
this.props.messageToReplyTo?.cancel();
}
async editMessage() {
if (this.ref.el.value || this.props.composer.message.attachments.length > 0) {
await this.processMessage(async (value) =>
@@ -40,6 +40,10 @@
}
}
.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;
@@ -73,7 +73,7 @@
}"
>
<div class="d-flex flex-grow-1 align-items-center" >
<button class="btn border-0 fa fa-smile-o rounded-pill" aria-label="Emojis" t-on-click="onClickAddEmoji" t-ref="emoji-picker"/>
<button class="btn border-0 fa fa-smile-o rounded-pill" aria-label="Emojis" t-on-click="onClickAddEmoji" t-ref="emoji-button"/>
<FileUploader t-if="allowUpload" multiUpload="true" onUploaded="(data) => { attachmentUploader.uploadData(data) }">
<t t-set-slot="toggler">
<button t-att-disabled="!state.active" class="btn fa fa-paperclip border-0 rounded-pill" title="Attach files" aria-label="Attach files" type="button" t-on-click="onClickAddAttachment"/>
@@ -95,6 +95,7 @@
attachments="props.composer.attachments"
unlinkAttachment="attachmentUploader.unlink"
imagesHeight="50"/>
<EmojiPicker t-if="ui.isSmall and state.keyboard === KEYBOARD.EMOJI" className="'o-mail-Composer-bottomPicker w-100'" onSelect="(str) => this.addEmoji(str)"/>
</div>
</div>
<span t-if="props.composer.message" class="text-muted" t-out="CANCEL_OR_SAVE_EDIT_TEXT" t-on-click="onClickCancelOrSaveEditText"/>
@@ -1,10 +1,15 @@
/* @odoo-module */
/**
* @typedef {{partnerIds: Set<number>, threadIds: Set<number>}} RawMentions
*/
export class Composer {
/** @type {import("@mail/core/common/attachment_model").Attachment[]} */
attachments = [];
/** @type {import("@mail/core/common/message_model").Message} */
message;
/** @type {RawMentions} */
rawMentions = {
partnerIds: new Set(),
threadIds: new Set(),
@@ -1,3 +1,7 @@
.o-bg-black {
background-color: rgba(0, 0, 0, var(--bg-opacity, 1));
}
.o-bg-inherit {
background-color: inherit;
}
@@ -18,6 +22,10 @@
min-width: 0;
}
.o-text-white {
color: #FFF;
}
.o-yellow {
color: $yellow;
}
@@ -20,6 +20,7 @@ import { _t } from "@web/core/l10n/translation";
import { usePopover } from "@web/core/popover/popover_hook";
import { memoize } from "@web/core/utils/functions";
import { fuzzyLookup } from "@web/core/utils/search";
import { useService } from "@web/core/utils/hooks";
/**
*
@@ -124,8 +125,7 @@ export async function loadEmoji() {
export const EMOJI_PER_ROW = 9;
export class EmojiPicker extends Component {
static props = ["onSelect", "close", "onClose?", "storeScroll?"];
static defaultProps = { onClose: () => {} };
static props = ["onSelect", "className?", "close?", "onClose?", "storeScroll?"];
static template = "mail.EmojiPicker";
recent = [];
@@ -136,6 +136,7 @@ export class EmojiPicker extends Component {
setup() {
this.inputRef = useRef("input");
this.gridRef = useRef("emoji-grid");
this.ui = useState(useService("ui"));
this.state = useState({
activeEmojiIndex: 0,
categoryId: null,
@@ -217,6 +218,7 @@ export class EmojiPicker extends Component {
}
onClick(ev) {
markEventHandled(ev, "EmojiPicker.onClick");
markEventHandled(ev, "emoji.selectEmoji");
}
@@ -260,8 +262,8 @@ export class EmojiPicker extends Component {
.click();
break;
case "Escape":
this.props.close();
this.props.onClose();
this.props.close?.();
this.props.onClose?.();
ev.stopPropagation();
}
}
@@ -291,8 +293,8 @@ export class EmojiPicker extends Component {
browser.localStorage.setItem("mail.emoji.frequent", JSON.stringify(this.recent));
this.gridRef.el.scrollTop = 0;
if (!ev.shiftKey) {
this.props.close();
this.props.onClose();
this.props.close?.();
this.props.onClose?.();
}
}
@@ -1,13 +1,19 @@
.o-mail-EmojiPicker {
.popover .o-mail-EmojiPicker {
width: 285px;
height: 350px;
}
.o-mail-EmojiPicker {
display: grid;
grid-template-areas:
"header"
"search"
"content";
grid-template-rows: 30px 35px 1fr;
"search"
"content"
"navbar";
&:not(.o-small) {
grid-template-rows: 40px 1fr 32px;
}
.o-mail-Emoji {
width: 30px;
@@ -17,12 +23,17 @@
}
}
&.o-small .o-mail-Emoji {
width: 40px;
font-size: 1.5rem;
}
.o-mail-EmojiPicker-content {
grid-area: content;
}
.o-mail-EmojiPicker-header {
grid-area: header;
.o-mail-EmojiPicker-navbar {
grid-area: navbar;
.o-mail-Emoji {
filter: grayscale(1);
}
@@ -32,27 +43,19 @@
filter: grayscale(1);
}
.o-mail-EmojiPicker-searchContainer {
border: $input-border-width solid $border-color !important;
&:focus-within {
border-color: $input-focus-border-color !important;
i {
opacity: 100% !important;
}
}
}
.o-mail-EmojiPicker-search {
grid-area: search;
border-radius: 0%;
background-color: $input-focus-bg;
background-color: $input-bg;
input {
appearance: none;
color: inherit;
background: inherit;
&:focus, &:hover {
& + .oi-search {
@extend .bg-white !optional;
}
}
&:not(:focus) + i.oi-search {
opacity: 60%;
}
}
}
@@ -60,16 +63,6 @@
font-size: 5rem !important;
filter: grayscale(0.25);
}
input:not(:focus) {
&::placeholder {
opacity: 75%;
}
& + i.oi-search {
opacity: 60%;
}
}
}
.o-mail-EmojiPicker-category:before {
@@ -2,27 +2,15 @@
<templates xml:space="preserve">
<t t-name="mail.EmojiPicker" owl="1">
<div class="o-mail-EmojiPicker bg-view" t-att-class="emojis.length === 0 ? 'd-flex flex-column align-items-center justify-content-center' : ''" t-on-click="onClick" t-on-keydown="onKeydown">
<div class="o-mail-EmojiPicker bg-view" t-att-class="{ 'd-flex flex-column align-items-center justify-content-center': emojis.length === 0, 'o-small': ui.isSmall }" t-attf-class="{{ props.className }}" t-on-click="onClick" t-on-keydown="onKeydown">
<t t-if="emojis.length === 0">
<span class="o-mail-EmojiPicker-empty">😵‍💫</span>
<span class="fs-5 text-muted">Failed to load emojis...</span>
</t>
<t t-else="">
<div class="o-mail-EmojiPicker-header d-flex align-items-center overflow-hidden bg-100">
<t t-if="recentEmojis.length > 0" t-call="mail.EmojiPicker.tab">
<t t-set="category" t-value="recentCategory"/>
</t>
<t t-foreach="categories" t-as="category" t-key="category.sortId">
<t t-call="mail.EmojiPicker.tab">
<t t-set="category" t-value="category"/>
</t>
</t>
</div>
<div class="o-mail-EmojiPicker-searchContainer d-flex mx-2 my-1 rounded">
<div class="o-mail-EmojiPicker-search border-0 align-items-center p-1 d-flex rounded w-100">
<i class="oi oi-search p-2 fs-5 text-muted opacity-75" title="Search..." role="img" aria-label="Search..."></i>
<input type="text" class="border-0 flex-grow-1" t-ref="input" placeholder="Search for an emoji" t-model="state.searchStr" t-on-input="() => this.state.activeEmojiIndex = 0"/>
</div>
<div class="o-mail-EmojiPicker-search d-flex align-items-center mt-2 mx-2 rounded">
<input type="text" class="form-control border-0 flex-grow-1 rounded-end-0" t-ref="input" placeholder="Search for an emoji" t-model="state.searchStr" t-on-input="() => this.state.activeEmojiIndex = 0"/>
<i class="oi oi-search p-2 fs-5 rounded-end" title="Search..." role="img" aria-label="Search..."/>
</div>
<t t-set="itemIndex" t-value="0"/>
<div class="o-mail-EmojiPicker-content overflow-auto d-flex flex-wrap align-items-center user-select-none mt-1" t-att-class="getEmojis().length === 0 ? 'flex-column justify-content-center' : 'align-content-start'" t-ref="emoji-grid" t-on-scroll="highlightActiveCategory">
@@ -57,12 +45,22 @@
<t t-set="itemIndex" t-value="itemIndex + 1"/>
</t>
</div>
<div class="o-mail-EmojiPicker-navbar d-flex align-items-center bg-100 overflow-auto">
<t t-if="recentEmojis.length > 0" t-call="mail.EmojiPicker.tab">
<t t-set="category" t-value="recentCategory"/>
</t>
<t t-foreach="categories" t-as="category" t-key="category.sortId">
<t t-call="mail.EmojiPicker.tab">
<t t-set="category" t-value="category"/>
</t>
</t>
</div>
</t>
</div>
</t>
<t t-name="mail.EmojiPicker.tab" owl="1">
<span class="o-mail-Emoji text-center p-1 cursor-pointer" t-att-class="{'bg-200': category.sortId === state.categoryId}" t-att-title="category.name" t-att-data-id="category.sortId" t-on-click="selectCategory">
<span class="o-mail-Emoji text-center p-1 cursor-pointer" t-att-class="{'bg-300': category.sortId === state.categoryId}" t-att-title="category.name" t-att-data-id="category.sortId" t-on-click="selectCategory">
<t t-esc="category.title"/>
</span>
</t>
@@ -73,7 +71,7 @@
</t>
<t t-name="mail.EmojiPicker.emoji" owl="1">
<span class="o-mail-Emoji cursor-pointer d-flex justify-content-center fs-2" t-att-class="{ 'bg-200': state.activeEmojiIndex === itemIndex }" t-att-title="emoji.name" t-att-data-codepoints="emoji.codepoints" t-att-data-index="itemIndex" t-att-data-category="recentCategory.sortId" t-on-click="selectEmoji">
<span class="o-mail-Emoji cursor-pointer d-flex justify-content-center" t-att-class="{ 'bg-200': state.activeEmojiIndex === itemIndex, 'fs-2': !ui.isSmall, 'fs-1': ui.isSmall }" t-att-title="emoji.name" t-att-data-codepoints="emoji.codepoints" t-att-data-index="itemIndex" t-att-data-category="recentCategory.sortId" t-on-click="selectEmoji">
<span t-esc="emoji.codepoints"/>
</span>
</t>
@@ -10,7 +10,12 @@ import {
replaceArrayWithCompare,
} from "@mail/utils/common/arrays";
import { prettifyMessageContent } from "@mail/utils/common/format";
import { assignDefined, createLocalId, onChange, nullifyClearCommands } from "@mail/utils/common/misc";
import {
assignDefined,
createLocalId,
onChange,
nullifyClearCommands,
} from "@mail/utils/common/misc";
import { markup } from "@odoo/owl";
@@ -885,7 +890,7 @@ export class ThreadService {
async post(
thread,
body,
{ attachments = [], isNote = false, parentId, rawMentions, cannedResponseIds }
{ attachments = [], isNote = false, parentId, rawMentions = {}, cannedResponseIds } = {}
) {
let tmpMsg;
const params = await this.getMessagePostParams({
@@ -0,0 +1,57 @@
/* @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 "@mail/utils/common/misc";
import { 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() {
this._super();
Object.assign(this.KEYBOARD, { GIF: "Gif" });
onExternalClick("gif-picker", () => (this.state.keyboard = this.KEYBOARD.NONE));
this.ui = useState(useService("ui"));
/** @type {import('@mail/discuss/gif_picker/common/gif_picker_service').GifPickerService} */
this.gifPickerService = useService("discuss.gifPicker");
if (this.gifPickerService.hasGifPickerFeature && !this.ui.isSmall) {
this.gifPicker = useGifPicker("gif-button", {
onSelected: this.sendGifMessage.bind(this),
});
}
},
/**
* @param {Event} ev
* @returns {boolean}
*/
isEventHandledByPicker(ev) {
return (
this._super(ev) ||
isEventHandled(ev, "Composer.onClickAddGif") ||
isEventHandled(ev, "GifPicker.onClick")
);
},
onClickAddGif(ev) {
markEventHandled(ev, "Composer.onClickAddGif");
this.gifPicker?.toggle();
if (!this.gifPicker) {
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, {
parentId: this.props.messageToReplyTo?.message?.id,
});
},
};
patch(Composer.prototype, "GifPicker", composerPatch);
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.Composer" t-inherit-mode="extension">
<xpath expr="//*[@t-ref='emoji-button']" position="after">
<button t-if="gifPickerService.hasGifPickerFeature and !env.inChatter and !props.composer.message" class="btn border-0 oi oi-gif-picker rounded-pill" aria-label="GIFs" t-on-click="onClickAddGif" t-ref="gif-button"/>
</xpath>
<xpath expr="//div[hasclass('o-mail-Composer-footer')]" position="inside">
<GifPicker t-if="ui.isSmall and state.keyboard === KEYBOARD.GIF" className="'o-mail-Composer-bottomPicker w-100'" onSelected.bind="sendGifMessage" close="() => this.state.keyboard = KEYBOARD.NONE"/>
</xpath>
</t>
</templates>
@@ -0,0 +1,292 @@
/* @odoo-module */
import { useStore } from "@mail/core/common/messaging_hook";
import { removeFromArrayWithPredicate } from "@mail/utils/common/arrays";
import { useOnBottomScrolled } from "@mail/utils/common/hooks";
import { markEventHandled } from "@mail/utils/common/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";
/**
* @typedef {Object} TenorCategory
* @property {string} searchterm
* @property {string} path
* @property {string} image
* @property {string} name
*/
/**
* @typedef {Object} TenorMediaFormat
* @property {string} url
* @property {number} duration
* @property {string} preview
* @property {number[]} dims
* @property {number} size
*/
/**
* @typedef {Object} TenorGif
* @property {string} id
* @property {string} title
* @property {number} created
* @property {string} content_description
* @property {string} itemurl
* @property {string} url
* @property {string[]} tags
* @property {string[]} flags
* @property {boolean} hasaudio
* @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 {string} [className]
* @property {function} [close]
* @extends {Component<Props, Env>}
*/
export class GifPicker extends Component {
static template = "discuss.GifPicker";
static props = ["onSelected", "className?", "close?"];
setup() {
this.rpc = useService("rpc");
this.orm = useService("orm");
this.store = useStore();
this.userService = useService("user");
useAutofocus();
useOnBottomScrolled(
"scroller",
() => {
if (!this.state.showCategories) {
this.state.loadingGif = true;
if (!this.showFavorite) {
this.searchDebounced();
} else {
this.loadFavoritesDebounced(this.offset);
}
}
},
300
);
this.next = "";
this.showFavorite = false;
this.offset = 0;
this.state = useState({
favorites: {
/** @type {TenorGif[]} */
gifs: [],
offset: 0,
},
searchTerm: "",
showCategories: true,
/** @type {TenorCategory[]} */
categories: [],
loadingGif: false,
loadingError: false,
evenGif: {
/** @type {TenorGif[]} */
gifs: [],
/** Size, in pixel, of the column. */
columnSize: 0,
},
oddGif: {
/** @type {TenorGif[]} */
gifs: [],
/** Size, in pixel, of the column. */
columnSize: 0,
},
});
this.searchDebounced = useDebounced(this.search, 200);
this.loadFavoritesDebounced = useDebounced(this.loadFavorites, 200);
onWillStart(() => {
this.loadCategories();
});
if (!this.store.guest) {
onWillStart(() => {
this.loadFavorites();
});
}
}
async loadCategories() {
try {
const { tags } = await this.rpc(
"/discuss/gif/categories",
{
country: this.userService.lang.slice(3, 5),
locale: this.userService.lang,
},
{ silent: true }
);
if (tags) {
this.state.categories = tags;
}
} catch {
this.state.loadingError = true;
}
}
openCategories() {
this.state.showCategories = true;
this.state.searchTerm = "";
this.clear();
}
closeCategories() {
this.state.showCategories = false;
}
async search() {
if (!this.state.searchTerm) {
return;
}
this.state.loadingGif = true;
try {
const params = {
country: this.userService.lang.slice(3, 5),
locale: this.userService.lang,
search_term: this.state.searchTerm,
};
if (this.next) {
params.position = this.next;
}
const { results, next } = await this.rpc("/discuss/gif/search", params, {
silent: true,
});
if (results) {
this.next = next;
for (const gif of results) {
this.pushGif(gif);
}
}
} catch {
this.state.loadingError = true;
}
this.state.loadingGif = false;
}
/**
* @param {TenorGif} gif
*/
pushGif(gif) {
if (this.state.evenGif.columnSize <= this.state.oddGif.columnSize) {
this.state.evenGif.gifs.push(gif);
this.state.evenGif.columnSize += gif.media_formats.tinygif.dims[1];
} else {
this.state.oddGif.gifs.push(gif);
this.state.oddGif.columnSize += gif.media_formats.tinygif.dims[1];
}
}
onInput() {
this.clear();
this.state.loadingGif = true;
this.searchDebounced();
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();
}
clear() {
this.state.evenGif.gifs = [];
this.state.evenGif.columnSize = 0;
this.state.oddGif.gifs = [];
this.state.oddGif.columnSize = 0;
}
/**
* @param {TenorCategory} category
*/
async onClickCategory(category) {
this.clear();
this.state.searchTerm = category.searchterm;
await this.search();
this.closeCategories();
}
/**
* @param {TenorGif} gif
*/
async onClickFavorite(gif) {
if (!this.isFavorite(gif)) {
this.state.favorites.gifs.push(gif);
await this.orm.silent.create("discuss.gif.favorite", [{ tenor_gif_id: gif.id }]);
} else {
removeFromArrayWithPredicate(this.state.favorites.gifs, ({ id }) => id === gif.id);
await this.rpc(
"/discuss/gif/remove_favorite",
{ tenor_gif_id: gif.id },
{ silent: true }
);
}
}
async loadFavorites() {
this.state.loadingGif = true;
const [results] = await this.rpc(
"/discuss/gif/favorites",
{ offset: this.offset },
{ silent: true }
);
this.offset += 20;
this.state.favorites.gifs.push(...results);
for (const gif of results) {
this.pushGif(gif);
}
this.state.loadingGif = false;
}
/**
* @param {TenorGif} gif
*/
isFavorite(gif) {
return this.state.favorites.gifs.map((favorite) => favorite.id).includes(gif.id);
}
onClickFavoritesCategory() {
this.showFavorite = true;
for (const gif of this.state.favorites.gifs) {
this.pushGif(gif);
}
this.closeCategories();
}
}
@@ -0,0 +1,55 @@
.popover .o-discuss-GifPicker {
width: 285px;
height: 350px;
}
.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-discuss-GifPicker-content {
grid-area: content;
}
.o-discuss-GifPickerCategory-img {
object-fit: cover;
}
.o-discuss-GifPicker-noFavoritesIcon {
color: $yellow;
filter: grayscale(0.25);
}
.o-discuss-Gif {
.o-starred {
color: $o-main-favorite-color !important;
}
&:hover i {
opacity: 1 !important;
}
}
.o-discuss-GifPicker-error {
font-size: 5rem;
}
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="discuss.GifPicker" owl="1">
<div class="o-discuss-GifPicker bg-view d-flex flex-column" t-attf-class="{{ props.className }}" t-on-click="onClick">
<div class="o-discuss-GifPicker-search d-flex align-items-center m-2 rounded">
<i t-if="!state.showCategories" aria-label="back" t-on-click="openCategories" class="oi oi-arrow-left cursor-pointer p-2 fs-5"/>
<input class="form-control border-0 flex-grow-1 rounded-end-0" placeholder="Search for a gif" t-on-input="onInput" t-model="state.searchTerm" t-ref="autofocus" />
<i class="oi oi-search p-2 fs-5 rounded-end" title="Search..." role="img" aria-label="Search..."/>
</div>
<div class="o-discuss-GifPicker-content overflow-y-auto overflow-x-hidden px-2 pb-2 h-100" t-ref="scroller">
<div t-if="state.loadingError" class="text-center">
<span class="o-discuss-GifPicker-error d-block">😵‍💫</span>
<span class="fs-5 text-muted d-block">Failed to load gifs...</span>
</div>
<div t-if="state.showCategories and !state.loadingError" class="row row-cols-2 gy-2 gx-2" aria-label="list">
<div t-if="!store.guest" class="col">
<div class="position-relative ratio ratio-16x9 cursor-pointer" t-on-click="onClickFavoritesCategory" aria-label="list-item">
<img
t-if="state.favorites.gifs.length > 0"
class="o-discuss-GifPicker-category"
t-att-src="state.favorites.gifs[0].media_formats.tinygif.url"
loading="lazy"
alt="GIF Favorites"
/>
<div class="position-absolute w-100 h-100 top-0 start-0 text-center o-text-white align-middle fs-2 o-bg-black bg-opacity-50">
Favorites
</div>
</div>
</div>
<t t-if="state.categories.length === 0">
<t t-foreach="[...Array(10).keys()]" t-as="category" t-key="category_index">
<div class="col">
<div class="position-relative ratio ratio-16x9 cursor-pointer" aria-label="list-item">
<div class="position-absolute w-100 h-100 top-0 start-0 text-center o-text-white align-middle fs-2 o-bg-black bg-opacity-50"/>
</div>
</div>
</t>
</t>
<t t-else="">
<t t-foreach="state.categories" t-as="category" t-key="category.name">
<div class="col">
<div class="position-relative ratio ratio-16x9 cursor-pointer" t-on-click="() => this.onClickCategory(category)" aria-label="list-item">
<img class="o-discuss-GifPickerCategory-img img-fluid" t-att-src="category.image" loading="lazy" alt="GIF Category"/>
<div class="position-absolute w-100 h-100 top-0 start-0 text-center o-text-white align-middle fs-2 o-bg-black bg-opacity-50">
<t t-out="category.name"/>
</div>
</div>
</div>
</t>
</t>
</div>
<t t-if="!state.showCategories">
<div class="d-flex gap-1">
<div>
<t t-foreach="state.evenGif.gifs" t-as="gif" t-key="gif_index">
<t t-call="discuss.Gif"/>
</t>
</div>
<div>
<t t-foreach="state.oddGif.gifs" t-as="gif" t-key="gif_index">
<t t-call="discuss.Gif"/>
</t>
</div>
</div>
<div t-if="state.loadingGif" class="text-center fs-6 h-100 d-flex flex-column justify-content-center">
<i class="fa fa-spin fa-circle-o-notch"/>
</div>
<div t-elif="showFavorite and state.evenGif.gifs.length === 0 and state.oddGif.gifs.length === 0" class="d-flex flex-column h-100 align-items-center justify-content-center text-center text-muted gap-2">
<i class="o-discuss-GifPicker-noFavoritesIcon fa fa-star fs-1"/>
<span class="fs-5">So uhh... maybe go favorite some GIFs?</span>
</div>
</t>
</div>
</div>
</t>
<t t-name="discuss.Gif" owl="1">
<div class="o-discuss-Gif position-relative mt-1 fs-5 cursor-pointer">
<i
t-if="!store.guest"
class="position-absolute top-0 end-0 me-3 mt-3 p-2 o-bg-black bg-opacity-75 fa cursor-pointer opacity-0"
t-att-class="{ 'o-text-white fa-star-o': !isFavorite(gif), 'fa-star o-starred': isFavorite(gif) }"
t-on-click="() => this.onClickFavorite(gif)"
/>
<img
class="img-fluid"
t-att-src="gif.media_formats.tinygif.url"
t-on-click="() => this.onClickGif(gif)"
loading="lazy"
alt="GIF"
/>
</div>
</t>
</templates>
@@ -0,0 +1,15 @@
/* @odoo-module */
import { registry } from "@web/core/registry";
export class GifPickerService {
hasGifPickerFeature = false;
}
export const gifPickerService = {
start(env, services) {
return new GifPickerService(env, services);
},
};
registry.category("services").add("discuss.gifPicker", gifPickerService);
@@ -0,0 +1,21 @@
/* @odoo-module */
import { Messaging, messagingService } from "@mail/core/common/messaging_service";
import { patch } from "@web/core/utils/patch";
messagingService.dependencies.push("discuss.gifPicker");
/** @type {Messaging} */
const messagingPatch = {
setup(env, services) {
this._super(env, services);
this.gifPickerService = services["discuss.gifPicker"];
},
initMessagingCallback(data) {
this._super(data);
this.gifPickerService.hasGifPickerFeature = data.hasGifPickerFeature;
},
};
patch(Messaging.prototype, "GifPicker", messagingPatch);
@@ -100,6 +100,29 @@ export function useHover(refName, callback = () => {}) {
return state;
}
/**
* Hook that execute the callback function each time the scrollable element hit
* the bottom minus the threshold.
*
* @param {string} refName scrollable t-ref name to observe
* @param {function} callback function to execute when scroll hit the bottom minus the threshold
* @param {number} threshold number of threshold pixel to trigger the callback
*/
export function useOnBottomScrolled(refName, callback, threshold = 1) {
const ref = useRef(refName);
function onScroll() {
if (Math.abs(ref.el.scrollTop + ref.el.clientHeight - ref.el.scrollHeight) < threshold) {
callback();
}
}
onMounted(() => {
ref.el.addEventListener("scroll", onScroll);
});
onWillUnmount(() => {
ref.el.removeEventListener("scroll", onScroll);
});
}
export function useAutoScroll(refName, shouldScrollPredicate = () => true) {
const ref = useRef(refName);
let el = null;
@@ -87,10 +87,10 @@ QUnit.test("recent category (basic)", async (assert) => {
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("button[aria-label='Emojis']");
assert.containsNone($, ".o-mail-EmojiPicker-header [title='Frequently used']");
assert.containsNone($, ".o-mail-EmojiPicker-navbar [title='Frequently used']");
await click(".o-mail-EmojiPicker-content .o-mail-Emoji:contains(😀)");
await click("button[aria-label='Emojis']");
assert.containsOnce($, ".o-mail-EmojiPicker-header [title='Frequently used']");
assert.containsOnce($, ".o-mail-EmojiPicker-navbar [title='Frequently used']");
assert.containsOnce(
$,
"span:contains(Frequently used) ~ .o-mail-Emoji:contains(😀) ~ span:contains(Smileys & Emotion)"
@@ -158,7 +158,7 @@ QUnit.test("first category should be highlight by default", async (assert) => {
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("button[aria-label='Emojis']");
assert.containsOnce($, ".o-mail-EmojiPicker-header .o-mail-Emoji:eq(0).bg-200");
assert.containsOnce($, ".o-mail-EmojiPicker-navbar .o-mail-Emoji:eq(0).bg-300");
});
QUnit.test(
@@ -0,0 +1,212 @@
/* @odoo-module */
import {
click,
insertText,
start,
startServer,
waitUntil,
} from "@mail/../tests/helpers/test_utils";
import { patchUiSize, SIZES } from "@mail/../tests/helpers/patch_ui_size";
const rpc = {
search: {
results: [
{
id: "16925131306449801434",
title: "",
media_formats: {
tinygif: {
url: "https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif",
duration: 0,
preview: "",
dims: [220, 190],
size: 1007885,
},
},
created: 1654414453.782169,
content_description: "Cry GIF",
itemurl: "https://tenor.com/view/cry-gif-25866484",
url: "https://tenor.com/bUHdw.gif",
tags: ["cry"],
flags: [],
hasaudio: false,
},
{
id: "11429640401266091247",
title: "",
media_formats: {
tinygif: {
url: "https://media.tenor.com/np49Y1vrJO8AAAAM/crying-cry.gif",
duration: 0,
preview: "",
dims: [220, 220],
size: 145353,
},
},
created: 1612455937.558013,
content_description: "Crying Crying Face GIF",
itemurl: "https://tenor.com/view/crying-cry-crying-face-gif-20235014",
url: "https://tenor.com/bw4dm.gif",
tags: ["crying", "cry", "Crying Face"],
flags: [],
hasaudio: false,
},
],
next: "CAgQpIGj_8WN_gIaHgoKAD-_xMQ20dMU_xIQ1MVHUnSAQxC98Y6VAAAAADAI",
},
categories: {
locale: "en",
tags: [
{
searchterm: "cry",
path: "/v2/search?q=cry&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif",
name: "#cry",
},
{
searchterm: "yes",
path: "/v2/search?q=yes&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/UVmpVqlpVhQAAAAM/yess-yes.gif",
name: "#yes",
},
{
searchterm: "no",
path: "/v2/search?q=no&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/aeswYw-86k8AAAAM/no-nooo.gif",
name: "#no",
},
{
searchterm: "lol",
path: "/v2/search?q=lol&locale=en&component=categories&contentfilter=low",
image: "https://media.tenor.com/BiseY2UXovAAAAAM/lmfao-laughing.gif",
name: "#lol",
},
],
},
};
QUnit.module("GIF picker");
QUnit.test("composer should display a GIF button", async (assert) => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
const { openDiscuss } = await start();
await openDiscuss(channelId);
assert.containsOnce($, "button[aria-label='GIFs']");
});
QUnit.test("Composer GIF button should open the GIF picker", async (assert) => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
const { openDiscuss } = await start();
await openDiscuss(channelId);
await click("button[aria-label='GIFs']");
assert.containsOnce($, ".o-discuss-GifPicker");
});
QUnit.test("Searching for a GIF", async (assert) => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
const { openDiscuss } = await start({
mockRPC(route) {
if (route === "/discuss/gif/search") {
return rpc.search;
}
},
});
await openDiscuss(channelId);
await click("button[aria-label='GIFs']");
await insertText("input[placeholder='Search for a gif']", "search");
assert.containsOnce($, "i[aria-label='back']");
await waitUntil(".o-discuss-Gif", 2);
});
QUnit.test("Open a GIF category trigger the search for the category", async (assert) => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
const { openDiscuss } = await start({
mockRPC(route) {
if (route === "/discuss/gif/categories") {
return rpc.categories;
}
if (route === "/discuss/gif/search") {
return rpc.search;
}
},
});
await openDiscuss(channelId);
await click("button[aria-label='GIFs']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await waitUntil(".o-discuss-Gif", 2);
assert.strictEqual(
document.querySelector("input[placeholder='Search for a gif']").value,
"cry"
);
});
QUnit.test("Reopen GIF category list when going back", async (assert) => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
const { openDiscuss } = await start({
mockRPC(route) {
if (route === "/discuss/gif/search") {
return rpc.search;
}
if (route === "/discuss/gif/categories") {
return rpc.categories;
}
},
});
await openDiscuss(channelId);
await click("button[aria-label='GIFs']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await click("i[aria-label='back']");
assert.containsOnce($, ".o-discuss-GifPicker div[aria-label='list']");
});
QUnit.test("Add GIF to favorite", async (assert) => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "" });
const { openDiscuss } = await start({
mockRPC(route) {
if (route === "/discuss/gif/search") {
return rpc.search;
}
if (route === "/discuss/gif/categories") {
return rpc.categories;
}
},
});
await openDiscuss(channelId);
await click("button[aria-label='GIFs']");
await click("img[data-src='https://media.tenor.com/6uIlQAHIkNoAAAAM/cry.gif']");
await click($(".o-discuss-Gif .fa-star-o"));
assert.containsOnce($, ".o-discuss-Gif .fa-star");
await click("i[aria-label='back']");
await click(".o-discuss-GifPicker div[aria-label='list-item']:contains(Favorites)");
assert.containsOnce($, ".o-discuss-Gif");
});
QUnit.test("Chatter should not have the GIF button", async (assert) => {
const { openFormView, pyEnv } = await start();
const partnerId = pyEnv["res.partner"].create({ name: "John Doe" });
await openFormView("res.partner", partnerId);
await click("button:contains(Log note)");
assert.containsNone($, "button[aria-label='GIFs']");
});
QUnit.test(
"Composer GIF button should open the GIF picker keyboard for mobile device",
async (assert) => {
const pyEnv = await startServer();
const channelId = pyEnv["discuss.channel"].create({ name: "channel" });
patchUiSize({ size: SIZES.SM });
const { openDiscuss } = await start();
await openDiscuss(channelId, { waitUntilMessagesLoaded: false });
await click("span:contains(channel)");
await click("button[aria-label='GIFs']");
assert.containsNone($, ".popover .o-discuss-GifPicker");
assert.containsOnce($, ".o-mail-Composer-footer .o-discuss-GifPicker");
}
);
@@ -164,6 +164,9 @@ patch(MockServer.prototype, "mail/controllers/discuss", {
limit
);
}
if (route === "/discuss/gif/favorites") {
return [[]];
}
return this._super(route, args);
},
/**
@@ -34,6 +34,7 @@ patch(MockServer.prototype, "mail/models/res_users", {
current_user_settings: this._mockResUsersSettings_ResUsersSettingsFormat(
userSettings.id
),
hasGifPickerFeature: true,
initBusId: this.lastBusNotificationId,
menu_id: false, // not useful in QUnit tests
needaction_inbox_counter: this._mockResPartner_GetNeedactionCount(user.partner_id),
@@ -22,6 +22,7 @@ addModelNamesToFetch([
"discuss.channel",
"discuss.channel.member",
"discuss.channel.rtc.session",
"discuss.gif.favorite",
"mail.followers",
"mail.guest",
"mail.link.preview",
@@ -33,6 +33,7 @@ import { session } from "@web/session";
import { makeMockXHR } from "@web/../tests/helpers/mock_services";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { createWebClient } from "@web/../tests/webclient/helpers";
import { gifPickerService } from "@mail/discuss/gif_picker/common/gif_picker_service";
const ROUTES_TO_IGNORE = [
"/web/webclient/load_menus",
@@ -180,6 +181,7 @@ export const setupManager = {
im_status: imStatusService,
effect: effectService,
"discuss.channel.member": channelMemberService,
"discuss.gifPicker": gifPickerService,
"mail.notification.permission": notificationPermissionService,
"mail.suggestion": suggestionService,
"mail.store": storeService,
@@ -0,0 +1,39 @@
<?xml version="1.0"?>
<odoo>
<record id="discuss_gif_favorite_view_form" model="ir.ui.view">
<field name="name">discuss.gif.favorite.form</field>
<field name="model">discuss.gif.favorite</field>
<field name="arch" type="xml">
<form string="GIF favorite">
<sheet>
<group>
<field name="id"/>
<field name="tenor_gif_id"/>
<field name="create_uid"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="discuss_gif_favorite_view_tree" model="ir.ui.view">
<field name="name">discuss.gif.favorite.tree</field>
<field name="model">discuss.gif.favorite</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<tree string="GIF favorite">
<field name="id"/>
<field name="tenor_gif_id"/>
<field name="create_uid"/>
</tree>
</field>
</record>
<record id="discuss_gif_favorite_action" model="ir.actions.act_window">
<field name="name">GIF favorite</field>
<field name="res_model">discuss.gif.favorite</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>
+5
View File
@@ -139,6 +139,11 @@
action="mail_link_preview_action"
parent="mail.mail_menu_technical"
sequence="55"/>
<menuitem id="discuss_gif_favorite_menu"
name="GIF favorite"
action="discuss_gif_favorite_action"
parent="mail.mail_menu_technical"
sequence="56"/>
<!--
This menuitem will be activated by integrations modules (like github, twitter, ...). It
@@ -77,6 +77,15 @@
<setting id="restrict_template_rendering_setting" help="Restrict mail templates edition and QWEB placeholders usage.">
<field name="restrict_template_rendering"/>
</setting>
<setting id="tenor_api_key" string="Tenor GIF API key" help="Add a Tenor GIF API key to enable GIFs support." documentation="https://developers.google.com/tenor/guides/quickstart#setup">
<field name="tenor_api_key" placeholder="Paste your API key"/>
</setting>
<setting id="tenor_content_filter" string="Tenor GIF content filter" help="Select the content filter used for filtering GIFs" documentation="https://developers.google.com/tenor/guides/content-filtering">
<field name="tenor_content_filter"/>
</setting>
<setting id="tenor_gif_limit" string="Tenor GIF limits" help="Fetch up to the specified number of GIF.">
<field name="tenor_gif_limit"/>
</setting>
</block>
</div>
<setting id="document_layout_setting" position="after">
@@ -109,6 +109,7 @@ class TestDiscussFullPerformance(TransactionCase):
"""
return {
'initBusId': self.env['bus.bus'].sudo()._bus_last_id(),
'hasGifPickerFeature': False,
'hasLinkPreviewFeature': True,
'needaction_inbox_counter': 1,
'starred_counter': 1,