[FIX] bus, mail, im_livechat: open discuss from livechat history

The kanban view used to display message history of livechats sessions is
replaced by Discuss for a smooth and consistent user experience.

Minimum viable version for Task-3497801

closes odoo/odoo#146800

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Killian Frappart
2023-12-29 18:33:23 +00:00
committed by Sébastien Theys
parent 9351b2a5eb
commit bc0b090cd6
16 changed files with 140 additions and 49 deletions
@@ -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);
+9 -9
View File
@@ -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.
@@ -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);
}
}
+13 -4
View File
@@ -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)
+8 -5
View File
@@ -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):
@@ -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
+10 -1
View File
@@ -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
@@ -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");
@@ -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() {
@@ -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);
@@ -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) {
@@ -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);
}
@@ -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
@@ -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();
}
@@ -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";
@@ -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);
}
});