diff --git a/addons/mail/models/mail_followers.py b/addons/mail/models/mail_followers.py index eb113d37b59..191b0288d8a 100644 --- a/addons/mail/models/mail_followers.py +++ b/addons/mail/models/mail_followers.py @@ -508,3 +508,14 @@ GROUP BY fol.id%s%s""" % ( update[fol_id] = {'subtype_ids': update_cmd} return new, update + + def _format_for_chatter(self): + return [{ + 'id': follower.id, + 'partner_id': follower.partner_id.id, + 'name': follower.name, + 'display_name': follower.display_name, + 'email': follower.email, + 'is_active': follower.is_active, + 'partner': follower.partner_id.mail_partner_format()[follower.partner_id], + } for follower in self] diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 795380d4332..1e0dbab5cc1 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -3988,6 +3988,16 @@ class MailThread(models.AbstractModel): return True + def message_get_followers(self, after=None, limit=100): + self.ensure_one() + domain = [ + ("res_id", "=", self.id), + ("res_model", "=", self._name), + ] + if after: + domain = expression.AND([domain, [('id', '>', after)]]) + return self.env["mail.followers"].search(domain, limit=limit, order='id ASC')._format_for_chatter() + # ------------------------------------------------------ # THREAD MESSAGE UPDATE # ------------------------------------------------------ @@ -4146,15 +4156,17 @@ class MailThread(models.AbstractModel): if 'attachments' in request_list: res['attachments'] = self._get_mail_thread_data_attachments()._attachment_format() if 'followers' in request_list: - res['followers'] = [{ - 'id': follower.id, - 'partner_id': follower.partner_id.id, - 'name': follower.name, - 'display_name': follower.display_name, - 'email': follower.email, - 'is_active': follower.is_active, - 'partner': follower.partner_id.mail_partner_format()[follower.partner_id], - } for follower in self.message_follower_ids] + res['followersCount'] = self.env['mail.followers'].search_count([ + ("res_id", "=", self.id), + ("res_model", "=", self._name) + ]) + self_follower = self.env['mail.followers'].search([ + ("res_id", "=", self.id), + ("res_model", "=", self._name), + ['partner_id', '=', self.env.user.partner_id.id] + ])._format_for_chatter() + res['selfFollower'] = self_follower[0] if len(self_follower) > 0 else None + res['followers'] = self.message_get_followers() if 'suggestedRecipients' in request_list: res['suggestedRecipients'] = self._message_get_suggested_recipients()[self.id] return res diff --git a/addons/mail/static/src/core/common/message.js b/addons/mail/static/src/core/common/message.js index 9b2465ee760..262bb5d2727 100644 --- a/addons/mail/static/src/core/common/message.js +++ b/addons/mail/static/src/core/common/message.js @@ -242,7 +242,7 @@ export class Message extends Component { get showUnfollow() { return Boolean( - this.message.originThread?.followerOfSelf && this.props.thread?.model === "mail.box" + this.message.originThread?.selfFollower && this.props.thread?.model === "mail.box" ); } diff --git a/addons/mail/static/src/core/common/message_service.js b/addons/mail/static/src/core/common/message_service.js index 56cc0bda4f0..5a67d59a516 100644 --- a/addons/mail/static/src/core/common/message_service.js +++ b/addons/mail/static/src/core/common/message_service.js @@ -158,7 +158,7 @@ export class MessageService { await this.setDone(message); } const thread = message.originThread; - await this.env.services["mail.thread"].removeFollower(thread.followerOfSelf); + await this.env.services["mail.thread"].removeFollower(thread.selfFollower); this.env.services.notification.add( sprintf(_t('You are no longer following "%(thread_name)s".'), { thread_name: thread.name, @@ -327,7 +327,7 @@ export class MessageService { ) ); if ("user_follower_id" in data && data.user_follower_id && this.store.self) { - this.env.services["mail.thread"].insertFollower({ + message.originThread.selfFollower = this.env.services["mail.thread"].insertFollower({ followedThread: message.originThread, id: data.user_follower_id, isActive: true, diff --git a/addons/mail/static/src/core/common/navigable_list.js b/addons/mail/static/src/core/common/navigable_list.js index af94f84314c..b8c5187357a 100644 --- a/addons/mail/static/src/core/common/navigable_list.js +++ b/addons/mail/static/src/core/common/navigable_list.js @@ -64,14 +64,13 @@ export class NavigableList extends Component { return Boolean(this.state.open && (this.state.isLoading || this.state.options.length)); } - open() { + async open() { if (this.state.isLoading) { return; } - this.load().then(() => { - this.state.open = true; - this.navigate("first"); - }); + await this.load(); + this.state.open = true; + this.navigate("first"); } close() { @@ -81,23 +80,21 @@ export class NavigableList extends Component { async load() { this.state.options = []; - const makeOption = (opt) => { - return Object.assign(Object.create(opt), { - id: this.state.options.length, - }); - }; if (this.props.options instanceof Promise) { this.state.isLoading = true; - return this.props.options.then((opts) => { - opts.forEach((opt) => this.state.options.push(makeOption(opt))); - this.state.isLoading = false; - }); + const options = await this.props.options; + this.state.options = options.map((option, index) => ({ + ...option, + id: index, + })); + this.state.isLoading = false; + return; } if (this.props.options instanceof Array) { - if (this.props.options.length === 0) { - return; - } - this.props.options.forEach((opt) => this.state.options.push(makeOption(opt))); + this.state.options = this.props.options.map((option, index) => ({ + ...option, + id: index, + })); } } diff --git a/addons/mail/static/src/core/common/thread_model.js b/addons/mail/static/src/core/common/thread_model.js index a4697fcf301..76727af49b9 100644 --- a/addons/mail/static/src/core/common/thread_model.js +++ b/addons/mail/static/src/core/common/thread_model.js @@ -53,6 +53,10 @@ export class Thread { description; /** @type {Set} */ followers = new Set(); + /** @type {import("@mail/core/common/follower_model").Follower} */ + selfFollower; + /** @type {integer|undefined} */ + followersCount; isAdmin = false; loadOlder = false; loadNewer = false; @@ -160,6 +164,13 @@ export class Thread { return this.memberCount === this.channelMembers.length; } + get followersFullyLoaded() { + return ( + this.followersCount === + (this.selfFollower ? this.followers.size + 1 : this.followers.size) + ); + } + get attachmentsInWebClientView() { const attachments = this.attachments.filter( (attachment) => (attachment.isPdf || attachment.isImage) && !attachment.uploading @@ -252,18 +263,6 @@ export class Thread { return undefined; } - /** - * @returns {import("@mail/core/common/follower_model").Follower} - */ - get followerOfSelf() { - for (const follower of this.followers) { - if (follower.partner === this._store.self) { - return follower; - } - } - return undefined; - } - get imgUrl() { return this.module_icon ?? "/mail/static/src/img/smiley/avatar.jpg"; } diff --git a/addons/mail/static/src/core/web/chatter.js b/addons/mail/static/src/core/web/chatter.js index f46a33a6bfc..65ba02a07ef 100644 --- a/addons/mail/static/src/core/web/chatter.js +++ b/addons/mail/static/src/core/web/chatter.js @@ -10,6 +10,7 @@ import { Activity } from "@mail/core/web/activity"; import { SuggestedRecipientsList } from "@mail/core/web/suggested_recipient_list"; import { useHover, useScrollPosition } from "@mail/utils/common/hooks"; import { isDragSourceExternalFile } from "@mail/utils/common/misc"; +import { RecipientList } from "./recipient_list"; import { FollowerList } from "./follower_list"; import { @@ -32,7 +33,6 @@ import { useService } from "@web/core/utils/hooks"; import { escape } from "@web/core/utils/strings"; import { useThrottleForAnimation } from "@web/core/utils/timing"; import { FileUploader } from "@web/views/fields/file_handler"; -import { RecipientList } from "./recipient_list"; /** * @typedef {Object} Props @@ -203,19 +203,25 @@ export class Chatter extends Component { * @returns {string} */ get toRecipientsText() { - const followers = [...this.state.thread.followers].slice(0, 5).map(({ partner }) => { + const allFollowers = []; + if (this.state.thread.selfFollower) { + allFollowers.push(this.state.thread.selfFollower); + } + allFollowers.push(...this.state.thread.followers); + const followers = allFollowers.slice(0, 5).map(({ partner }) => { if (partner === this.store.self) { return `me`; } + const text = partner.email ? partner.emailWithoutDomain : partner.name; return `${escape( - partner.emailWithoutDomain + text )}`; }); const formatter = new Intl.ListFormat( this.store.env.services["user"].lang?.replace("_", "-"), { type: "unit" } ); - if (this.state.thread.followers.size > 5) { + if (allFollowers.length > 5) { followers.push("…"); } return markup(formatter.format(followers)); @@ -264,7 +270,7 @@ export class Chatter extends Component { } async onClickUnfollow() { - await this.threadService.removeFollower(this.state.thread.followerOfSelf); + await this.threadService.removeFollower(this.state.thread.selfFollower); this.onFollowerChanged(); } diff --git a/addons/mail/static/src/core/web/chatter.xml b/addons/mail/static/src/core/web/chatter.xml index 34f0afa1aa0..1f5895e7393 100644 --- a/addons/mail/static/src/core/web/chatter.xml +++ b/addons/mail/static/src/core/web/chatter.xml @@ -35,13 +35,14 @@ - + + - - - + + + - + + Load more + + + + diff --git a/addons/mail/static/src/core/web/recipient_list.js b/addons/mail/static/src/core/web/recipient_list.js index e3197088d0b..820b7edc922 100644 --- a/addons/mail/static/src/core/web/recipient_list.js +++ b/addons/mail/static/src/core/web/recipient_list.js @@ -1,6 +1,8 @@ /* @odoo-module */ -import { Component } from "@odoo/owl"; +import { useVisible } from "@mail/utils/common/hooks"; +import { Component, useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; /** * @typedef {Object} Props @@ -11,4 +13,13 @@ import { Component } from "@odoo/owl"; export class RecipientList extends Component { static template = "mail.RecipientList"; static props = ["thread", "close?"]; + + setup() { + this.threadService = useState(useService("mail.thread")); + this.loadMoreState = useVisible("load-more", () => { + if (this.loadMoreState.isVisible) { + this.threadService.loadMoreFollowers(this.props.thread); + } + }); + } } diff --git a/addons/mail/static/src/core/web/recipient_list.xml b/addons/mail/static/src/core/web/recipient_list.xml index c9a6062096b..c31cfa7aa03 100644 --- a/addons/mail/static/src/core/web/recipient_list.xml +++ b/addons/mail/static/src/core/web/recipient_list.xml @@ -4,9 +4,11 @@
    +
  • + Load more
diff --git a/addons/mail/static/src/core/web/thread_service_patch.js b/addons/mail/static/src/core/web/thread_service_patch.js index c9fcc9d6727..aa2ab477f90 100644 --- a/addons/mail/static/src/core/web/thread_service_patch.js +++ b/addons/mail/static/src/core/web/thread_service_patch.js @@ -5,7 +5,7 @@ import { ThreadService, threadService } from "@mail/core/common/thread_service"; import { parseEmail } from "@mail/js/utils"; import { createLocalId } from "@mail/utils/common/misc"; -import { markup } from "@odoo/owl"; +import { markup, toRaw } from "@odoo/owl"; import { _t } from "@web/core/l10n/translation"; import { patch } from "@web/core/utils/patch"; @@ -79,11 +79,21 @@ patch(ThreadService.prototype, "mail/core/web", { this.setMainAttachmentFromIndex(thread, 0); } if ("followers" in result) { + if (result.selfFollower) { + thread.selfFollower = this.insertFollower({ + followedThread: thread, + ...result.selfFollower, + }); + } + thread.followersCount = result.followersCount; for (const followerData of result.followers) { - this.insertFollower({ + const follower = this.insertFollower({ followedThread: thread, ...followerData, }); + if (toRaw(follower) !== toRaw(thread.selfFollower)) { + thread.followers.add(follower); + } } } if ("suggestedRecipients" in result) { @@ -138,7 +148,6 @@ patch(ThreadService.prototype, "mail/core/web", { partner: this.personaService.insert({ ...data.partner, type: "partner" }), _store: this.store, }); - follower.followedThread.followers.add(follower); return follower; }, /** @@ -174,6 +183,21 @@ patch(ThreadService.prototype, "mail/core/web", { } this._super(...arguments); }, + async loadMoreFollowers(thread) { + const followers = await this.orm.call(thread.model, "message_get_followers", [ + [thread.id], + Array.from(thread.followers).at(-1).id, + ]); + for (const data of followers) { + const follower = this.insertFollower({ + followedThread: thread, + ...data, + }); + if (toRaw(follower) !== toRaw(thread.selfFollower)) { + thread.followers.add(follower); + } + } + }, open(thread, replaceNewMessageChatWindow) { if (!this.store.discuss.isActive || this.ui.isSmall) { this.chatWindowService.open(thread, replaceNewMessageChatWindow); @@ -188,7 +212,12 @@ patch(ThreadService.prototype, "mail/core/web", { [follower.followedThread.id], [follower.partner.id], ]); - follower.followedThread.followers.delete(follower); + const thread = follower.followedThread; + if (toRaw(follower) === toRaw(thread.selfFollower)) { + thread.selfFollower = undefined; + } else { + thread.followers.delete(follower); + } delete this.store.followers[follower.id]; }, unpin(thread) { diff --git a/addons/mail/static/tests/composer/composer_tests.js b/addons/mail/static/tests/composer/composer_tests.js index 1a9f39fd44a..7f1445dbc33 100644 --- a/addons/mail/static/tests/composer/composer_tests.js +++ b/addons/mail/static/tests/composer/composer_tests.js @@ -864,7 +864,7 @@ QUnit.test("Show a thread name in the recipient status text.", async (assert) => assert.containsOnce($, 'span[title="test@odoo.com"]'); }); -QUnit.test("Show follower list when there is more than 5 followers.", async (assert) => { +QUnit.test("Show recipient list when there is more than 5 followers.", async (assert) => { const pyEnv = await startServer(); const partnerIds = pyEnv["res.partner"].create([ { name: "test name 1", email: "test1@odoo.com" }, 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 14fdab16009..39ee52ebef9 100644 --- a/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js +++ b/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js @@ -77,7 +77,7 @@ patch(MockServer.prototype, "mail/controllers/discuss", { if (route === "/mail/link_preview") { return this._mockRouteMailLinkPreview(args.message_id, args.clear); } - if (route == "/mail/link_preview/delete") { + if (route === "/mail/link_preview/delete") { const [linkPreview] = this.pyEnv["mail.link.preview"].searchRead([ ["id", "=", args.link_preview_id], ]); @@ -516,18 +516,18 @@ patch(MockServer.prototype, "mail/controllers/discuss", { } } if (request_list.includes("followers")) { - const followers = this.pyEnv["mail.followers"].searchRead([ - ["id", "in", thread.message_follower_ids || []], - ]); - // search read returns many2one relations as an array [id, display_name]. - // But the original route does not. Thus, we need to change it now. - followers.forEach((follower) => { - follower.partner_id = follower.partner_id[0]; - follower.partner = this._mockResPartnerMailPartnerFormat([follower.partner_id]).get( - follower.partner_id - ); - }); - res["followers"] = followers; + const domain = [ + ["res_id", "=", thread.id], + ["res_model", "=", thread_model], + ]; + res["followersCount"] = (thread.message_follower_ids || []).length; + const selfFollower = this.pyEnv["mail.followers"].searchRead( + domain.concat([["partner_id", "=", this.pyEnv.currentPartnerId]]) + )[0]; + res["selfFollower"] = selfFollower + ? this._mockMailFollowers_FormatForChatter(selfFollower.id)[0] + : false; + res["followers"] = this._mockMailThreadMessageGetFollowers(thread_model, [thread_id]); } if (request_list.includes("suggestedRecipients")) { res["suggestedRecipients"] = this._mockMailThread_MessageGetSuggestedRecipients( diff --git a/addons/mail/static/tests/helpers/mock_server/models/mail_followers.js b/addons/mail/static/tests/helpers/mock_server/models/mail_followers.js new file mode 100644 index 00000000000..c6af0f7714a --- /dev/null +++ b/addons/mail/static/tests/helpers/mock_server/models/mail_followers.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ + +import { patch } from "@web/core/utils/patch"; +import { MockServer } from "@web/../tests/helpers/mock_server"; + +patch(MockServer.prototype, "mail/models/mail_followers", { + /** + * Simulates `_format_for_chatter` on `mail.followers`. + * + * @private + * @returns {integer[]} ids + * @returns {Object[]} + */ + _mockMailFollowers_FormatForChatter(ids) { + const followers = this.getRecords("mail.followers", [["id", "in", ids]]); + // sorted from lowest ID to highest ID (i.e. from least to most recent) + followers.sort((f1, f2) => (f1.id < f2.id ? -1 : 1)); + return followers.map((follower) => { + return { + id: follower.id, + partner_id: follower.partner_id, + name: follower.name, + display_name: follower.display_name, + email: follower.email, + is_active: follower.is_active, + partner: this._mockResPartnerMailPartnerFormat([follower.partner_id]).get( + follower.partner_id + ), + }; + }); + }, +}); diff --git a/addons/mail/static/tests/helpers/mock_server/models/mail_thread.js b/addons/mail/static/tests/helpers/mock_server/models/mail_thread.js index 61cb79aa0c3..b7dfc59df3c 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/mail_thread.js +++ b/addons/mail/static/tests/helpers/mock_server/models/mail_thread.js @@ -18,6 +18,12 @@ patch(MockServer.prototype, "mail/models/mail_thread", { const partner_ids = args.args[1] || args.kwargs.partner_ids; return this._mockMailThreadMessageUnsubscribe(args.model, ids, partner_ids); } + if (args.method === "message_get_followers") { + const ids = args.args[0]; + const after = args.args[1] || args.kwargs.after; + const limit = args.args[2] || args.kwargs.limit; + return this._mockMailThreadMessageGetFollowers(args.model, ids, after, limit); + } if (args.method === "message_post") { const id = args.args[0]; const kwargs = args.kwargs; @@ -113,6 +119,30 @@ patch(MockServer.prototype, "mail/models/mail_thread", { result[record.id].push([partner, email, reason]); return result; }, + /** + * Simulates `message_get_followers` on `mail.thread`. + * + * @private + * @param {string} model + * @param {integer[]} ids + * @param {integer} [after] + * @param {integer} [limit=100] + * @returns {Object[]} + */ + _mockMailThreadMessageGetFollowers(model, ids, after, limit = 100) { + const domain = [ + ["res_id", "=", ids[0]], + ["res_model", "=", model], + ]; + if (after) { + domain.push(["id", ">", after]); + } + const followers = this.getRecords("mail.followers", domain).sort( + (f1, f2) => (f1.id < f2.id ? -1 : 1) // sorted from lowest ID to highest ID (i.e. from oldest to youngest) + ); + followers.length = Math.min(followers.length, limit); + return this._mockMailFollowers_FormatForChatter(followers.map((follower) => follower.id)); + }, /** * Simulates `_message_get_suggested_recipients` on `mail.thread`. * diff --git a/addons/mail/static/tests/web/follow_button_tests.js b/addons/mail/static/tests/web/follow_button_tests.js index 6a7cfb065e8..f5dc61f59a4 100644 --- a/addons/mail/static/tests/web/follow_button_tests.js +++ b/addons/mail/static/tests/web/follow_button_tests.js @@ -17,15 +17,12 @@ QUnit.test("base rendering not editable", async (assert) => { QUnit.test("hover following button", async (assert) => { const pyEnv = await startServer(); const threadId = pyEnv["res.partner"].create({}); - const followerId = pyEnv["mail.followers"].create({ + pyEnv["mail.followers"].create({ is_active: true, partner_id: pyEnv.currentPartnerId, res_id: threadId, res_model: "res.partner", }); - pyEnv["res.partner"].write([pyEnv.currentPartnerId], { - message_follower_ids: [followerId], - }); const { openView } = await start(); await openView({ res_id: threadId, diff --git a/addons/mail/static/tests/web/follower_list_menu_tests.js b/addons/mail/static/tests/web/follower_list_menu_tests.js index 113d3b9f459..86f80708e37 100644 --- a/addons/mail/static/tests/web/follower_list_menu_tests.js +++ b/addons/mail/static/tests/web/follower_list_menu_tests.js @@ -1,6 +1,6 @@ /* @odoo-module */ -import { click, start, startServer } from "@mail/../tests/helpers/test_utils"; +import { afterNextRender, click, start, startServer } from "@mail/../tests/helpers/test_utils"; import { patchWithCleanup } from "@web/../tests/helpers/utils"; import { nextTick } from "web.test_utils"; @@ -211,6 +211,84 @@ QUnit.test( } ); +QUnit.test("Load 100 followers at once", async (assert) => { + const pyEnv = await startServer(); + const partnerIds = pyEnv["res.partner"].create( + [...Array(210).keys()].map((i) => ({ display_name: `Partner${i}`, name: `Partner${i}` })) + ); + pyEnv["mail.followers"].create( + [...Array(210).keys()].map((i) => { + return { + is_active: true, + partner_id: i === 0 ? pyEnv.currentPartnerId : partnerIds[i], + res_id: partnerIds[0], + res_model: "res.partner", + }; + }) + ); + const { openFormView } = await start(); + await openFormView("res.partner", partnerIds[0]); + assert.containsOnce($, "button[title='Show Followers']:contains(210)"); + + await click("button[title='Show Followers']"); + assert.containsOnce($, ".o-mail-Follower:contains(Mitchell Admin)"); + assert.containsN($, ".o-mail-Follower", 100); + assert.containsOnce($, ".o-mail-Followers-dropdown span:contains(Load more)"); + await afterNextRender(() => + $(".o-mail-Followers-dropdown span:contains(Load more)")[0].scrollIntoView() + ); + assert.containsN($, ".o-mail-Follower", 200); + assert.containsOnce($, ".o-mail-Followers-dropdown span:contains(Load more)"); + await afterNextRender(() => + $(".o-mail-Followers-dropdown span:contains(Load more)")[0].scrollIntoView() + ); + assert.containsN($, ".o-mail-Follower", 210); + assert.containsNone($, ".o-mail-Followers-dropdown span:contains(Load more)"); +}); + +QUnit.test("Load 100 recipients at once", async (assert) => { + const pyEnv = await startServer(); + const partnerIds = pyEnv["res.partner"].create( + [...Array(210).keys()].map((i) => ({ + display_name: `Partner${i}`, + name: `Partner${i}`, + email: `partner${i}@example.com`, + })) + ); + pyEnv["mail.followers"].create( + [...Array(210).keys()].map((i) => { + return { + is_active: true, + partner_id: i === 0 ? pyEnv.currentPartnerId : partnerIds[i], + res_id: partnerIds[0], + res_model: "res.partner", + }; + }) + ); + const { openFormView } = await start(); + await openFormView("res.partner", partnerIds[0]); + assert.containsOnce($, "button[title='Show Followers']:contains(210)"); + await click("button:contains(Send message)"); + assert.containsOnce( + $, + ".o-mail-Chatter:contains('me, partner1, partner2, partner3, partner4, …')" + ); + assert.containsOnce($, "button[title='Show all recipients']"); + await click("button[title='Show all recipients']"); + assert.containsN($, ".o-mail-RecipientList li", 100); + assert.containsOnce($, ".o-mail-RecipientList span:contains(Load more)"); + await afterNextRender(() => + $(".o-mail-RecipientList span:contains(Load more)")[0].scrollIntoView() + ); + assert.containsN($, ".o-mail-RecipientList li", 200); + assert.containsOnce($, ".o-mail-RecipientList span:contains(Load more)"); + await afterNextRender(() => + $(".o-mail-RecipientList span:contains(Load more)")[0].scrollIntoView() + ); + assert.containsN($, ".o-mail-RecipientList li", 210); + assert.containsNone($, ".o-mail-RecipientList span:contains(Load more)"); +}); + QUnit.test( 'Show "Add follower" and subtypes edition/removal buttons on all followers if user has write access', async (assert) => {