[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:
committed by
Alexandre Kühn
co-authored by
Brieuc-brd
parent
ee963d1002
commit
771477b9a7
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user