😵💫
Failed to load emojis...
-
-
-
-
-
-
+
+
+
@@ -57,12 +45,22 @@
+
+
+
+
+
+
+
+
+
+
-
+
@@ -73,7 +71,7 @@
-
+
diff --git a/addons/mail/static/src/core/common/thread_service.js b/addons/mail/static/src/core/common/thread_service.js
index efc01a9ce29..058b465d4c3 100644
--- a/addons/mail/static/src/core/common/thread_service.js
+++ b/addons/mail/static/src/core/common/thread_service.js
@@ -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({
diff --git a/addons/mail/static/src/discuss/gif_picker/common/composer_patch.js b/addons/mail/static/src/discuss/gif_picker/common/composer_patch.js
new file mode 100644
index 00000000000..25b711e6662
--- /dev/null
+++ b/addons/mail/static/src/discuss/gif_picker/common/composer_patch.js
@@ -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);
diff --git a/addons/mail/static/src/discuss/gif_picker/common/composer_patch.xml b/addons/mail/static/src/discuss/gif_picker/common/composer_patch.xml
new file mode 100644
index 00000000000..a0d38b44303
--- /dev/null
+++ b/addons/mail/static/src/discuss/gif_picker/common/composer_patch.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.js b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.js
new file mode 100644
index 00000000000..9618c59d68b
--- /dev/null
+++ b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.js
@@ -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
}
+ */
+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();
+ }
+}
diff --git a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.scss b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.scss
new file mode 100644
index 00000000000..f932e66f009
--- /dev/null
+++ b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.scss
@@ -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;
+}
diff --git a/addons/mail/static/src/discuss/gif_picker/common/gif_picker.xml b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.xml
new file mode 100644
index 00000000000..dbd9aeea52d
--- /dev/null
+++ b/addons/mail/static/src/discuss/gif_picker/common/gif_picker.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+ 😵💫
+ Failed to load gifs...
+
+
+
+
+
![GIF Favorites]()
+
+ Favorites
+
+
+
+
+
+
+
+
+
+
+
+
+
![GIF Category]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ So uhh... maybe go favorite some GIFs?
+
+
+
+
+
+
+
+
+
+
![GIF]()
+
+
+
diff --git a/addons/mail/static/src/discuss/gif_picker/common/gif_picker_service.js b/addons/mail/static/src/discuss/gif_picker/common/gif_picker_service.js
new file mode 100644
index 00000000000..ba138131223
--- /dev/null
+++ b/addons/mail/static/src/discuss/gif_picker/common/gif_picker_service.js
@@ -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);
diff --git a/addons/mail/static/src/discuss/gif_picker/common/messaging_service_patch.js b/addons/mail/static/src/discuss/gif_picker/common/messaging_service_patch.js
new file mode 100644
index 00000000000..609fe302828
--- /dev/null
+++ b/addons/mail/static/src/discuss/gif_picker/common/messaging_service_patch.js
@@ -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);
diff --git a/addons/mail/static/src/utils/common/hooks.js b/addons/mail/static/src/utils/common/hooks.js
index cd8fdceb95f..ec2cc734b5f 100644
--- a/addons/mail/static/src/utils/common/hooks.js
+++ b/addons/mail/static/src/utils/common/hooks.js
@@ -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;
diff --git a/addons/mail/static/tests/emoji/emoji_tests.js b/addons/mail/static/tests/emoji/emoji_tests.js
index 8d882fc9f84..5dd88d14548 100644
--- a/addons/mail/static/tests/emoji/emoji_tests.js
+++ b/addons/mail/static/tests/emoji/emoji_tests.js
@@ -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(
diff --git a/addons/mail/static/tests/gif_picker/gif_picker_test.js b/addons/mail/static/tests/gif_picker/gif_picker_test.js
new file mode 100644
index 00000000000..83f4cf2ecf1
--- /dev/null
+++ b/addons/mail/static/tests/gif_picker/gif_picker_test.js
@@ -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");
+ }
+);
diff --git a/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js b/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js
index 30c190ef113..8c9610cbb3c 100644
--- a/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js
+++ b/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js
@@ -164,6 +164,9 @@ patch(MockServer.prototype, "mail/controllers/discuss", {
limit
);
}
+ if (route === "/discuss/gif/favorites") {
+ return [[]];
+ }
return this._super(route, args);
},
/**
diff --git a/addons/mail/static/tests/helpers/mock_server/models/res_users.js b/addons/mail/static/tests/helpers/mock_server/models/res_users.js
index 78035cf5412..cd1e8037585 100644
--- a/addons/mail/static/tests/helpers/mock_server/models/res_users.js
+++ b/addons/mail/static/tests/helpers/mock_server/models/res_users.js
@@ -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),
diff --git a/addons/mail/static/tests/helpers/model_definitions_setup.js b/addons/mail/static/tests/helpers/model_definitions_setup.js
index 126d82dba90..0db17c4e3c7 100644
--- a/addons/mail/static/tests/helpers/model_definitions_setup.js
+++ b/addons/mail/static/tests/helpers/model_definitions_setup.js
@@ -22,6 +22,7 @@ addModelNamesToFetch([
"discuss.channel",
"discuss.channel.member",
"discuss.channel.rtc.session",
+ "discuss.gif.favorite",
"mail.followers",
"mail.guest",
"mail.link.preview",
diff --git a/addons/mail/static/tests/helpers/webclient_setup.js b/addons/mail/static/tests/helpers/webclient_setup.js
index 8bda2def150..441b5ca3f8e 100644
--- a/addons/mail/static/tests/helpers/webclient_setup.js
+++ b/addons/mail/static/tests/helpers/webclient_setup.js
@@ -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,
diff --git a/addons/mail/views/discuss/discuss_gif_favorite_views.xml b/addons/mail/views/discuss/discuss_gif_favorite_views.xml
new file mode 100644
index 00000000000..0fa78849fce
--- /dev/null
+++ b/addons/mail/views/discuss/discuss_gif_favorite_views.xml
@@ -0,0 +1,39 @@
+
+
+
+
+ discuss.gif.favorite.form
+ discuss.gif.favorite
+
+
+
+
+
+
+ discuss.gif.favorite.tree
+ discuss.gif.favorite
+ 10
+
+
+
+
+
+
+
+
+
+
+ GIF favorite
+ discuss.gif.favorite
+ tree,form
+
+
+
diff --git a/addons/mail/views/mail_menus.xml b/addons/mail/views/mail_menus.xml
index 494165c5669..8a5531d19d6 100644
--- a/addons/mail/views/mail_menus.xml
+++ b/addons/mail/views/mail_menus.xml
@@ -139,6 +139,11 @@
action="mail_link_preview_action"
parent="mail.mail_menu_technical"
sequence="55"/>
+