diff --git a/addons/im_livechat/static/src/embed/core/disabled_features.js b/addons/im_livechat/static/src/embed/core/disabled_features.js index ec863b4d0c4..24b2a0829c5 100644 --- a/addons/im_livechat/static/src/embed/core/disabled_features.js +++ b/addons/im_livechat/static/src/embed/core/disabled_features.js @@ -25,6 +25,9 @@ patch(Thread.prototype, { get hasMemberList() { return false; }, + get hasAttachmentPanel() { + return this.type !== "livechat" && super.hasAttachmentPanel; + }, }); patch(ThreadService.prototype, { diff --git a/addons/mail/controllers/discuss/channel.py b/addons/mail/controllers/discuss/channel.py index 17397c4a495..cc22a81f7cd 100644 --- a/addons/mail/controllers/discuss/channel.py +++ b/addons/mail/controllers/discuss/channel.py @@ -81,3 +81,21 @@ class ChannelController(http.Controller): def discuss_channel_notify_typing(self, channel_id, is_typing): channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_context_or_raise(channel_id=int(channel_id)) channel_member_sudo._notify_typing(is_typing) + + @http.route("/discuss/channel/attachments", methods=["POST"], type="json", auth="public") + @add_guest_to_context + def load_attachments(self, channel_id, limit=30, before=None): + """Load attachments of a channel. If before is set, load attachments + older than the given id. + :param channel_id: id of the channel + :param limit: maximum number of attachments to return + :param before: id of the attachment from which to load older attachments + """ + channel_member_sudo = request.env["discuss.channel.member"]._get_as_sudo_from_context_or_raise(channel_id=channel_id) + domain = [ + ["res_id", "=", channel_id], + ["res_model", "=", "discuss.channel"], + ] + if before: + domain.append(["id", "<", before]) + return channel_member_sudo.env["ir.attachment"].search(domain, limit=limit, order="id DESC")._attachment_format() diff --git a/addons/mail/models/ir_attachment.py b/addons/mail/models/ir_attachment.py index 7857eb0ed7f..2624eb8b942 100644 --- a/addons/mail/models/ir_attachment.py +++ b/addons/mail/models/ir_attachment.py @@ -76,6 +76,7 @@ class IrAttachment(models.Model): safari = request and request.httprequest.user_agent and request.httprequest.user_agent.browser == 'safari' return [{ 'checksum': attachment.checksum, + 'create_date': attachment.create_date, 'id': attachment.id, 'filename': attachment.name, 'name': attachment.name, diff --git a/addons/mail/static/src/core/common/attachment_model.js b/addons/mail/static/src/core/common/attachment_model.js index ecb0082daa0..c6e1a11bc35 100644 --- a/addons/mail/static/src/core/common/attachment_model.js +++ b/addons/mail/static/src/core/common/attachment_model.js @@ -3,6 +3,7 @@ import { Record } from "@mail/core/common/record"; import { assignDefined } from "@mail/utils/common/misc"; +import { deserializeDateTime } from "@web/core/l10n/dates"; import { url } from "@web/core/utils/urls"; export class Attachment extends Record { @@ -25,6 +26,8 @@ export class Attachment extends Record { uploading; /** @type {import("@mail/core/common/message_model").Message} */ message; + /** @type {string} */ + create_date; /** @type {import("@mail/core/common/thread_model").Thread} */ get originThread() { @@ -54,6 +57,14 @@ export class Attachment extends Record { return this.mimetype && this.mimetype.startsWith("application/pdf"); } + get monthYear() { + if (!this.create_date) { + return undefined; + } + const datetime = deserializeDateTime(this.create_date); + return `${datetime.monthLong}, ${datetime.year}`; + } + get isImage() { const imageMimetypes = [ "image/bmp", diff --git a/addons/mail/static/src/core/common/attachment_service.js b/addons/mail/static/src/core/common/attachment_service.js index 9dab855c953..d8966b97904 100644 --- a/addons/mail/static/src/core/common/attachment_service.js +++ b/addons/mail/static/src/core/common/attachment_service.js @@ -38,6 +38,7 @@ export class AttachmentService { update(attachment, data) { assignDefined(attachment, data, [ "checksum", + "create_date", "filename", "mimetype", "name", diff --git a/addons/mail/static/src/core/common/date_section.js b/addons/mail/static/src/core/common/date_section.js new file mode 100644 index 00000000000..403fc4181a2 --- /dev/null +++ b/addons/mail/static/src/core/common/date_section.js @@ -0,0 +1,13 @@ +/* @odoo-module */ + +import { Component } from "@odoo/owl"; + +/** + * @typedef {Object} Props + * @property {string} date + * @property {string} [className] + */ +export class DateSection extends Component { + static template = "mail.DateSection"; + static props = ["date", "className?"]; +} diff --git a/addons/mail/static/src/core/common/date_section.scss b/addons/mail/static/src/core/common/date_section.scss new file mode 100644 index 00000000000..9b74ad1d538 --- /dev/null +++ b/addons/mail/static/src/core/common/date_section.scss @@ -0,0 +1,3 @@ +.o-mail-DateSection hr { + opacity: $hr-opacity / 2; +} diff --git a/addons/mail/static/src/core/common/date_section.xml b/addons/mail/static/src/core/common/date_section.xml new file mode 100644 index 00000000000..13cbe5d3f77 --- /dev/null +++ b/addons/mail/static/src/core/common/date_section.xml @@ -0,0 +1,11 @@ + + + + +
+
+ +
+
+
+
diff --git a/addons/mail/static/src/core/common/thread.js b/addons/mail/static/src/core/common/thread.js index 805dc458501..8184c5023ce 100644 --- a/addons/mail/static/src/core/common/thread.js +++ b/addons/mail/static/src/core/common/thread.js @@ -1,5 +1,6 @@ /* @odoo-module */ +import { DateSection } from "@mail/core/common/date_section"; import { Message } from "@mail/core/common/message"; import { useAutoScroll, @@ -35,7 +36,7 @@ export const PRESENT_THRESHOLD = 2500; * @extends {Component} */ export class Thread extends Component { - static components = { Message, Transition }; + static components = { Message, Transition, DateSection }; static props = [ "isInChatWindow?", "hasScrollAdjust?", diff --git a/addons/mail/static/src/core/common/thread.scss b/addons/mail/static/src/core/common/thread.scss index 80ab34e1c4a..4699ff2b58e 100644 --- a/addons/mail/static/src/core/common/thread.scss +++ b/addons/mail/static/src/core/common/thread.scss @@ -1,7 +1,3 @@ -.o-mail-Thread-date hr { - opacity: $hr-opacity / 2; -} - .o-mail-Thread-newMessage { transition: opacity 0.5s; diff --git a/addons/mail/static/src/core/common/thread.xml b/addons/mail/static/src/core/common/thread.xml index 95beb8e49a4..37e5fa37755 100644 --- a/addons/mail/static/src/core/common/thread.xml +++ b/addons/mail/static/src/core/common/thread.xml @@ -19,11 +19,7 @@ -
-
- -
-
+ diff --git a/addons/mail/static/src/core/common/thread_model.js b/addons/mail/static/src/core/common/thread_model.js index 907e46c4743..c25c3512242 100644 --- a/addons/mail/static/src/core/common/thread_model.js +++ b/addons/mail/static/src/core/common/thread_model.js @@ -204,6 +204,10 @@ export class Thread extends Record { return ["channel", "group"].includes(this.type); } + get hasAttachmentPanel() { + return this.model === "discuss.channel"; + } + get isChatChannel() { return ["chat", "group"].includes(this.type); } diff --git a/addons/mail/static/src/discuss/core/common/attachment_panel.js b/addons/mail/static/src/discuss/core/common/attachment_panel.js new file mode 100644 index 00000000000..3fba3a77d30 --- /dev/null +++ b/addons/mail/static/src/discuss/core/common/attachment_panel.js @@ -0,0 +1,50 @@ +/* @odoo-module */ + +import { DateSection } from "@mail/core/common/date_section"; +import { ActionPanel } from "@mail/discuss/core/common/action_panel"; +import { AttachmentList } from "@mail/core/common/attachment_list"; + +import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { useVisible } from "@mail/utils/common/hooks"; + +/** + * @typedef {Object} Props + * @property {import("@mail/core/common/thread_model").Thread} thread + */ +export class AttachmentPanel extends Component { + static components = { ActionPanel, AttachmentList, DateSection }; + static props = ["thread"]; + static template = "mail.AttachmentPanel"; + + setup() { + this.threadService = useService("mail.thread"); + this.attachmentUploadService = useService("mail.attachment_upload"); + onWillStart(() => { + this.threadService.fetchMoreAttachments(this.props.thread); + }); + onWillUpdateProps((nextProps) => { + if (nextProps.thread.notEq(this.props.thread)) { + this.threadService.fetchMoreAttachments(nextProps.thread); + } + }); + const loadOlderState = useVisible("load-older", () => { + if (loadOlderState.isVisible) { + this.threadService.fetchMoreAttachments(this.props.thread); + } + }); + } + + /** + * @return {Object} + */ + get attachmentsByDate() { + const attachmentsByDate = {}; + for (const attachment of this.props.thread.attachments) { + const attachments = attachmentsByDate[attachment.monthYear] ?? []; + attachments.push(attachment); + attachmentsByDate[attachment.monthYear] = attachments; + } + return attachmentsByDate; + } +} diff --git a/addons/mail/static/src/discuss/core/common/attachment_panel.xml b/addons/mail/static/src/discuss/core/common/attachment_panel.xml new file mode 100644 index 00000000000..e71f58ddaee --- /dev/null +++ b/addons/mail/static/src/discuss/core/common/attachment_panel.xml @@ -0,0 +1,22 @@ + + + + + Attachments + +
+

+ This channel doesn't have any attachments. + This conversation doesn't have any attachments. +

+
+ + +
+
+ +
+
+
diff --git a/addons/mail/static/src/discuss/core/common/thread_actions.js b/addons/mail/static/src/discuss/core/common/thread_actions.js index 343b8769584..d7e70378fea 100644 --- a/addons/mail/static/src/discuss/core/common/thread_actions.js +++ b/addons/mail/static/src/discuss/core/common/thread_actions.js @@ -1,6 +1,7 @@ /* @odoo-module */ import { threadActionsRegistry } from "@mail/core/common/thread_actions"; +import { AttachmentPanel } from "@mail/discuss/core/common/attachment_panel"; import { ChannelInvitation } from "@mail/discuss/core/common/channel_invitation"; import { ChannelMemberList } from "@mail/discuss/core/common/channel_member_list"; @@ -10,6 +11,18 @@ import { _t } from "@web/core/l10n/translation"; import { usePopover } from "@web/core/popover/popover_hook"; threadActionsRegistry + .add("attachments", { + condition: (component) => + component.thread?.hasAttachmentPanel && + (!component.props.chatWindow || component.props.chatWindow.isOpen), + component: AttachmentPanel, + icon: "fa fa-fw fa-paperclip", + iconLarge: "fa fa-fw fa-lg fa-paperclip", + name: _t("Show Attachments"), + nameActive: _t("Hide Attachments"), + sequence: 25, + toggle: true, + }) .add("add-users", { close(component, action) { action.popover?.close(); diff --git a/addons/mail/static/src/discuss/core/common/thread_service_patch.js b/addons/mail/static/src/discuss/core/common/thread_service_patch.js index 0f0f77bf723..d1cd148deed 100644 --- a/addons/mail/static/src/discuss/core/common/thread_service_patch.js +++ b/addons/mail/static/src/discuss/core/common/thread_service_patch.js @@ -27,4 +27,24 @@ patch(ThreadService.prototype, { } return super.post(...arguments); }, + + async fetchMoreAttachments(thread, limit = 30) { + if (thread.isLoadingAttachments || thread.areAttachmentsLoaded) { + return; + } + thread.isLoadingAttachments = true; + try { + const rawAttachments = await this.rpc("/discuss/channel/attachments", { + before: Math.min(...thread.attachments.map(({ id }) => id)), + channel_id: thread.id, + limit, + }); + const attachments = rawAttachments.map((a) => this.store.Attachment.insert(a)); + if (attachments.length < limit) { + thread.areAttachmentsLoaded = true; + } + } finally { + thread.isLoadingAttachments = false; + } + }, }); diff --git a/addons/mail/static/src/discuss/message_pin/common/thread_actions.js b/addons/mail/static/src/discuss/message_pin/common/thread_actions.js index 3a3c75a93b7..28c40c68c93 100644 --- a/addons/mail/static/src/discuss/message_pin/common/thread_actions.js +++ b/addons/mail/static/src/discuss/message_pin/common/thread_actions.js @@ -20,7 +20,7 @@ threadActionsRegistry.add("pinned-messages", { iconLarge: "fa fa-fw fa-lg fa-thumb-tack", name: _t("Pinned Messages"), nameActive: _t("Hide Pinned Messages"), - sequence: 29, + sequence: 20, setup(action) { useChildSubEnv({ pinMenu: { diff --git a/addons/mail/static/tests/chat_window/chat_window_tests.js b/addons/mail/static/tests/chat_window/chat_window_tests.js index 3549b71a06e..67cc58a7a71 100644 --- a/addons/mail/static/tests/chat_window/chat_window_tests.js +++ b/addons/mail/static/tests/chat_window/chat_window_tests.js @@ -146,8 +146,9 @@ QUnit.test("chat window: basic rendering", async (assert) => { "There are no messages in this conversation." ); await click("[title='Open Actions Menu']"); - await contains(".o-mail-ChatWindow-command", 9); + await contains(".o-mail-ChatWindow-command", 10); await contains("[title='Pinned Messages']"); + await contains("[title='Show Attachments']"); await contains("[title='Add Users']"); await contains("[title='Show Member List']"); await contains("[title='Show Call Settings']"); diff --git a/addons/mail/static/tests/discuss/core/attachment_panel_tests.js b/addons/mail/static/tests/discuss/core/attachment_panel_tests.js new file mode 100644 index 00000000000..6b15504515d --- /dev/null +++ b/addons/mail/static/tests/discuss/core/attachment_panel_tests.js @@ -0,0 +1,44 @@ +/* @odoo-module */ + +import { click, contains, start, startServer } from "@mail/../tests/helpers/test_utils"; + +QUnit.module("attachment panel"); + +QUnit.test("Empty attachment panel", async () => { + const pyEnv = await startServer(); + const channelId = await pyEnv["discuss.channel"].create({ name: "General" }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Discuss-header button[title='Show Attachments']"); + await contains( + ".o-mail-Discuss-inspector:contains(This channel doesn't have any attachments.)" + ); +}); + +QUnit.test("Attachment panel sort by date", async () => { + const pyEnv = await startServer(); + const channelId = await pyEnv["discuss.channel"].create({ name: "General" }); + pyEnv["ir.attachment"].create([ + { + res_id: channelId, + res_model: "discuss.channel", + name: "file1.pdf", + create_date: "2023-08-20", + }, + { + res_id: channelId, + res_model: "discuss.channel", + name: "file2.pdf", + create_date: "2023-09-21", + }, + ]); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Discuss-header button[title='Show Attachments']"); + await contains( + ".o-mail-DateSection:contains(August, 2023) + .o-mail-AttachmentList:contains(file1.pdf)" + ); + await contains( + ".o-mail-DateSection:contains(September, 2023) + .o-mail-AttachmentList:contains(file2.pdf)" + ); +}); diff --git a/addons/mail/static/tests/helpers/mock_server/controllers/channel.js b/addons/mail/static/tests/helpers/mock_server/controllers/channel.js new file mode 100644 index 00000000000..cbcae81c0b6 --- /dev/null +++ b/addons/mail/static/tests/helpers/mock_server/controllers/channel.js @@ -0,0 +1,41 @@ +/* @odoo-module */ + +import { patch } from "@web/core/utils/patch"; +import { MockServer } from "@web/../tests/helpers/mock_server"; + +patch(MockServer.prototype, { + /** + * @override + */ + async _performRPC(route, args) { + if (route === "/discuss/channel/attachments") { + return this._mockRouteDiscussChannelAttachments( + args.channel_id, + args.limit, + args.older_attachment_id + ); + } + return super._performRPC(route, args); + }, + + /** + * Simulates the `/discuss/channel/attachments` route. + * + * @param {number} channelId + * @param {number} [limit=30] + * @param {number} [olderAttachmentId] + */ + _mockRouteDiscussChannelAttachments(channelId, limit = 30, olderAttachmentId = null) { + const attachmentIds = this.models["ir.attachment"].records + .filter( + ({ id, res_id, res_model }) => + res_id === channelId && + res_model === "discuss.channel" && + (!olderAttachmentId || id < olderAttachmentId) + ) + .sort() + .slice(0, limit) + .map(({ id }) => id); + return this._mockIrAttachment_attachmentFormat(attachmentIds); + }, +}); diff --git a/addons/mail/static/tests/helpers/mock_server/models/ir_attachment.js b/addons/mail/static/tests/helpers/mock_server/models/ir_attachment.js index 4b1c8e928ec..c0e81f7c358 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/ir_attachment.js +++ b/addons/mail/static/tests/helpers/mock_server/models/ir_attachment.js @@ -22,6 +22,7 @@ patch(MockServer.prototype, { const attachments = this.mockRead("ir.attachment", [ids]); return attachments.map((attachment) => { const res = { + create_date: attachment.create_date, checksum: attachment.checksum, filename: attachment.name, id: attachment.id, diff --git a/addons/mail/static/tests/thread/thread_tests.js b/addons/mail/static/tests/thread/thread_tests.js index c892d63708d..cec3f3abffa 100644 --- a/addons/mail/static/tests/thread/thread_tests.js +++ b/addons/mail/static/tests/thread/thread_tests.js @@ -128,7 +128,7 @@ QUnit.test("display day separator before first message of the day", async () => ]); const { openDiscuss } = await start(); openDiscuss(channelId); - await contains(".o-mail-Thread-date"); + await contains(".o-mail-DateSection"); }); QUnit.test("do not display day separator if all messages of the day are empty", async () => { @@ -142,7 +142,7 @@ QUnit.test("do not display day separator if all messages of the day are empty", const { openDiscuss } = await start(); openDiscuss(channelId); await contains(".o-mail-Thread-empty"); - await contains(".o-mail-Thread-date", 0); + await contains(".o-mail-DateSection", 0); }); QUnit.test("scroll position is kept when navigating from one channel to another", async () => { diff --git a/addons/mail/tests/discuss/test_message_controller.py b/addons/mail/tests/discuss/test_message_controller.py index ce32cf0dd30..aef39e19811 100644 --- a/addons/mail/tests/discuss/test_message_controller.py +++ b/addons/mail/tests/discuss/test_message_controller.py @@ -3,7 +3,7 @@ import json import odoo -from odoo.tools import mute_logger +from odoo.tools import mute_logger, date_utils from odoo.tests import HttpCase @@ -96,7 +96,7 @@ class TestMessageController(HttpCase): message_format1 = res2.json()["result"] self.assertEqual( message_format1["attachment_ids"], - json.loads(json.dumps(self.attachments[0]._attachment_format())), + json.loads(json.dumps(self.attachments[0]._attachment_format(), default=date_utils.json_default)), "guest should be allowed to add attachment with token when posting message", ) # test message update: token error @@ -139,7 +139,7 @@ class TestMessageController(HttpCase): message_format2 = res4.json()["result"] self.assertEqual( message_format2["attachment_ids"], - json.loads(json.dumps(self.attachments.sorted("id")._attachment_format())), + json.loads(json.dumps(self.attachments.sorted("id")._attachment_format(), default=date_utils.json_default)), "guest should be allowed to add attachment with token when updating message", ) # test message update: own attachment ok @@ -160,7 +160,7 @@ class TestMessageController(HttpCase): message_format3 = res5.json()["result"] self.assertEqual( message_format3["attachment_ids"], - json.loads(json.dumps(self.attachments.sorted("id")._attachment_format())), + json.loads(json.dumps(self.attachments.sorted("id")._attachment_format(), default=date_utils.json_default)), "guest should be allowed to add own attachment without token when updating message", )