From 8dc9d129059defc1a79a984e9daf31bb3c92ce0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Mon, 10 Jul 2023 14:36:19 +0000 Subject: [PATCH] [FIX] mail: fetch followers 100 at a time Before this commit, the data of all followers of a chatter were fetched whenever `mail/thread/data` was called. This route is called when loading the chatter for the first time, and whenever the chatter must be reloaded. When there are at most dozens of followers, it's not a big deal. However, with thousand of followers, the route `mail/thread/data` may become quite long, which made chatter barely usable with a long loading. This commit fixes the issue by showing only 100 followers at once when opening the follower menu. When scrolling down the list, it loads data of more followers. Task-3293718 closes odoo/odoo#127966 Signed-off-by: Didier Debondt (did) --- addons/mail/models/mail_followers.py | 11 +++ addons/mail/models/mail_thread.py | 30 ++++--- addons/mail/static/src/core/common/message.js | 2 +- .../static/src/core/common/message_service.js | 4 +- .../static/src/core/common/navigable_list.js | 33 ++++---- .../static/src/core/common/thread_model.js | 23 +++--- addons/mail/static/src/core/web/chatter.js | 16 ++-- addons/mail/static/src/core/web/chatter.xml | 7 +- .../mail/static/src/core/web/follower_list.js | 6 ++ .../static/src/core/web/follower_list.xml | 41 +++++++--- .../static/src/core/web/recipient_list.js | 13 ++- .../static/src/core/web/recipient_list.xml | 2 + .../src/core/web/thread_service_patch.js | 37 ++++++++- .../static/tests/composer/composer_tests.js | 2 +- .../mock_server/controllers/discuss.js | 26 +++--- .../mock_server/models/mail_followers.js | 32 ++++++++ .../helpers/mock_server/models/mail_thread.js | 30 +++++++ .../static/tests/web/follow_button_tests.js | 5 +- .../tests/web/follower_list_menu_tests.js | 80 ++++++++++++++++++- 19 files changed, 313 insertions(+), 87 deletions(-) create mode 100644 addons/mail/static/tests/helpers/mock_server/models/mail_followers.js 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) => {