From 697cee6caf5bba5e558ef1a1f0b919b88d79125f Mon Sep 17 00:00:00 2001 From: Vitalijus Gavinas <79800450+vgavinas@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:15:16 +0000 Subject: [PATCH 1/3] [FIX] mail_gateway: keep thread action conditions synchronous The conditions of the "New Partner" and "Open Contact" thread actions started an async searchRead and then re-rendered the owner. Conditions are evaluated on every render, so this re-rendered DiscussContent while it was still mounting and the Owl fiber never completed: Discuss showed no messages in any channel. The gateway partner and the single guest of the channel are now sent with the channel store data, and the conditions only read them. Fixes #319 Assisted-by: Claude Opus 5.5 --- mail_gateway/models/discuss_channel.py | 28 +++++ .../src/core/common/thread_actions.esm.js | 118 +++++++----------- .../src/core/common/thread_model_patch.esm.js | 7 +- mail_gateway/tests/test_mail_gateway.py | 33 +++++ 4 files changed, 110 insertions(+), 76 deletions(-) diff --git a/mail_gateway/models/discuss_channel.py b/mail_gateway/models/discuss_channel.py index 5d1381058..5a8d19025 100644 --- a/mail_gateway/models/discuss_channel.py +++ b/mail_gateway/models/discuss_channel.py @@ -39,10 +39,38 @@ def _to_store(self, store: Store, fields): "id": record.id, "gateway": record.gateway_id._gateway_info(), "gateway_id": record.id, + "gateway_partner_id": record._get_gateway_partner().id, + "gateway_guest_id": record._get_gateway_guest().id, }, ) return res + def _get_gateway_partner(self): + """Partner linked to the gateway token of the channel, if any.""" + self.ensure_one() + if not self.gateway_id or not self.gateway_channel_token: + return self.env["res.partner"] + # sudo: res.partner.gateway.channel - technical lookup by token + return ( + self.env["res.partner.gateway.channel"] + .sudo() + .search( + [ + ("gateway_id", "=", self.gateway_id.id), + ("gateway_token", "=", self.gateway_channel_token), + ], + limit=1, + ) + .partner_id + ) + + def _get_gateway_guest(self): + """Guest of the channel when there is exactly one (sender not yet + linked to a partner), used by the "New Partner" action.""" + self.ensure_one() + guests = self.channel_member_ids.guest_id + return guests if len(guests) == 1 else self.env["mail.guest"] + def _generate_avatar_gateway(self): # We will use this function to set a default avatar on each module return False diff --git a/mail_gateway/static/src/core/common/thread_actions.esm.js b/mail_gateway/static/src/core/common/thread_actions.esm.js index 48c43b74c..3152a9c84 100644 --- a/mail_gateway/static/src/core/common/thread_actions.esm.js +++ b/mail_gateway/static/src/core/common/thread_actions.esm.js @@ -1,97 +1,65 @@ import {_t} from "@web/core/l10n/translation"; import {threadActionsRegistry} from "@mail/core/common/thread_actions"; +/* + * Conditions are evaluated on every render: they must stay synchronous and free + * of side effects. Starting an RPC and re-rendering the owner from here keeps + * the Discuss fiber from completing (no messages are shown). The data they need + * (gateway partner and guest) comes with the channel store data. + */ +function isVisibleChannel(owner, thread) { + return ( + thread?.model === "discuss.channel" && + (!owner.props.chatWindow || owner.props.chatWindow.isOpen) + ); +} + +function getProfilePartnerId(thread) { + if (thread.channel_type === "gateway") { + return thread.gateway_partner_id || false; + } + return thread.correspondent?.partner_id?.id || false; +} + threadActionsRegistry .add("open-gw-new-partner", { - condition({owner, thread}) { - if ( - thread?.model !== "discuss.channel" || - (owner.props.chatWindow && !owner.props.chatWindow.isOpen) - ) { - return false; - } - if (thread._guestChecked) { - return Boolean(thread._cachedGuestId); - } - const orm = owner.env.services.orm; - (async () => { - const members = await orm.silent.searchRead( - "discuss.channel.member", - [["channel_id", "=", thread.id]], - ["guest_id"] - ); - const guestMembers = members.filter((m) => m.guest_id); - if (guestMembers.length === 1) { - thread._cachedGuestId = guestMembers[0].guest_id; - } else { - thread._cachedGuestId = null; - } - thread._guestChecked = true; - owner.render?.(); - })(); - return false; - }, + condition: ({owner, thread}) => + isVisibleChannel(owner, thread) && Boolean(thread.gateway_guest_id), icon: "fa fa-fw fa-address-book", name: _t("New Partner"), async open({owner, thread}) { - const guestId = thread._cachedGuestId; - if (!guestId) return; - await owner.env.services.action.doAction({ - type: "ir.actions.act_window", - res_model: "mail.guest.manage", - context: {default_guest_id: guestId[0]}, - views: [[false, "form"]], - target: "new", - }); + const guestId = thread.gateway_guest_id; + if (!guestId) { + return; + } + await owner.env.services.action.doAction( + { + type: "ir.actions.act_window", + res_model: "mail.guest.manage", + context: {default_guest_id: guestId}, + views: [[false, "form"]], + target: "new", + }, + { + // Refresh the gateway partner/guest once the guest is managed + onClose: () => + thread.store.fetchStoreData("discuss.channel", [thread.id]), + } + ); }, iconLarge: "fa fa-fw fa-lg fa-address-book", sequence: 18, }) .add("open-gw-profile", { - condition({owner, thread}) { - if ( - thread?.model !== "discuss.channel" || - (owner.props.chatWindow && !owner.props.chatWindow.isOpen) - ) { - return false; - } - if (thread._partnerIdChecked) { - return Boolean(thread._cachedPartnerId); - } - const orm = owner.env.services.orm; - (async () => { - const channelData = await orm.silent.searchRead( - "discuss.channel", - [["id", "=", thread.id]], - ["gateway_channel_token"] - ); - if (channelData[0]?.gateway_channel_token) { - const gatewayChannel = await orm.silent.searchRead( - "res.partner.gateway.channel", - [["gateway_token", "=", channelData[0].gateway_channel_token]], - ["partner_id"] - ); - thread._cachedPartnerId = Array.isArray( - gatewayChannel[0]?.partner_id - ) - ? gatewayChannel[0].partner_id[0] - : gatewayChannel[0]?.partner_id; - } else { - thread._cachedPartnerId = thread.correspondent?.persona?.id; - } - thread._partnerIdChecked = true; - owner.render?.(); - })(); - return false; - }, + condition: ({owner, thread}) => + isVisibleChannel(owner, thread) && Boolean(getProfilePartnerId(thread)), icon: "fa fa-fw fa-user-circle-o", name: _t("Open Contact"), async open({owner, thread}) { - const partnerId = thread._cachedPartnerId; await owner.env.services.action.doAction({ type: "ir.actions.act_window", res_model: "res.partner", - res_id: partnerId, + res_id: getProfilePartnerId(thread), views: [[false, "form"]], }); }, diff --git a/mail_gateway/static/src/core/common/thread_model_patch.esm.js b/mail_gateway/static/src/core/common/thread_model_patch.esm.js index 8787b07cb..2c4049fdf 100644 --- a/mail_gateway/static/src/core/common/thread_model_patch.esm.js +++ b/mail_gateway/static/src/core/common/thread_model_patch.esm.js @@ -8,7 +8,12 @@ patch(Thread, { _insert(data) { const thread = super._insert(...arguments); if (thread.channel_type === "gateway") { - assignIn(thread, data, ["anonymous_name", "gateway"]); + assignIn(thread, data, [ + "anonymous_name", + "gateway", + "gateway_partner_id", + "gateway_guest_id", + ]); } return thread; }, diff --git a/mail_gateway/tests/test_mail_gateway.py b/mail_gateway/tests/test_mail_gateway.py index a54fe8782..0e4e457bd 100644 --- a/mail_gateway/tests/test_mail_gateway.py +++ b/mail_gateway/tests/test_mail_gateway.py @@ -480,6 +480,39 @@ def test_discuss_channel_to_store(self): ["gateway_id", "gateway_token", "gateway_message_ids", "channel_type"], ) + def test_discuss_channel_to_store_gateway_partner(self): + impl = self.env["mail.gateway.abstract"] + channel = impl._get_channel( + self.gateway, "ch-partner-tok", {}, force_create=True + ) + self.assertFalse(channel._get_gateway_partner()) + self.env["res.partner.gateway.channel"].create( + { + "partner_id": self.partner.id, + "gateway_id": self.gateway.id, + "gateway_token": "ch-partner-tok", + } + ) + self.assertEqual(channel._get_gateway_partner(), self.partner) + store = Store() + channel._to_store(store, ["channel_type"]) + values = [ + value + for value in store.get_result()["discuss.channel"] + if value["id"] == channel.id + ] + self.assertTrue( + any(value.get("gateway_partner_id") == self.partner.id for value in values) + ) + + def test_get_gateway_guest(self): + impl = self.env["mail.gateway.abstract"] + channel = impl._get_channel(self.gateway, "ch-guest-tok", {}, force_create=True) + self.assertFalse(channel._get_gateway_guest()) + guest = self.env["mail.guest"].create({"name": "GW Guest"}) + channel._add_members(guests=guest) + self.assertEqual(channel._get_gateway_guest(), guest) + # ── mail.thread additional coverage ─────────────────────────────────────── def test_get_notify_valid_parameters(self): From d708e607b3268399b4163533036806e028ccd05a Mon Sep 17 00:00:00 2001 From: Vitalijus Gavinas <79800450+vgavinas@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:02:39 +0000 Subject: [PATCH 2/3] [FIX] mail_gateway: batch the gateway partner lookup in _to_store _to_store runs for every channel of the user on Discuss load: search res.partner.gateway.channel once for the whole recordset instead of once per gateway channel. --- mail_gateway/models/discuss_channel.py | 37 ++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/mail_gateway/models/discuss_channel.py b/mail_gateway/models/discuss_channel.py index 5a8d19025..e1fc91306 100644 --- a/mail_gateway/models/discuss_channel.py +++ b/mail_gateway/models/discuss_channel.py @@ -30,17 +30,44 @@ class DiscussChannel(models.Model): def _to_store(self, store: Store, fields): res = super()._to_store(store, fields) - for record in self.filtered("gateway_id"): - if not record: - continue + gw_channels = self.filtered("gateway_id") + if not gw_channels: + return res + # sudo: res.partner.gateway.channel - technical lookup by token, done + # once for the whole recordset (this runs for every channel on load) + partner_by_key = {} + for gw_channel in ( + self.env["res.partner.gateway.channel"] + .sudo() + .search( + [ + ("gateway_id", "in", gw_channels.gateway_id.ids), + ( + "gateway_token", + "in", + [t for t in gw_channels.mapped("gateway_channel_token") if t], + ), + ] + ) + ): + partner_by_key.setdefault( + (gw_channel.gateway_id.id, gw_channel.gateway_token), + gw_channel.partner_id, + ) + for record in gw_channels: + guests = record.channel_member_ids.guest_id + partner = partner_by_key.get( + (record.gateway_id.id, record.gateway_channel_token), + self.env["res.partner"], + ) store.add_model_values( self._name, { "id": record.id, "gateway": record.gateway_id._gateway_info(), "gateway_id": record.id, - "gateway_partner_id": record._get_gateway_partner().id, - "gateway_guest_id": record._get_gateway_guest().id, + "gateway_partner_id": partner.id, + "gateway_guest_id": guests.id if len(guests) == 1 else False, }, ) return res From 96f2134e231a4ba809f0e431ee4c40e343f77397 Mon Sep 17 00:00:00 2001 From: Vitalijus Gavinas <79800450+vgavinas@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:46:45 +0000 Subject: [PATCH 3/3] [FIX] mail_gateway: use router.stateToUrl stateToUrl is not exported by @web/core/browser/router in 19.0 (only through the router object), so building the URL of a gateway message failed as soon as such a message was rendered. It went unnoticed while Discuss did not render any message. --- .../static/src/core/common/message_model_patch.esm.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mail_gateway/static/src/core/common/message_model_patch.esm.js b/mail_gateway/static/src/core/common/message_model_patch.esm.js index 1866de703..18ea60a09 100644 --- a/mail_gateway/static/src/core/common/message_model_patch.esm.js +++ b/mail_gateway/static/src/core/common/message_model_patch.esm.js @@ -1,6 +1,6 @@ import {Message} from "@mail/core/common/message_model"; import {patch} from "@web/core/utils/patch"; -import {stateToUrl} from "@web/core/browser/router"; +import {router} from "@web/core/browser/router"; import {url} from "@web/core/utils/urls"; patch(Message.prototype, { @@ -13,7 +13,7 @@ patch(Message.prototype, { return super.resUrl; } return url( - stateToUrl({ + router.stateToUrl({ model: this.gateway_thread_data.model, resId: this.gateway_thread_data.id, })