diff --git a/addons/bus/static/src/workers/websocket_worker.js b/addons/bus/static/src/workers/websocket_worker.js index a58d00a9ac1..b13af9565db 100644 --- a/addons/bus/static/src/workers/websocket_worker.js +++ b/addons/bus/static/src/workers/websocket_worker.js @@ -65,6 +65,7 @@ export class WebsocketWorker { this.lastNotificationId = 0; this.messageWaitQueue = []; this._forceUpdateChannels = debounce(this._forceUpdateChannels, 300); + this._updateChannels = debounce(this._updateChannels, 0); this._onWebsocketClose = this._onWebsocketClose.bind(this); this._onWebsocketError = this._onWebsocketError.bind(this); diff --git a/addons/bus/static/tests/bus_tests.js b/addons/bus/static/tests/bus_tests.js index 9f902c50191..4a80ff5531c 100644 --- a/addons/bus/static/tests/bus_tests.js +++ b/addons/bus/static/tests/bus_tests.js @@ -25,7 +25,7 @@ QUnit.test("notifications received from the channel", async () => { const pyEnv = await startServer(); const env = await makeTestEnv({ activateMockServer: true }); env.services["bus_service"].addChannel("lambda"); - await waitForChannels(["lambda"]); + await waitUntilSubscribe("lambda"); pyEnv["bus.bus"]._sendone("lambda", "notifType", "beta"); pyEnv["bus.bus"]._sendone("lambda", "notifType", "epsilon"); await waitNotifications([env, "notifType", "beta"], [env, "notifType", "epsilon"]); @@ -39,7 +39,7 @@ QUnit.test("notifications not received after stoping the service", async () => { firstTabEnv.services["bus_service"].start(); secondTabEnv.services["bus_service"].start(); firstTabEnv.services["bus_service"].addChannel("lambda"); - await waitForChannels(["lambda"]); + await waitUntilSubscribe("lambda"); // both tabs should receive the notification pyEnv["bus.bus"]._sendone("lambda", "notifType", "beta"); await waitNotifications( @@ -76,7 +76,7 @@ QUnit.test("tabs share message from a channel", async () => { mainEnv.services["bus_service"].addChannel("lambda"); const slaveEnv = await makeTestEnv(); slaveEnv.services["bus_service"].addChannel("lambda"); - await waitForChannels(["lambda"]); + await waitUntilSubscribe("lambda"); pyEnv["bus.bus"]._sendone("lambda", "notifType", "beta"); await waitNotifications([mainEnv, "notifType", "beta"], [slaveEnv, "notifType", "beta"]); }); @@ -97,7 +97,7 @@ QUnit.test("second tab still receives notifications after main pagehide", async }); const secondEnv = await makeTestEnv({ activateMockServer: true }); secondEnv.services["bus_service"].addChannel("lambda"); - await waitForChannels(["lambda"]); + await waitUntilSubscribe("lambda"); pyEnv["bus.bus"]._sendone("lambda", "notifType", "beta"); await waitNotifications([mainEnv, "notifType", "beta"], [secondEnv, "notifType", "beta"]); // simulate unloading main @@ -117,7 +117,7 @@ QUnit.test("two tabs adding a different channel", async () => { const secondTabEnv = await makeTestEnv({ activateMockServer: true }); firstTabEnv.services["bus_service"].addChannel("alpha"); secondTabEnv.services["bus_service"].addChannel("beta"); - await waitForChannels(["alpha", "beta"]); + await waitUntilSubscribe("alpha", "beta"); pyEnv["bus.bus"]._sendmany([ ["alpha", "notifType", "alpha"], ["beta", "notifType", "beta"], @@ -138,8 +138,8 @@ QUnit.test("channel management from multiple tabs", async (assert) => { super._sendToServer(...arguments); }, }); - const firstTabEnv = await makeTestEnv(); - const secTabEnv = await makeTestEnv(); + const firstTabEnv = await makeTestEnv({ activateMockServer: true }); + const secTabEnv = await makeTestEnv({ activateMockServer: true }); firstTabEnv.services["bus_service"].addChannel("channel1"); await waitForChannels(["channel1"]); // this should not trigger a subscription since the channel1 was @@ -154,7 +154,7 @@ QUnit.test("channel management from multiple tabs", async (assert) => { // this should trigger a subscription since the channel2 was not // known. secTabEnv.services["bus_service"].addChannel("channel2"); - await waitForChannels(["channel2"]); + await waitUntilSubscribe("channel2"); assert.verifySteps(["subscribe - [channel1]", "subscribe - [channel1,channel2]"]); }); @@ -188,7 +188,7 @@ QUnit.test("Last notification id is passed to the worker on service start", asyn const env1 = await makeTestEnv(); env1.services["bus_service"].start(); env1.services["bus_service"].addChannel("lambda"); - await waitForChannels(["lambda"]); + await waitUntilSubscribe("lambda"); await updateLastNotificationDeferred; // First bus service has never received notifications thus the // default is 0. diff --git a/addons/im_livechat/static/src/views/discuss_channel_list/discuss_channel_list_view_controller.js b/addons/im_livechat/static/src/views/discuss_channel_list/discuss_channel_list_view_controller.js index 783c2a4dbd1..6e94ef2e731 100644 --- a/addons/im_livechat/static/src/views/discuss_channel_list/discuss_channel_list_view_controller.js +++ b/addons/im_livechat/static/src/views/discuss_channel_list/discuss_channel_list_view_controller.js @@ -1,20 +1,36 @@ /* @odoo-module */ -import { _t } from "@web/core/l10n/translation"; +import { useState } from "@odoo/owl"; + import { ListController } from "@web/views/list/list_controller"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; export class DiscussChannelListController extends ListController { setup() { super.setup(...arguments); + this.threadService = useService("mail.thread"); + this.store = useState(useService("mail.store")); + this.ui = useState(useService("ui")); } async openRecord(record) { - if (!record.data.is_member) { - return super.openRecord(record); + if (!this.ui.isSmall) { + return this.actionService.doAction("mail.action_discuss", { + name: _t("Discuss"), + additionalContext: { active_id: record.resId }, + }); } - this.actionService.doAction("mail.action_discuss", { - name: _t("Discuss"), - additionalContext: { active_id: record.resId }, + let thread = this.store.Thread.get({ + model: "discuss.channel", + id: record.resId, }); + if (!thread?.type) { + thread = await this.threadService.fetchChannel(record.resId); + } + if (thread) { + return this.threadService.open(thread); + } + return super.openRecord(record); } } diff --git a/addons/mail/controllers/discuss/channel.py b/addons/mail/controllers/discuss/channel.py index 4cf6d3b34b0..3674b5fb520 100644 --- a/addons/mail/controllers/discuss/channel.py +++ b/addons/mail/controllers/discuss/channel.py @@ -20,7 +20,7 @@ class ChannelController(http.Controller): @http.route("/discuss/channel/update_avatar", methods=["POST"], type="json") def discuss_channel_avatar_update(self, channel_id, data): - channel = request.env["discuss.channel"].browse(int(channel_id)).exists() + channel = request.env["discuss.channel"].search([("id", "=", channel_id)]) if not channel or not data: raise NotFound() channel.write({"image_128": data}) @@ -64,7 +64,10 @@ class ChannelController(http.Controller): """Mute notifications for the given number of minutes. :param minutes: (integer) number of minutes to mute notifications, -1 means mute until the user unmutes """ - member = request.env["discuss.channel.member"].search([("channel_id", "=", channel_id), ("is_self", "=", True)]) + channel = request.env["discuss.channel"].search([("id", "=", channel_id)]) + if not channel: + raise request.not_found() + member = channel._find_or_create_member_for_self() if not member: raise request.not_found() if minutes == -1: @@ -83,7 +86,10 @@ class ChannelController(http.Controller): @http.route("/discuss/channel/update_custom_notifications", methods=["POST"], type="json", auth="user") def discuss_channel_update_custom_notifications(self, channel_id, custom_notifications): - member = request.env["discuss.channel.member"].search([("channel_id", "=", channel_id), ("is_self", "=", True)]) + channel = request.env["discuss.channel"].search([("id", "=", channel_id)]) + if not channel: + raise request.not_found() + member = channel._find_or_create_member_for_self() if not member: raise request.not_found() member.custom_notifications = custom_notifications @@ -105,7 +111,10 @@ class ChannelController(http.Controller): @http.route("/discuss/channel/notify_typing", methods=["POST"], type="json", auth="public") @add_guest_to_context def discuss_channel_notify_typing(self, channel_id, is_typing): - member = request.env["discuss.channel.member"].search([("channel_id", "=", channel_id), ("is_self", "=", True)]) + channel = request.env["discuss.channel"].search([("id", "=", channel_id)]) + if not channel: + raise request.not_found() + member = channel._find_or_create_member_for_self() if not member: raise NotFound() member._notify_typing(is_typing) diff --git a/addons/mail/controllers/discuss/rtc.py b/addons/mail/controllers/discuss/rtc.py index 7dcf76bb2ff..f8ec052bb70 100644 --- a/addons/mail/controllers/discuss/rtc.py +++ b/addons/mail/controllers/discuss/rtc.py @@ -64,7 +64,10 @@ class RtcController(http.Controller): """Joins the RTC call of a channel if the user is a member of that channel :param int channel_id: id of the channel to join """ - member = request.env["discuss.channel.member"].search([("channel_id", "=", channel_id), ("is_self", "=", True)]) + channel = request.env["discuss.channel"].search([("id", "=", channel_id)]) + if not channel: + raise request.not_found() + member = channel._find_or_create_member_for_self() if not member: raise NotFound() # sudo: discuss.channel.rtc.session - member of current user can join call @@ -89,11 +92,11 @@ class RtcController(http.Controller): :param member_ids: members whose invitation is to cancel :type member_ids: list(int) or None """ - member = request.env["discuss.channel.member"].search([("channel_id", "=", channel_id), ("is_self", "=", True)]) - if not member: + channel = request.env["discuss.channel"].search([("id", "=", channel_id)]) + if not channel: raise NotFound() - # sudo: discuss.channel.rtc.session - member of current user can cancel invitations - return member.sudo().channel_id._rtc_cancel_invitations(member_ids=member_ids) + # sudo: discuss.channel.rtc.session - can cancel invitations in accessible channel + return channel.sudo()._rtc_cancel_invitations(member_ids=member_ids) @http.route("/mail/rtc/audio_worklet_processor", methods=["GET"], type="http", auth="public") def audio_worklet_processor(self): diff --git a/addons/mail/models/discuss/discuss_channel.py b/addons/mail/models/discuss/discuss_channel.py index 9a8d88ce6b6..f5e5f91eaed 100644 --- a/addons/mail/models/discuss/discuss_channel.py +++ b/addons/mail/models/discuss/discuss_channel.py @@ -345,6 +345,7 @@ class Channel(models.Model): partners = self.env['res.partner'].browse(partner_ids or []).exists() guests = self.env['mail.guest'].browse(guest_ids or []).exists() notifications = [] + all_new_members = self.env["discuss.channel.member"] for channel in self: members_to_create = [] existing_members = self.env['discuss.channel.member'].search(expression.AND([ @@ -363,6 +364,7 @@ class Channel(models.Model): 'channel_id': channel.id, } for guest in guests - existing_members.guest_id] new_members = self.env['discuss.channel.member'].create(members_to_create) + all_new_members += new_members for member in new_members.filtered(lambda member: member.partner_id): # notify invited members through the bus user = member.partner_id.user_ids[0] if member.partner_id.user_ids else self.env['res.users'] @@ -416,6 +418,7 @@ class Channel(models.Model): # sudo: discuss.channel.rtc.session - current user can invite new members in call current_channel_member.sudo()._rtc_invite_members(member_ids=new_members.ids) self.env['bus.bus']._sendmany(notifications) + return all_new_members # ------------------------------------------------------------ # RTC @@ -690,6 +693,19 @@ class Channel(models.Model): } self.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment") + def _find_or_create_member_for_self(self): + self.ensure_one() + domain = [("channel_id", "=", self.id), ("is_self", "=", True)] + member = self.env["discuss.channel.member"].search(domain) + if member: + return member + if not self.env.user._is_public(): + return self.add_members(partner_ids=self.env.user.partner_id.ids) + guest = self.env["mail.guest"]._get_guest_from_context() + if guest: + return self.add_members(guest_ids=guest.ids) + return self.env["discuss.channel.member"] + def _find_or_create_persona_for_channel(self, guest_name, timezone, country_code, post_joined_message=True): """ :param channel: channel to add the persona to diff --git a/addons/mail/models/discuss/ir_websocket.py b/addons/mail/models/discuss/ir_websocket.py index fb82456657b..8cac84da2f2 100644 --- a/addons/mail/models/discuss/ir_websocket.py +++ b/addons/mail/models/discuss/ir_websocket.py @@ -1,5 +1,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +import re + from odoo import models from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context @@ -22,16 +24,23 @@ class IrWebsocket(models.AbstractModel): @add_guest_to_context def _build_bus_channel_list(self, channels): channels = list(channels) # do not alter original list + discuss_channel_ids = list() for channel in list(channels): if isinstance(channel, str) and channel.startswith("mail.guest_"): channels.remove(channel) guest = self.env["mail.guest"]._get_guest_from_token(channel.split("_")[1]) if guest: self = self.with_context(guest=guest) + if isinstance(channel, str): + match = re.findall(r'discuss\.channel_(\d+)', channel) + if match: + channels.remove(channel) + discuss_channel_ids.append(int(match[0])) guest = self.env["mail.guest"]._get_guest_from_context() if guest: channels.append(guest) - channels.extend(self.env["discuss.channel"].search([("is_member", "=", True)])) + domain = ["|", ("is_member", "=", True), ("id", "in", discuss_channel_ids)] + channels.extend(self.env["discuss.channel"].search(domain)) return super()._build_bus_channel_list(channels) @add_guest_to_context diff --git a/addons/mail/static/src/core/common/chat_window_model.js b/addons/mail/static/src/core/common/chat_window_model.js index d734daff80f..ec9e145bb63 100644 --- a/addons/mail/static/src/core/common/chat_window_model.js +++ b/addons/mail/static/src/core/common/chat_window_model.js @@ -67,6 +67,7 @@ export class ChatWindow extends Record { autofocus = 0; folded = false; hidden = false; + openMessagingMenuOnClose = false; get displayName() { return this.thread?.displayName ?? _t("New message"); diff --git a/addons/mail/static/src/core/common/chat_window_service.js b/addons/mail/static/src/core/common/chat_window_service.js index d2c33f53782..fea246b6939 100644 --- a/addons/mail/static/src/core/common/chat_window_service.js +++ b/addons/mail/static/src/core/common/chat_window_service.js @@ -1,5 +1,7 @@ /* @odoo-module */ +import { assignDefined } from "@mail/utils/common/misc"; + import { browser } from "@web/core/browser/browser"; import { registry } from "@web/core/registry"; @@ -41,12 +43,12 @@ export class ChatWindowService { return chatWindow; } - openNewMessage() { + openNewMessage({ openMessagingMenuOnClose } = {}) { if (this.store.discuss.chatWindows.some(({ thread }) => !thread)) { // New message chat window is already opened. return; } - this.store.ChatWindow.insert(); + this.store.ChatWindow.insert(assignDefined({}, { openMessagingMenuOnClose })); } closeNewMessage() { diff --git a/addons/mail/static/src/core/common/store_service.js b/addons/mail/static/src/core/common/store_service.js index 016938b3db4..c2b7bfd8f18 100644 --- a/addons/mail/static/src/core/common/store_service.js +++ b/addons/mail/static/src/core/common/store_service.js @@ -207,8 +207,11 @@ export class Store extends BaseStore { const ids = Object.keys(this.Thread.records).sort(); // Ensure channels processed in same order. for (const id of ids) { const thread = this.Thread.records[id]; - if (thread.model === "discuss.channel" && thread.hasSelfAsMember) { + if (thread.model === "discuss.channel") { channelIds.push(id); + if (!thread.hasSelfAsMember) { + this.env.services["bus_service"].addChannel(`discuss.channel_${thread.id}`); + } } } const channels = JSON.stringify(channelIds); diff --git a/addons/mail/static/src/core/common/thread_model.js b/addons/mail/static/src/core/common/thread_model.js index edccebaa061..91890ad1c50 100644 --- a/addons/mail/static/src/core/common/thread_model.js +++ b/addons/mail/static/src/core/common/thread_model.js @@ -281,6 +281,8 @@ export class Thread extends Record { mute_until_dt; /** @type {Boolean} */ isLocallyPinned = false; + /** @type {"not_fetched"|"pending"|"fetched"} */ + fetchMembersState = "not_fetched"; get accessRestrictedToGroupText() { if (!this.authorizedGroupFullName) { diff --git a/addons/mail/static/src/core/common/thread_service.js b/addons/mail/static/src/core/common/thread_service.js index e6d9aef1eee..568fbaa274c 100644 --- a/addons/mail/static/src/core/common/thread_service.js +++ b/addons/mail/static/src/core/common/thread_service.js @@ -61,11 +61,23 @@ export class ThreadService { } async fetchChannelMembers(thread) { + if (thread.fetchMembersState === "pending") { + return; + } + const previousState = thread.fetchMembersState; + thread.fetchMembersState = "pending"; const known_member_ids = thread.channelMembers.map((channelMember) => channelMember.id); - const results = await this.rpc("/discuss/channel/members", { - channel_id: thread.id, - known_member_ids: known_member_ids, - }); + let results; + try { + results = await this.rpc("/discuss/channel/members", { + channel_id: thread.id, + known_member_ids: known_member_ids, + }); + } catch (e) { + thread.fetchMembersState = previousState; + throw e; + } + thread.fetchMembersState = "fetched"; let channelMembers = []; if ( results["channelMembers"] && @@ -438,8 +450,9 @@ export class ThreadService { /** * @param {import("models").Thread} thread * @param {boolean} replaceNewMessageChatWindow + * @param {Object} [options] */ - open(thread, replaceNewMessageChatWindow) { + open(thread, replaceNewMessageChatWindow, options) { this.setDiscussThread(thread); } diff --git a/addons/mail/static/src/core/web/chat_window_service_patch.js b/addons/mail/static/src/core/web/chat_window_service_patch.js index f6cbfe27e70..45ad1450e18 100644 --- a/addons/mail/static/src/core/web/chat_window_service_patch.js +++ b/addons/mail/static/src/core/web/chat_window_service_patch.js @@ -6,7 +6,11 @@ import { patch } from "@web/core/utils/patch"; patch(ChatWindowService.prototype, { async _onClose(chatWindow, options) { - if (this.ui.isSmall && !this.store.discuss.isActive) { + if ( + this.ui.isSmall && + !this.store.discuss.isActive && + chatWindow.openMessagingMenuOnClose + ) { // If we are in mobile and discuss is not open, it means the // chat window was opened from the messaging menu. In that // case it should be re-opened to simulate it was always diff --git a/addons/mail/static/src/core/web/messaging_menu.js b/addons/mail/static/src/core/web/messaging_menu.js index e2b9dd9760d..fdea7e35ab2 100644 --- a/addons/mail/static/src/core/web/messaging_menu.js +++ b/addons/mail/static/src/core/web/messaging_menu.js @@ -152,7 +152,7 @@ export class MessagingMenu extends Component { } openDiscussion(thread) { - this.threadService.open(thread); + this.threadService.open(thread, undefined, { openMessagingMenuOnClose: true }); this.close(); } @@ -160,7 +160,7 @@ export class MessagingMenu extends Component { if (this.ui.isSmall || this.env.inDiscussApp) { this.state.addingChat = true; } else { - this.chatWindowService.openNewMessage(); + this.chatWindowService.openNewMessage({ openMessagingMenuOnClose: true }); this.close(); } } @@ -191,7 +191,7 @@ export class MessagingMenu extends Component { // and the chat window does not look good. this.store.discuss.chatWindows.find(({ thr }) => thr?.eq(thread))?.close(); } else { - this.threadService.open(thread); + this.threadService.open(thread, undefined, { openMessagingMenuOnClose: true }); } this.close(); } 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 67a4b26a8d9..d87018ab218 100644 --- a/addons/mail/static/src/core/web/thread_service_patch.js +++ b/addons/mail/static/src/core/web/thread_service_patch.js @@ -6,7 +6,7 @@ import { parseEmail } from "@mail/js/utils"; import { _t } from "@web/core/l10n/translation"; import { patch } from "@web/core/utils/patch"; import { Record } from "@mail/core/common/record"; -import { compareDatetime } from "@mail/utils/common/misc"; +import { assignDefined, compareDatetime } from "@mail/utils/common/misc"; let nextId = 1; @@ -186,13 +186,14 @@ patch(ThreadService.prototype, { } }); }, - open(thread, replaceNewMessageChatWindow) { + /** @override */ + open(thread, replaceNewMessageChatWindow, options) { if (!this.store.discuss.isActive && !this.ui.isSmall) { - this._openChatWindow(thread, replaceNewMessageChatWindow); + this._openChatWindow(thread, replaceNewMessageChatWindow, options); return; } if (this.ui.isSmall && thread.model === "discuss.channel") { - this._openChatWindow(thread, replaceNewMessageChatWindow); + this._openChatWindow(thread, replaceNewMessageChatWindow, options); return; } if (thread.model !== "discuss.channel") { @@ -227,12 +228,19 @@ patch(ThreadService.prototype, { } super.unpin(...arguments); }, - _openChatWindow(thread, replaceNewMessageChatWindow) { - const chatWindow = this.store.ChatWindow.insert({ - folded: false, - thread, - replaceNewMessageChatWindow, - }); + _openChatWindow(thread, replaceNewMessageChatWindow, { openMessagingMenuOnClose } = {}) { + const chatWindow = this.store.ChatWindow.insert( + assignDefined( + { + folded: false, + replaceNewMessageChatWindow, + thread, + }, + { + openMessagingMenuOnClose, + } + ) + ); chatWindow.autofocus++; if (thread) { thread.state = "open"; diff --git a/addons/mail/static/src/discuss/core/common/channel_member_list.js b/addons/mail/static/src/discuss/core/common/channel_member_list.js index cfca9e26a11..68556fc5f37 100644 --- a/addons/mail/static/src/discuss/core/common/channel_member_list.js +++ b/addons/mail/static/src/discuss/core/common/channel_member_list.js @@ -17,9 +17,13 @@ export class ChannelMemberList extends Component { this.store = useState(useService("mail.store")); this.channelMemberService = useService("discuss.channel.member"); this.threadService = useState(useService("mail.thread")); - onWillStart(() => this.threadService.fetchChannelMembers(this.props.thread)); + onWillStart(() => { + if (this.props.thread.fetchMembersState === "not_fetched") { + this.threadService.fetchChannelMembers(this.props.thread); + } + }); onWillUpdateProps((nextProps) => { - if (nextProps.thread.channelMembers.length === 0) { + if (nextProps.thread.fetchMembersState === "not_fetched") { this.threadService.fetchChannelMembers(nextProps.thread); } });