diff --git a/addons/im_livechat/tests/test_message.py b/addons/im_livechat/tests/test_message.py index d1b44d340a4..16dd79de8e4 100644 --- a/addons/im_livechat/tests/test_message.py +++ b/addons/im_livechat/tests/test_message.py @@ -64,6 +64,7 @@ class TestImLivechatMessage(TransactionCase): 'module_icon': '/mail/static/description/icon.png', 'needaction_partner_ids': [], 'notifications': [], + 'pinned_at': False, 'rating': { 'id': record_rating.id, 'ratingImageUrl': record_rating.rating_image_url, diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py index 64a4231e08d..182786efcf3 100644 --- a/addons/mail/controllers/discuss.py +++ b/addons/mail/controllers/discuss.py @@ -213,8 +213,8 @@ class DiscussController(http.Controller): # -------------------------------------------------------------------------- @http.route('/mail/inbox/messages', methods=['POST'], type='json', auth='user') - def discuss_inbox_messages(self, before=None, after=None, limit=30, **kwargs): - return request.env['mail.message']._message_fetch(domain=[('needaction', '=', True)], before=before, after=after, limit=limit).message_format() + def discuss_inbox_messages(self, before=None, after=None, limit=30, around=None, **kwargs): + return request.env['mail.message']._message_fetch(domain=[('needaction', '=', True)], before=before, after=after, around=around, limit=limit).message_format() @http.route('/mail/history/messages', methods=['POST'], type='json', auth='user') def discuss_history_messages(self, before=None, after=None, limit=30, **kwargs): @@ -406,16 +406,22 @@ class DiscussController(http.Controller): raise NotFound() @http.route('/mail/channel/messages', methods=['POST'], type='json', auth='public') - def mail_channel_messages(self, channel_id, before=None, after=None, limit=30, **kwargs): + def mail_channel_messages(self, channel_id, before=None, after=None, limit=30, around=None, **kwargs): channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) - messages = channel_member_sudo.env['mail.message']._message_fetch(domain=[ + domain = [ ('res_id', '=', channel_id), ('model', '=', 'mail.channel'), ('message_type', '!=', 'user_notification'), - ], before=before, after=after, limit=limit) - if not request.env.user._is_public(): + ] + messages = channel_member_sudo.env['mail.message']._message_fetch(domain=domain, before=before, after=after, around=around, limit=limit) + if not request.env.user._is_public() and not around: messages.set_message_done() - return messages.message_format() + return messages.sorted('id', reverse=True).message_format() + + @http.route('/mail/channel/pinned_messages', methods=['POST'], type='json', auth='public') + def mail_channel_pins(self, channel_id, **kwargs): + channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id)) + return channel_member_sudo.channel_id.pinned_message_ids.sorted(key='pinned_at', reverse=True).message_format() @http.route('/mail/channel/set_last_seen_message', methods=['POST'], type='json', auth='public') def mail_channel_mark_as_seen(self, channel_id, last_message_id, allow_older=False, **kwargs): @@ -451,12 +457,12 @@ class DiscussController(http.Controller): return thread._get_mail_thread_data(request_list) @http.route('/mail/thread/messages', methods=['POST'], type='json', auth='user') - def mail_thread_messages(self, thread_model, thread_id, before=None, after=None, limit=30, **kwargs): + def mail_thread_messages(self, thread_model, thread_id, before=None, after=None, around=None, limit=30, **kwargs): messages = request.env['mail.message']._message_fetch(domain=[ ('res_id', '=', int(thread_id)), ('model', '=', thread_model), ('message_type', '!=', 'user_notification'), - ], before=before, after=after, limit=limit) + ], before=before, after=after, around=around, limit=limit) if not request.env.user._is_public(): messages.set_message_done() return messages.message_format() diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 38cb666a8d0..a8a66035d45 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -6,6 +6,7 @@ import logging from collections import defaultdict from hashlib import sha512 from secrets import choice +from markupsafe import Markup from odoo import _, api, fields, models, tools, Command from odoo.addons.base.models.avatar_mixin import get_hsl_from_seed @@ -65,6 +66,7 @@ class Channel(models.Model): channel_member_ids = fields.One2many( 'mail.channel.member', 'channel_id', string='Members', groups='base.group_user') + pinned_message_ids = fields.One2many('mail.message', 'res_id', domain=lambda self: [('model', '=', 'mail.channel'), ('pinned_at', '!=', False)], string='Pinned Messages') rtc_session_ids = fields.One2many('mail.channel.rtc.session', 'channel_id', groups="base.group_system") is_member = fields.Boolean('Is Member', compute='_compute_is_member', search='_search_is_member') member_count = fields.Integer(string="Member Count", compute='_compute_member_count', compute_sudo=True) @@ -744,6 +746,46 @@ class Channel(models.Model): # - when a message is posted on a channel (to the channel, using _notify() method) # ------------------------------------------------------------ + def set_message_pin(self, message_id, pinned): + """ (Un)pin a message on the channel and send a notification to the + members. + :param message_id: id of the message to be pinned. + :param pinned: whether the message should be pinned or unpinned. + """ + self.ensure_one() + message_to_update = self.env['mail.message'].search([ + ['id', '=', message_id], + ['model', '=', 'mail.channel'], + ['res_id', '=', self.id], + ['pinned_at', '=' if pinned else '!=', False] + ]) + if not message_to_update: + return + message_to_update.write({'pinned_at': fields.datetime.now() if pinned else False}) + self.env['bus.bus']._sendone(self, 'mail.record/insert', { + 'Message': { + 'id': message_id, + 'pinned_at': fields.Datetime.to_string(message_to_update.pinned_at), + } + }) + if pinned: + notification_text = ''' +
+ %(user_pinned_a_message_to_this_channel)s + %(see_all_pins)s +
+ ''' + notification = Markup(notification_text) % { + 'user_pinned_a_message_to_this_channel': _( + Markup('%(user_name)s pinned a message to this channel.') % { + 'user_name': self.env.user.display_name, + 'message_id': message_id + } + ), + 'see_all_pins': _('See all pinned messages.'), + } + self.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment") + def channel_info(self): """ Get the informations header for the current channels :returns a list of channels values diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 38a5150700b..e58e830e25a 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -147,6 +147,7 @@ class Message(models.Model): # user interface starred_partner_ids = fields.Many2many( 'res.partner', 'mail_message_res_partner_starred_rel', string='Favorited By') + pinned_at = fields.Datetime('Pinned', help='Datetime at which the message has been pinned') starred = fields.Boolean( 'Starred', compute='_compute_starred', search='_search_starred', compute_sudo=False, help='Current user has a starred notification linked to this message') @@ -911,25 +912,32 @@ class Message(models.Model): 'trackingValues': allowed_tracking_ids._tracking_value_format(), 'linkPreviews': message_sudo.link_preview_ids._link_preview_format(), 'messageReactionGroups': reaction_groups, + 'pinned_at': message_sudo.pinned_at, 'record_name': record_name, }) return vals_list @api.model - def _message_fetch(self, domain, before=None, after=None, limit=30): + def _message_fetch(self, domain, before=None, after=None, around=None, limit=30): """ Get a limited amount of formatted messages with provided domain. :param domain: the domain to filter messages; - :param after: messages must be more recent than this id - :param before: message must be less recent than this id + :param before: fetch messages before this message id. + :param after: fetch messages after this message id. + :param around: fetch messages around this message id + i.e. limit//2 before and limit//2 after. :param limit: the maximum amount of messages to get; :returns: record set of mail.message """ + if around: + messages_before = self.search(domain=[*domain, ('id', '<=', around)], limit=limit // 2, order="id DESC") + messages_after = self.search(domain=[*domain, ('id', '>', around)], limit=limit // 2, order='id ASC') + return messages_after + messages_before if before: domain = expression.AND([domain, [('id', '<', before)]]) if after: domain = expression.AND([domain, [('id', '>', after)]]) - return self.search(domain, limit=limit) + return self.search(domain, limit=limit, order='id ASC' if after else 'id DESC') def message_format(self, format_reply=True, msg_vals=None): """ Get the message values in the format for web client. Since message diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 6f0fab85923..219294838c8 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -4043,7 +4043,9 @@ class MailThread(models.AbstractModel): self.env['mail.message.schedule'].sudo()._send_message_notifications(message) # cleanup related message data if the message is empty - message.sudo()._filter_empty()._cleanup_side_records() + empty_messages = message.sudo()._filter_empty() + empty_messages._cleanup_side_records() + empty_messages.write({'pinned_at': None}) return self._message_update_content_after_hook(message) diff --git a/addons/mail/static/src/composer/composer.js b/addons/mail/static/src/composer/composer.js index 02fcb43cde2..f6b6d640e0d 100644 --- a/addons/mail/static/src/composer/composer.js +++ b/addons/mail/static/src/composer/composer.js @@ -20,7 +20,7 @@ import { browser } from "@web/core/browser/browser"; import { _t } from "@web/core/l10n/translation"; import { useService } from "@web/core/utils/hooks"; -import { MessageDeleteDialog } from "../core_ui/message_delete_dialog"; +import { MessageConfirmDialog } from "../core_ui/message_confirm_dialog"; export const SHORT_TYPING = 5000; export const LONG_TYPING = 50000; @@ -45,7 +45,6 @@ export class Composer extends Component { static props = [ "composer", "autofocus?", - "highlightReplyTo?", "messageToReplyTo?", "onDiscardCallback?", "onPostCallback?", @@ -493,9 +492,11 @@ export class Composer extends Component { ) ); } else { - this.env.services.dialog.add(MessageDeleteDialog, { + this.env.services.dialog.add(MessageConfirmDialog, { message: this.props.composer.message, messageComponent: this.props.messageComponent, + onConfirm: () => this.messageService.delete(this.message), + prompt: _t("Are you sure you want to delete this message?"), }); } this.suggestion?.clearRawMentions(); diff --git a/addons/mail/static/src/composer/composer.xml b/addons/mail/static/src/composer/composer.xml index 8a46cb80c63..0301e160b8b 100644 --- a/addons/mail/static/src/composer/composer.xml +++ b/addons/mail/static/src/composer/composer.xml @@ -16,7 +16,7 @@ Avatar of user
- + Replying to diff --git a/addons/mail/static/src/core/message_model.js b/addons/mail/static/src/core/message_model.js index f3fec94a7f4..d91a0ca3007 100644 --- a/addons/mail/static/src/core/message_model.js +++ b/addons/mail/static/src/core/message_model.js @@ -62,6 +62,11 @@ export class Message { type; /** @type {string} */ temporary_id = null; + /** @type {string|undefined} */ + notificationType; + /** @type {string} */ + pinned_at; + /** * We exclude the milliseconds because datetime string from the server don't * have them. Message without date like transient message can be missordered @@ -189,7 +194,22 @@ export class Message { return htmlToTextContentInline(this.body); } + get notificationIcon() { + switch (this.notificationType) { + case "pin": + return "fa fa-thumb-tack"; + } + return null; + } + get failureNotifications() { return this.notifications.filter((notification) => notification.isFailure); } + + get pinnedAt() { + if (!this.pinned_at) { + return null; + } + return luxon.DateTime.fromISO(new Date(this.pinned_at).toISOString()); + } } diff --git a/addons/mail/static/src/core/message_service.js b/addons/mail/static/src/core/message_service.js index 348a502da84..d10a42f6de5 100644 --- a/addons/mail/static/src/core/message_service.js +++ b/addons/mail/static/src/core/message_service.js @@ -144,6 +144,13 @@ export class MessageService { await this.orm.silent.call("mail.message", "set_message_done", [[message.id]]); } + setPin(message, pinned) { + return this.orm.call("mail.channel", "set_message_pin", [message.originThread.id], { + message_id: message.id, + pinned, + }); + } + async unstarAll() { // apply the change immediately for faster feedback this.store.discuss.starred.counter = 0; @@ -222,6 +229,12 @@ export class MessageService { * @param {boolean} [fromFetch=false] */ _update(message, data, fromFetch = false) { + if (message.pinned_at && data.pinned_at === false) { + removeFromArrayWithPredicate( + message.originThread.pinnedMessages, + ({ id }) => id === message.id + ); + } const { attachment_ids: attachments = message.attachments, default_subject: defaultSubject = message.defaultSubject, @@ -285,6 +298,12 @@ export class MessageService { message.originThread.modelName = data.res_model_name; } this._updateReactions(message, data.messageReactionGroups); + if (message.isNotification && !message.notificationType) { + const parser = new DOMParser(); + const htmlBody = parser.parseFromString(message.body, "text/html"); + message.notificationType = + htmlBody.querySelector(".o_mail_notification")?.dataset.oeType; + } } updateNotifications(message) { diff --git a/addons/mail/static/src/core/messaging_service.js b/addons/mail/static/src/core/messaging_service.js index 8444d69d08d..e94d1f538a6 100644 --- a/addons/mail/static/src/core/messaging_service.js +++ b/addons/mail/static/src/core/messaging_service.js @@ -310,6 +310,10 @@ export class Messaging { ({ id }) => id === message.id ); } + removeFromArrayWithPredicate( + message.originThread.pinnedMessages, + ({ id }) => id === message.id + ); if (message.id > message.originThread.seen_message_id) { message.originThread.message_unread_counter--; } @@ -509,7 +513,11 @@ export class Messaging { model: channel.model, }); if (!channel.messages.includes(message)) { - channel.messages.push(message); + if (!channel.loadNewer) { + channel.messages.push(message); + } else if (channel.state === "loading") { + channel.pendingNewMessages.push(message); + } if (message.isSelfAuthored) { channel.seen_message_id = message.id; } else { @@ -539,6 +547,7 @@ export class Messaging { } } if ( + !channel.loadNewer && !message.isSelfAuthored && channel.composer.isFocused && channel.newestPersistentMessage && @@ -606,9 +615,22 @@ export class Messaging { ...messageData, body: messageData.body ? markup(messageData.body) : messageData.body, }); + if ( + message.pinned_at && + !message.originThread?.pinnedMessages.some(({ id }) => id === message.id) + ) { + message.originThread.pinnedMessages.unshift(message); + } if (isStarred && message.isEmpty) { this.messageService.updateStarred(message, false); } + if (message.pinned_at && message.isEmpty) { + message.pinned_at = false; + removeFromArrayWithPredicate( + message.originThread.pinnedMessages, + ({ id }) => id === message.id + ); + } } const { "res.users.settings": settings } = notif.payload; if (settings) { diff --git a/addons/mail/static/src/core/thread_model.js b/addons/mail/static/src/core/thread_model.js index d0506d07137..87ecbf7fe85 100644 --- a/addons/mail/static/src/core/thread_model.js +++ b/addons/mail/static/src/core/thread_model.js @@ -54,10 +54,13 @@ export class Thread { /** @type {import("@mail/core/follower_model").Follower[]} */ followers = []; isAdmin = false; - loadMore = false; + loadOlder = false; + loadNewer = false; isLoadingAttachments = false; isLoadedDeferred = new Deferred(); isLoaded = false; + /** @type {"loading"|"loaded"} */ + pinLoadState; /** @type {import("@mail/attachments/attachment_model").Attachment} */ mainAttachment; memberCount = 0; @@ -65,6 +68,7 @@ export class Thread { message_unread_counter = 0; /** * Contains continuous sequence of messages to show in message list. + * Messages are ordered from older to most recent. * There should not be any hole in this list: there can be unknown * messages before start and after end, but there should not be any * unknown in-between messages. @@ -74,8 +78,17 @@ export class Thread { * @type {import("@mail/core/message_model").Message[]} */ messages = []; + /** + * Contains messages received from the bus that are not yet inserted in + * `messages` list. This is a temporary storage to ensure nothing is lost + * when fetching newer messages. + * + * @type {import("@mail/core/message_model").Message[]} + */ + pendingNewMessages = []; /** * Contains continuous sequence of needaction messages to show in messaging menu. + * Messages are ordered from older to most recent. * There should not be any hole in this list: there can be unknown * messages before start and after end, but there should not be any * unknown in-between messages. @@ -85,6 +98,8 @@ export class Thread { * @type {import("@mail/core/message_model").Message[]} */ needactionMessages = []; + /** @type {import("@mail/core/message_model").Message[]} */ + pinnedMessages = []; /** @type {string} */ name; /** @type {number|false} */ diff --git a/addons/mail/static/src/core/thread_service.js b/addons/mail/static/src/core/thread_service.js index c3552151fc6..781fbe51d90 100644 --- a/addons/mail/static/src/core/thread_service.js +++ b/addons/mail/static/src/core/thread_service.js @@ -109,7 +109,11 @@ export class ThreadService { } const newestPersistentMessage = thread.newestPersistentMessage; thread.seen_message_id = thread.newestPersistentMessage?.id ?? false; - if (this.isUnread(thread) && thread.allowSetLastSeenMessage && newestPersistentMessage) { + if ( + thread.message_unread_counter > 0 && + thread.allowSetLastSeenMessage && + newestPersistentMessage + ) { this.rpc("/mail/channel/set_last_seen_message", { channel_id: thread.id, last_message_id: newestPersistentMessage.id, @@ -163,6 +167,58 @@ export class ThreadService { await this.orm.silent.call("mail.channel", "channel_fetched", [[thread.id]]); } + async fetchPinnedMessages(thread) { + if ( + thread.model !== "mail.channel" || + ["loaded", "loading"].includes(thread.pinLoadState) + ) { + return; + } + thread.pinLoadState = "loading"; + try { + const messages = await this.rpc("/mail/channel/pinned_messages", { + channel_id: thread.id, + }); + const pinnedMessages = messages.map((message) => { + if (message.parentMessage) { + message.parentMessage.body = markup(message.parentMessage.body); + } + message.body = markup(message.body); + return this.messageService.insert(message); + }); + thread.pinnedMessages = pinnedMessages; + } finally { + thread.pinLoadState = "loaded"; + } + } + + getFetchRoute(thread) { + if (thread.model === "mail.channel") { + return "/mail/channel/messages"; + } + switch (thread.type) { + case "chatter": + return "/mail/thread/messages"; + case "mailbox": + return `/mail/${thread.id}/messages`; + default: + throw new Error(`Unknown thread type: ${thread.type}`); + } + } + + getFetchParams(thread) { + if (thread.model === "mail.channel") { + return { channel_id: thread.id }; + } + if (thread.type === "chatter") { + return { + thread_id: thread.id, + thread_model: thread.model, + }; + } + return {}; + } + /** * @param {Thread} thread * @param {{after: Number, before: Number}} @@ -172,35 +228,10 @@ export class ThreadService { if (thread.type === "chatter" && !thread.id) { return []; } - const route = (() => { - if (thread.model === "mail.channel") { - return "/mail/channel/messages"; - } - switch (thread.type) { - case "chatter": - return "/mail/thread/messages"; - case "mailbox": - return `/mail/${thread.id}/messages`; - default: - throw new Error(`Unknown thread type: ${thread.type}`); - } - })(); - const params = (() => { - if (thread.model === "mail.channel") { - return { channel_id: thread.id }; - } - if (thread.type === "chatter") { - return { - thread_id: thread.id, - thread_model: thread.model, - }; - } - return {}; - })(); try { // ordered messages received: newest to oldest - const rawMessages = await this.rpc(route, { - ...params, + const rawMessages = await this.rpc(this.getFetchRoute(thread), { + ...this.getFetchParams(thread), limit: FETCH_LIMIT, after, before, @@ -242,10 +273,19 @@ export class ThreadService { // feed messages // could have received a new message as notification during fetch // filter out already fetched (e.g. received as notification in the meantime) - const startIndex = - after === undefined - ? 0 - : thread.messages.findIndex((message) => message.id === after); + let startIndex; + if (after === undefined) { + startIndex = 0; + } else { + const afterIndex = thread.messages.findIndex((message) => message.id === after); + if (afterIndex === -1) { + // there might have been a jump to message during RPC fetch. + // Abort feeding messages as to not put holes in message list. + return; + } else { + startIndex = afterIndex + 1; + } + } const alreadyKnownMessages = new Set(thread.messages.map((m) => m.id)); const filtered = fetched.filter( (message) => @@ -280,18 +320,52 @@ export class ThreadService { thread.needactionMessages.splice(startNeedactionIndex, 0, ...filteredNeedaction); } Object.assign(thread, { - loadMore: + loadOlder: after === undefined && fetched.length === FETCH_LIMIT ? true : after === undefined && fetched.length !== FETCH_LIMIT ? false - : thread.loadMore, + : thread.loadOlder, }); } catch { // handled in fetchMessages } } + /** + * Get ready to jump to a message in a thread. This method will fetch the + * messages around the message to jump to if required, and update the thread + * messages accordingly. + * + * @param {Message} [messageId] if not provided, load around newest message + */ + async loadAround(thread, messageId) { + if (!thread.messages.some(({ id }) => id === messageId)) { + const messages = await this.rpc(this.getFetchRoute(thread), { + ...this.getFetchParams(thread), + around: messageId, + }); + thread.messages = messages.reverse().map((message) => + this.messageService.insert({ + ...message, + body: message.body ? markup(message.body) : message.body, + }) + ); + thread.loadNewer = true; + thread.loadOlder = true; + if (messages.length < FETCH_LIMIT) { + const olderMessagesCount = messages.filter(({ id }) => id < messageId).length; + if (olderMessagesCount < FETCH_LIMIT / 2) { + thread.loadOlder = false; + } else { + thread.loadNewer = false; + } + } + // Give some time to the UI to update. + await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve))); + } + } + // This function is like fetchNewMessages but just for a single message at most on all pinned threads fetchPreviews = memoize(async () => { const ids = []; @@ -319,7 +393,7 @@ export class ThreadService { } } thread.isLoaded = true; - thread.loadMore = true; + thread.loadOlder = true; thread.status = "ready"; } } @@ -327,22 +401,55 @@ export class ThreadService { /** * @param {Thread} thread + * @param {"older"|"newer"} epoch */ - async fetchMoreMessages(thread) { - if (thread.status === "loading") { + async fetchMoreMessages(thread, epoch = "older") { + if ( + thread.status === "loading" || + (epoch === "older" && !thread.loadOlder) || + (epoch === "newer" && !thread.loadNewer) + ) { return; } + const before = epoch === "older" ? thread.oldestPersistentMessage?.id : undefined; + const after = epoch === "newer" ? thread.newestPersistentMessage?.id : undefined; try { - const fetched = await this.fetchMessages(thread, { - before: thread.oldestPersistentMessage?.id, - }); - thread.messages.unshift(...fetched); + const fetched = await this.fetchMessages(thread, { after, before }); + if ( + (after !== undefined && !thread.messages.some((message) => message.id === after)) || + (before !== undefined && !thread.messages.some((message) => message.id === before)) + ) { + // there might have been a jump to message during RPC fetch. + // Abort feeding messages as to not put holes in message list. + return; + } + const alreadyKnownMessages = new Set(thread.messages.map(({ id }) => id)); + const messagesToAdd = fetched.filter( + (message) => !alreadyKnownMessages.has(message.id) + ); + if (epoch === "older") { + thread.messages.unshift(...messagesToAdd); + } else { + thread.messages.push(...messagesToAdd); + } if (fetched.length < FETCH_LIMIT) { - thread.loadMore = false; + if (epoch === "older") { + thread.loadOlder = false; + } else if (epoch === "newer") { + thread.loadNewer = false; + const missingMessages = thread.pendingNewMessages.filter( + ({ id }) => !alreadyKnownMessages.has(id) + ); + if (missingMessages.length > 0) { + thread.messages.push(...missingMessages); + thread.messages.sort((m1, m2) => m1.id - m2.id); + } + } } } catch { // handled in fetchMessages } + thread.pendingNewMessages = []; } async createChannel(name) { @@ -872,13 +979,6 @@ export class ThreadService { return message; } - /** - * @param {Thread} thread - */ - isUnread(thread) { - return thread.message_unread_counter > 0; - } - /** * @param {Thread} thread */ @@ -916,7 +1016,7 @@ export class ThreadService { if (categoryId === "channels") { return channel.message_needaction_counter > 0 ? acc + 1 : acc; } else { - return this.isUnread(channel) > 0 ? acc + 1 : acc; + return channel.message_unread_counter > 0 ? acc + 1 : acc; } }, 0); } diff --git a/addons/mail/static/src/core_ui/message.js b/addons/mail/static/src/core_ui/message.js index 25ba56082b5..c6f18b20767 100644 --- a/addons/mail/static/src/core_ui/message.js +++ b/addons/mail/static/src/core_ui/message.js @@ -20,7 +20,7 @@ import { import { useService } from "@web/core/utils/hooks"; import { Composer } from "../composer/composer"; import { useMessaging, useStore } from "../core/messaging_hook"; -import { MessageDeleteDialog } from "./message_delete_dialog"; +import { MessageConfirmDialog } from "./message_confirm_dialog"; import { LinkPreviewList } from "./link_preview/link_preview_list"; import { RelativeTime } from "./relative_time"; import { MessageReactions } from "./message_reactions"; @@ -63,7 +63,6 @@ export class Message extends Component { static defaultProps = { hasActions: true, isInChatWindow: false, - onParentMessageClick: () => {}, }; static props = [ "hasActions?", @@ -268,7 +267,9 @@ export class Message extends Component { get isAlignedRight() { return Boolean( - this.env.inChatWindow && this.user.partnerId === this.props.message.author?.id + !this.env.pinnedPanel && + this.env.inChatWindow && + this.user.partnerId === this.props.message.author?.id ); } @@ -299,7 +300,7 @@ export class Message extends Component { * @returns {boolean} */ get shouldDisplayAuthorName() { - if (!this.env.inChatWindow) { + if (!this.env.inChatWindow || this.env.pinnedPanel) { return true; } if (this.message.isSelfAuthored) { @@ -312,9 +313,25 @@ export class Message extends Component { } onClickDelete() { - this.env.services.dialog.add(MessageDeleteDialog, { + this.env.services.dialog.add(MessageConfirmDialog, { message: this.message, messageComponent: Message, + prompt: _t("Are you sure you want to delete this message?"), + onConfirm: () => this.messageService.delete(this.message), + }); + } + + onClickPin() { + const prompt = this.message.pinned_at + ? _t("Are you sure you want to remove this pinned message?") + : _t( + "The following message will be pinned to the channel. Are you sure you want to continue?" + ); + this.env.services.dialog.add(MessageConfirmDialog, { + message: this.message, + messageComponent: Message, + prompt, + onConfirm: () => this.messageService.setPin(this.message, !this.message.pinned_at), }); } @@ -350,6 +367,10 @@ export class Message extends Component { return this.hasOpenChatFeature ? _t("Open chat") : ""; } + get pinOptionText() { + return this.message.pinned_at ? _t("Unpin") : _t("Pin"); + } + openChatAvatar(ev) { markEventHandled(ev, "Message.ClickAuthor"); if (!this.hasOpenChatFeature) { diff --git a/addons/mail/static/src/core_ui/message.scss b/addons/mail/static/src/core_ui/message.scss index 3a57591c9c3..1fb8cc098f5 100644 --- a/addons/mail/static/src/core_ui/message.scss +++ b/addons/mail/static/src/core_ui/message.scss @@ -28,7 +28,7 @@ } } -.o-mail-ChatWindow .o-mail-Message.o-selfAuthored { +.o-mail-ChatWindow .o-mail-Message:not(.o-pinnedPanel).o-selfAuthored { flex-direction: row-reverse; .o-mail-Message-core, .o-mail-Message-content { diff --git a/addons/mail/static/src/core_ui/message.xml b/addons/mail/static/src/core_ui/message.xml index 53c6dc6e55d..fb9a801e8f6 100644 --- a/addons/mail/static/src/core_ui/message.xml +++ b/addons/mail/static/src/core_ui/message.xml @@ -5,13 +5,14 @@
View Reactions Mark as Read + Mark as Unread Edit - Delete + Delete
diff --git a/addons/mail/static/src/core_ui/message_delete_dialog.js b/addons/mail/static/src/core_ui/message_confirm_dialog.js similarity index 67% rename from addons/mail/static/src/core_ui/message_delete_dialog.js rename to addons/mail/static/src/core_ui/message_confirm_dialog.js index 687d5d06671..70b7ba8a835 100644 --- a/addons/mail/static/src/core_ui/message_delete_dialog.js +++ b/addons/mail/static/src/core_ui/message_confirm_dialog.js @@ -6,10 +6,10 @@ import { Dialog } from "@web/core/dialog/dialog"; import { _t } from "@web/core/l10n/translation"; import { useService } from "@web/core/utils/hooks"; -export class MessageDeleteDialog extends Component { +export class MessageConfirmDialog extends Component { static components = { Dialog }; - static props = ["close", "message", "messageComponent"]; - static template = "mail.MessageDeleteDialog"; + static props = ["close", "message", "messageComponent", "prompt", "onConfirm"]; + static template = "mail.MessageConfirmDialog"; setup() { /** @type {import("@mail/core/message_service").MessageService} */ @@ -17,8 +17,8 @@ export class MessageDeleteDialog extends Component { this.title = _t("Confirmation"); } - onClickDelete() { - this.messageService.delete(this.props.message); + onClickConfirm() { + this.props.onConfirm(); this.props.close(); } } diff --git a/addons/mail/static/src/core_ui/message_delete_dialog.xml b/addons/mail/static/src/core_ui/message_confirm_dialog.xml similarity index 78% rename from addons/mail/static/src/core_ui/message_delete_dialog.xml rename to addons/mail/static/src/core_ui/message_confirm_dialog.xml index ccd8f1fac4f..f0bbd41d62b 100644 --- a/addons/mail/static/src/core_ui/message_delete_dialog.xml +++ b/addons/mail/static/src/core_ui/message_confirm_dialog.xml @@ -1,14 +1,14 @@ - + -

Are you sure you want to delete this message?

+

- +
diff --git a/addons/mail/static/src/core_ui/message_in_reply.js b/addons/mail/static/src/core_ui/message_in_reply.js index aed539380cb..dfcbbd867c8 100644 --- a/addons/mail/static/src/core_ui/message_in_reply.js +++ b/addons/mail/static/src/core_ui/message_in_reply.js @@ -8,7 +8,7 @@ import { useMessaging, useStore } from "@mail/core/messaging_hook"; import { url } from "@web/core/utils/urls"; export class MessageInReply extends Component { - static props = ["message", "alignedRight", "onClick"]; + static props = ["message", "alignedRight", "onClick?"]; static template = "mail.MessageInReply"; setup() { diff --git a/addons/mail/static/src/core_ui/message_in_reply.xml b/addons/mail/static/src/core_ui/message_in_reply.xml index c82c1613606..c9be12cbd56 100644 --- a/addons/mail/static/src/core_ui/message_in_reply.xml +++ b/addons/mail/static/src/core_ui/message_in_reply.xml @@ -6,9 +6,9 @@ 'me-5': store.discuss.isActive or (env.inChatWindow and !props.alignedRight), 'd-flex justify-content-end ms-5': env.inChatWindow and props.alignedRight, }"> - + - + Avatar @: diff --git a/addons/mail/static/src/core_ui/thread.js b/addons/mail/static/src/core_ui/thread.js index 30bf33e6199..ce1fe63876d 100644 --- a/addons/mail/static/src/core_ui/thread.js +++ b/addons/mail/static/src/core_ui/thread.js @@ -1,18 +1,28 @@ /* @odoo-module */ -import { Component, onMounted, onWillStart, onWillUpdateProps, useRef, useState } from "@odoo/owl"; +import { + Component, + onMounted, + onWillStart, + onWillUpdateProps, + useEffect, + useRef, + useState, +} from "@odoo/owl"; import { useMessaging, useStore } from "../core/messaging_hook"; import { useAutoScroll, useScrollPosition, useScrollSnapshot, useVisible } from "@mail/utils/hooks"; import { Message } from "./message"; import { Transition } from "@web/core/transition"; import { useService } from "@web/core/utils/hooks"; +import { escape } from "@web/core/utils/strings"; + +export const PRESENT_THRESHOLD = 2500; /** * @typedef {Object} Props * @property {boolean} [isInChatWindow=false] * @property {import("@mail/utils/hooks").MessageEdition} [messageEdition] - * @property {import("@mail/utils/hooks").MessageHighlight} [messageHighlight] * @property {import("@mail/utils/hooks").MessageToReplyTo} [messageToReplyTo] * @property {"asc"|"desc"} [order="asc"] * @property {import("@mail/core/thread_model").Thread} thread @@ -22,32 +32,29 @@ export class Thread extends Component { static components = { Message, Transition }; static props = [ "isInChatWindow?", - "hasMessageScrollAdjustInChatter?", + "hasScrollAdjust?", "thread", "messageEdition?", - "messageHighlight?", "messageToReplyTo?", "order?", ]; static defaultProps = { isInChatWindow: false, - hasMessageScrollAdjustInChatter: false, + hasScrollAdjust: true, order: "asc", // 'asc' or 'desc' }; static template = "mail.Thread"; setup() { + this.escape = escape; this.messaging = useMessaging(); this.store = useStore(); - this.state = useState({ isReplyingTo: false }); + this.state = useState({ isReplyingTo: false, showJumpPresent: false }); /** @type {import("@mail/core/thread_service").ThreadService} */ this.threadService = useState(useService("mail.thread")); - if (!this.env.inChatter || this.props.hasMessageScrollAdjustInChatter) { + if (!this.env.inChatter || !this.props.hasScrollAdjust) { useAutoScroll("messages", () => { - if ( - this.props.messageHighlight && - this.props.messageHighlight.highlightedMessageId - ) { + if (this.env.messageHighlight?.highlightedMessageId) { return false; } if (this.props.thread.scrollPosition.isSaved) { @@ -56,40 +63,69 @@ export class Thread extends Component { return true; }); } + this.messageHighlight = this.env.messageHighlight + ? useState(this.env.messageHighlight) + : null; + this.present = useRef("load-newer"); this.messagesRef = useRef("messages"); - this.loadMoreState = useVisible("load-more", () => { - if (this.loadMoreState.isVisible) { + this.loadOlderState = useVisible("load-older", () => { + if (this.loadOlderState.isVisible && !this.isJumpingRecent) { this.threadService.fetchMoreMessages(this.props.thread); } }); + this.loadNewerState = useVisible("load-newer", () => { + if (this.loadNewerState.isVisible && !this.isJumpingRecent) { + this.threadService.fetchMoreMessages(this.props.thread, "newer"); + } + }); + this.presentThresholdState = useVisible( + "present-treshold", + () => this.updateShowJumpPresent(), + { init: true } + ); this.oldestPersistentMessageId = null; this.scrollPosition = useScrollPosition( "messages", this.props.thread.scrollPosition, "bottom" ); - if (!this.env.inChatter || this.props.hasMessageScrollAdjustInChatter) { + if (!this.env.inChatter || !this.props.hasScrollAdjust) { useScrollSnapshot("messages", { onWillPatch: () => { return { hasMoreMsgsAbove: - this.props.thread.oldestPersistentMessage?.id !== + this.props.thread.oldestPersistentMessage?.id < this.oldestPersistentMessage && this.props.order === "asc", + hasMoreMsgBelow: + this.props.thread.loadNewer && + this.props.thread.newestPersistentMessage?.id > + this.newestPersistentMessage && + this.props.order === "asc", }; }, - onPatched: ({ hasMoreMsgsAbove, scrollTop, scrollHeight }) => { + onPatched: ({ hasMoreMsgsAbove, hasMoreMsgBelow, scrollTop, scrollHeight }) => { const el = this.messagesRef.el; - if (hasMoreMsgsAbove) { - el.scrollTop = scrollTop + el.scrollHeight - scrollHeight; + if (!this.isJumpingRecent) { + if (hasMoreMsgsAbove) { + el.scrollTop = scrollTop + el.scrollHeight - scrollHeight; + } else if (hasMoreMsgBelow) { + el.scrollTop = scrollTop; + } } this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id; + this.newestPersistentMessage = this.props.thread.newestPersistentMessage?.id; }, }); } + useEffect( + () => this.updateShowJumpPresent(), + () => [this.props.thread.loadNewer] + ); onMounted(() => { this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id; - if (!this.env.inChatter || this.props.hasMessageScrollAdjustInChatter) { + if (!this.env.inChatter || !this.props.hasScrollAdjust) { this.scrollPosition.restore(); + this.updateShowJumpPresent(); } }); onWillStart(() => { @@ -100,10 +136,48 @@ export class Thread extends Component { }); } - onClickLoadMore() { + get PRESENT_THRESHOLD() { + return PRESENT_THRESHOLD; + } + + updateShowJumpPresent() { + this.state.showJumpPresent = + this.props.thread.loadNewer || !this.presentThresholdState.isVisible; + } + + onClickLoadOlder() { this.threadService.fetchMoreMessages(this.props.thread); } + async onClickJumpPresent() { + this.isJumpingRecent = true; + await this.threadService.loadAround(this.props.thread); + this.props.thread.loadNewer = false; + this.present.el.scrollIntoView({ + behavior: this.props.order === "asc" ? "smooth" : "instant", // FIXME somehow smooth not working in desc mode + block: "center", + }); + // Let smooth scroll a bit so load more is not visible + // smooth scrolling starts after 1 animation frame, hence needs to wait 2 animation frames + // for load more becoming not visible + await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve))); + await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve))); + this.state.showJumpPresent = false; + this.isJumpingRecent = false; + } + + /** + * @param {MouseEvent} ev + */ + async onClickNotification(ev) { + const { oeType, oeId } = ev.target.dataset; + if (oeType === "highlight") { + await this.env.messageHighlight?.highlightMessage(Number(oeId), this.props.thread); + } else if (oeType === "pin-menu") { + this.env.pinMenu?.open(); + } + } + isSquashed(msg, prevMsg) { if (this.props.thread.model === "mail.box") { return false; diff --git a/addons/mail/static/src/core_ui/thread.scss b/addons/mail/static/src/core_ui/thread.scss index 8c194615c25..eaff1ddf805 100644 --- a/addons/mail/static/src/core_ui/thread.scss +++ b/addons/mail/static/src/core_ui/thread.scss @@ -9,3 +9,19 @@ color: lighten($o-brand-odoo, 15%); } } + +.o_mail_notification { + & a:hover { + text-decoration: underline; + } + display: inline; +} + +.o-mail-Thread-noPinnedPanel [data-oe-type="pin-menu"] { + display: none; +} + +.o-mail-Thread-jumpPresent { + background-color: mix(map-get($theme-colors, 'info'), $o-webclient-background-color, 5%); + z-index: $o-mail-NavigableList-zIndex - 1; +} diff --git a/addons/mail/static/src/core_ui/thread.xml b/addons/mail/static/src/core_ui/thread.xml index e6594ee992a..859e2487dd3 100644 --- a/addons/mail/static/src/core_ui/thread.xml +++ b/addons/mail/static/src/core_ui/thread.xml @@ -1,16 +1,22 @@ - + -
- -
+ +
+ +
+
- - + + + You're viewing older messages + + Jump to Present + + + + + + @@ -88,15 +107,19 @@
An error occurred while fetching messages.
-
-
- +
+ +
diff --git a/addons/mail/static/src/discuss/discuss.js b/addons/mail/static/src/discuss/discuss.js index 292488de05a..dd6bd6d7c0a 100644 --- a/addons/mail/static/src/discuss/discuss.js +++ b/addons/mail/static/src/discuss/discuss.js @@ -24,6 +24,7 @@ import { usePopover } from "@web/core/popover/popover_hook"; import { useService } from "@web/core/utils/hooks"; import { ChannelInvitation } from "./channel_invitation"; import { _t } from "@web/core/l10n/translation"; +import { PinnedMessagesPanel } from "./pinned_messages_panel"; export class Discuss extends Component { static components = { @@ -34,6 +35,7 @@ export class Discuss extends Component { Call, CallSettings, ChannelMemberList, + PinnedMessagesPanel, }; static props = { public: { type: Boolean, optional: true }, @@ -42,6 +44,7 @@ export class Discuss extends Component { MODES = Object.freeze({ MEMBER_LIST: "member-list", + PINNED_MESSAGES: "pinned-messages", SETTINGS: "settings", NONE: "", }); @@ -67,7 +70,18 @@ export class Discuss extends Component { this.orm = useService("orm"); this.effect = useService("effect"); this.prevInboxCounter = this.store.discuss.inbox.counter; - useChildSubEnv({ inDiscussApp: true }); + useChildSubEnv({ + inDiscussApp: true, + messageHighlight: this.messageHighlight, + pinMenu: { + open: () => (this.state.activeMode = this.MODES.PINNED_MESSAGES), + close: () => { + if (this.state.activeMode === this.MODES.PINNED_MESSAGES) { + this.state.activeMode = this.MODES.NONE; + } + }, + }, + }); useEffect( () => { if ( @@ -98,6 +112,13 @@ export class Discuss extends Component { return this.store.threads[this.store.discuss.threadLocalId]; } + togglePinMenu() { + this.state.activeMode = + this.state.activeMode === this.MODES.PINNED_MESSAGES + ? this.MODES.NONE + : this.MODES.PINNED_MESSAGES; + } + toggleInviteForm() { if (this.closePopover) { this.closePopover(); diff --git a/addons/mail/static/src/discuss/discuss.xml b/addons/mail/static/src/discuss/discuss.xml index 6e0d0faf674..b571d4dc1c3 100644 --- a/addons/mail/static/src/discuss/discuss.xml +++ b/addons/mail/static/src/discuss/discuss.xml @@ -28,6 +28,9 @@
+ @@ -57,9 +60,10 @@
- - + +
+
diff --git a/addons/mail/static/src/discuss/pinned_messages_panel.js b/addons/mail/static/src/discuss/pinned_messages_panel.js new file mode 100644 index 00000000000..5964bdf5dc5 --- /dev/null +++ b/addons/mail/static/src/discuss/pinned_messages_panel.js @@ -0,0 +1,75 @@ +/* @odoo-module */ + +import { Component, onWillStart, onWillUpdateProps, useSubEnv } from "@odoo/owl"; +import { Message } from "@mail/core_ui/message"; +import { MessageConfirmDialog } from "@mail/core_ui/message_confirm_dialog"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; + +export class PinnedMessagesPanel extends Component { + static components = { + Message, + }; + static props = ["thread"]; + static template = "mail.PinnedMessagesPanel"; + + setup() { + this.threadService = useService("mail.thread"); + this.messageService = useService("mail.message"); + this.store = useService("mail.store"); + this.rpc = useService("rpc"); + onWillStart(() => { + this.threadService.fetchPinnedMessages(this.props.thread); + }); + onWillUpdateProps(async (nextProps) => { + if (nextProps.thread.id !== this.props.thread.id) { + this.threadService.fetchPinnedMessages(nextProps.thread); + } + }); + useSubEnv({ + pinnedPanel: true, + }); + } + + /** + * Highlight the given message and scrolls to it. In small mode, the + * pin menu is closed beforwards + * + * @param {Message} message + */ + async onClickJump(message) { + if (this.env.isSmall) { + this.env.pinMenu.close(); + // Give the time to the pin menu to close before scrolling + // to the message. + await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve))); + } + await this.env.messageHighlight?.highlightMessage(message.id, this.props.thread); + } + + /** + * Prompt the user for confirmation and unpin the given message if + * confirmed. + * + * @param {Message} message + */ + onClickUnpin(message) { + this.env.services.dialog.add(MessageConfirmDialog, { + message, + messageComponent: Message, + prompt: _t("Are you sure you want to remove this pinned message?"), + onConfirm: () => this.messageService.setPin(message, false), + }); + } + + /** + * Get the message to display when nothing is pinned on this thread. + */ + get emptyMessage() { + if (this.props.thread.type === "channel") { + return _t("This channel doesn't have any pinned messages."); + } else { + return _t("This conversation doesn't have any pinned messages."); + } + } +} diff --git a/addons/mail/static/src/discuss/pinned_messages_panel.scss b/addons/mail/static/src/discuss/pinned_messages_panel.scss new file mode 100644 index 00000000000..a424f21f4de --- /dev/null +++ b/addons/mail/static/src/discuss/pinned_messages_panel.scss @@ -0,0 +1,19 @@ +.o-mail-PinnedMessagesPanel { + flex-basis: 425px; +} + +.o-mail-PinnedMessagesPanel-title { + z-index: 1; +} + +.o-mail-PinnedMessagesPanel-jump { + color: white; +} + +.card-body:hover .o-mail-PinnedMessagesPanel-jump { + opacity: 100 !important; +} + +.o_touch_device .o-mail-PinnedMessagesPanel-jump.btn { + font-size: 0.9rem; +} diff --git a/addons/mail/static/src/discuss/pinned_messages_panel.xml b/addons/mail/static/src/discuss/pinned_messages_panel.xml new file mode 100644 index 00000000000..521f404c921 --- /dev/null +++ b/addons/mail/static/src/discuss/pinned_messages_panel.xml @@ -0,0 +1,28 @@ + + + +
+

+ Pinned messages +

+ +
+
+
+
+ + +
+ +
+
+

+

+
+
+
diff --git a/addons/mail/static/src/utils/hooks.js b/addons/mail/static/src/utils/hooks.js index 9dae842afd4..6ee89c04b0f 100644 --- a/addons/mail/static/src/utils/hooks.js +++ b/addons/mail/static/src/utils/hooks.js @@ -9,6 +9,7 @@ import { useRef, useState, } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; function useExternalListener(target, eventName, handler, eventParams) { const boundHandler = handler.bind(useComponent()); @@ -114,9 +115,9 @@ export function useAutoScroll(refName, shouldScrollPredicate = () => true) { onPatched(applyScroll); } -export function useVisible(refName, cb) { +export function useVisible(refName, cb, { init = false } = {}) { const ref = useRef(refName); - const state = { isVisible: false }; + const state = { isVisible: init }; const observer = new IntersectionObserver((entries) => { for (const entry of entries) { const newVal = entry.isIntersecting; @@ -202,8 +203,10 @@ export function useScrollSnapshot(refName, { onWillPatch: p_onWillPatch, onPatch */ export function useMessageHighlight(duration = 2000) { let timeout; + const threadService = useService("mail.thread"); const state = useState({ - async highlightMessage(msgId) { + async highlightMessage(msgId, thread) { + await threadService.loadAround(thread, msgId); const lastHighlightedMessageId = state.highlightedMessageId; clearHighlight(); if (lastHighlightedMessageId === msgId) { diff --git a/addons/mail/static/src/views/form/form_compiler.js b/addons/mail/static/src/views/form/form_compiler.js index cc96a0efd10..f604c69ac83 100644 --- a/addons/mail/static/src/views/form/form_compiler.js +++ b/addons/mail/static/src/views/form/form_compiler.js @@ -89,7 +89,7 @@ export class MailFormCompiler extends ViewCompiler { }); const chatterContainerXml = chatterContainerHookXml.querySelector("Chatter"); setAttributes(chatterContainerXml, { - hasMessageScrollAdjustInChatter: "true", + hasMessageListScrollAdjust: "true", isInFormSheetBg: "false", }); } @@ -158,7 +158,7 @@ patch(FormCompiler.prototype, "mail", { } const chatterContainerXml = chatterContainerHookXml.querySelector("Chatter"); setAttributes(chatterContainerXml, { - hasMessageScrollAdjustInChatter: "false", + hasMessageListScrollAdjust: "false", isInFormSheetBg: "false", saveRecord: "__comp__.props.saveButtonClicked", }); diff --git a/addons/mail/static/src/web/chat_window/chat_window.js b/addons/mail/static/src/web/chat_window/chat_window.js index 0c805032aa1..b46d6a8645c 100644 --- a/addons/mail/static/src/web/chat_window/chat_window.js +++ b/addons/mail/static/src/web/chat_window/chat_window.js @@ -15,6 +15,7 @@ import { ThreadIcon } from "@mail/discuss/thread_icon"; import { ChannelInvitation } from "@mail/discuss/channel_invitation"; import { isEventHandled } from "@mail/utils/misc"; import { ChannelSelector } from "@mail/discuss/channel_selector"; +import { PinnedMessagesPanel } from "@mail/discuss/pinned_messages_panel"; /** * @typedef {Object} Props @@ -32,6 +33,7 @@ export class ChatWindow extends Component { ChannelMemberList, ThreadIcon, ChannelInvitation, + PinnedMessagesPanel, }; static props = ["chatWindow", "right?"]; static template = "mail.ChatWindow"; @@ -49,6 +51,7 @@ export class ChatWindow extends Component { this.state = useState({ /** * activeMode: + * "pinned-messages": pin menu is displayed * "member-list": channel member list is displayed * "in-settings": settings is displayed * "add-users": add users is displayed (small device) @@ -58,7 +61,18 @@ export class ChatWindow extends Component { }); this.action = useService("action"); this.contentRef = useRef("content"); - useChildSubEnv({ inChatWindow: true }); + useChildSubEnv({ + inChatWindow: true, + messageHighlight: this.messageHighlight, + pinMenu: { + open: () => (this.state.activeMode = "pinned-messages"), + close: () => { + if (this.state.activeMode === "pinned-messages") { + this.state.activeMode = ""; + } + }, + }, + }); } get thread() { @@ -108,6 +122,11 @@ export class ChatWindow extends Component { this.chatWindowService.notifyState(this.props.chatWindow); } + togglePinMenu() { + this.state.activeMode = + this.state.activeMode === "pinned-messages" ? "" : "pinned-messages"; + } + toggleSettings() { this.state.activeMode = this.state.activeMode === "in-settings" ? "" : "in-settings"; } diff --git a/addons/mail/static/src/web/chat_window/chat_window.xml b/addons/mail/static/src/web/chat_window/chat_window.xml index 1565fe25acc..e0bf156852a 100644 --- a/addons/mail/static/src/web/chat_window/chat_window.xml +++ b/addons/mail/static/src/web/chat_window/chat_window.xml @@ -21,10 +21,13 @@ 'ms-3 me-1 my-0': !thread, }" /> -
- () +
+ ()
+
+ +
@@ -48,13 +51,14 @@
- + + - - + +
diff --git a/addons/mail/static/src/web/chatter.js b/addons/mail/static/src/web/chatter.js index 685aedbcd3d..278c1893d77 100644 --- a/addons/mail/static/src/web/chatter.js +++ b/addons/mail/static/src/web/chatter.js @@ -51,7 +51,7 @@ export class Chatter extends Component { "hasActivities?", "hasFollowers?", "hasMessageList?", - "hasMessageScrollAdjustInChatter?", + "hasMessageListScrollAdjust?", "hasParentReloadOnAttachmentsChanged?", "hasParentReloadOnFollowersUpdate?", "hasParentReloadOnMessagePosted?", @@ -67,7 +67,7 @@ export class Chatter extends Component { hasActivities: true, hasFollowers: true, hasMessageList: true, - hasMessageScrollAdjustInChatter: false, + hasMessageListScrollAdjust: false, hasParentReloadOnAttachmentsChanged: false, hasParentReloadOnFollowersUpdate: false, hasParentReloadOnMessagePosted: false, diff --git a/addons/mail/static/src/web/chatter.xml b/addons/mail/static/src/web/chatter.xml index fed448f8f44..bd637bb36d5 100644 --- a/addons/mail/static/src/web/chatter.xml +++ b/addons/mail/static/src/web/chatter.xml @@ -2,7 +2,7 @@ -
+
diff --git a/addons/mail/static/src/web/discuss/sidebar.xml b/addons/mail/static/src/web/discuss/sidebar.xml index 2608dd9a29c..51f43f980e8 100644 --- a/addons/mail/static/src/web/discuss/sidebar.xml +++ b/addons/mail/static/src/web/discuss/sidebar.xml @@ -86,7 +86,7 @@ t-att-class="{ 'bg-100': threadLocalId !== store.discuss.threadLocalId, 'o-active bg-200': threadLocalId === store.discuss.threadLocalId, - 'o-unread': threadService.isUnread(thread), + 'o-unread': thread.message_unread_counter > 0, }" t-on-click="(ev) => this.openThread(ev, thread)" > @@ -94,7 +94,7 @@ Thread Image
- +
diff --git a/addons/mail/static/src/web/messaging_menu/messaging_menu.js b/addons/mail/static/src/web/messaging_menu/messaging_menu.js index e4bc9c47275..53193e2106a 100644 --- a/addons/mail/static/src/web/messaging_menu/messaging_menu.js +++ b/addons/mail/static/src/web/messaging_menu/messaging_menu.js @@ -134,7 +134,7 @@ export class MessagingMenu extends Component { id: `preview-${thread.localId}`, count: thread.message_unread_counter, imgUrl: thread.imgUrl, - hasMarkAsReadButton: this.threadService.isUnread(thread), + hasMarkAsReadButton: thread.message_unread_counter > 0, message, thread, isNeedaction: false, @@ -302,7 +302,7 @@ export class MessagingMenu extends Component { let value = this.store.discuss.inbox.counter + Object.values(this.store.threads).filter( - (thread) => thread.is_pinned && this.threadService.isUnread(thread) + (thread) => thread.is_pinned && thread.message_unread_counter > 0 ).length + Object.values(this.store.notificationGroups).reduce( (acc, ng) => acc + parseInt(Object.values(ng.notifications).length), diff --git a/addons/mail/static/tests/discuss/jump_to_present_tests.js b/addons/mail/static/tests/discuss/jump_to_present_tests.js new file mode 100644 index 00000000000..f4fe5a93cc5 --- /dev/null +++ b/addons/mail/static/tests/discuss/jump_to_present_tests.js @@ -0,0 +1,94 @@ +/** @odoo-module */ + +import { + afterNextRender, + click, + nextAnimationFrame, + start, + startServer, +} from "@mail/../tests/helpers/test_utils"; +import { PRESENT_THRESHOLD } from "@mail/core_ui/thread"; +import { nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; + +QUnit.module("jump to present"); + +QUnit.test("Basic jump to present when scrolling to outdated messages", async (assert) => { + // make scroll behavior instantaneous. + patchWithCleanup(Element.prototype, { + scrollIntoView() { + return this._super(true); + }, + }); + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ name: "General" }); + pyEnv["mail.message"].create({ + body: "Hello world!", + model: "mail.channel", + res_id: channelId, + }); + for (let i = 0; i < 20; i++) { + pyEnv["mail.message"].create({ + body: "Non Empty Body ".repeat(100), + message_type: "comment", + model: "mail.channel", + res_id: channelId, + }); + } + const { openDiscuss } = await start(); + await openDiscuss(channelId); + assert.ok( + $(".o-mail-Thread")[0].scrollHeight > PRESENT_THRESHOLD, + "should have enough scroll height to trigger jump to present" + ); + await afterNextRender(() => $(".o-mail-Thread").scrollTop(0)); + assert.containsOnce($, ".o-mail-Thread:contains(You're viewing older messagesJump to Present)"); + await click(".o-mail-Thread-jumpPresent"); + await nextTick(); + assert.containsNone($, ".o-mail-Thread:contains(You're viewing older messagesJump to Present)"); + assert.ok( + $(".o-mail-Thread")[0].scrollHeight - $(".o-mail-Thread")[0].scrollTop <= + $(".o-mail-Thread")[0].clientHeight + ); +}); + +QUnit.test("Jump to old reply should prompt jump to presence", async (assert) => { + // make scroll behavior instantaneous. + patchWithCleanup(Element.prototype, { + scrollIntoView() { + return this._super(true); + }, + }); + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ name: "General" }); + const oldestMessageId = pyEnv["mail.message"].create({ + body: "Hello world!", + model: "mail.channel", + res_id: channelId, + }); + for (let i = 0; i < 100; i++) { + pyEnv["mail.message"].create({ + body: "Non Empty Body ".repeat(100), + message_type: "comment", + model: "mail.channel", + res_id: channelId, + }); + } + pyEnv["mail.message"].create({ + body: "Most Recent!", + model: "mail.channel", + res_id: channelId, + parent_id: oldestMessageId, + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-MessageInReply .cursor-pointer"); + assert.isVisible($(".o-mail-Message:contains(Hello world!):eq(0)")[0]); + assert.containsOnce($, ".o-mail-Thread:contains(You're viewing older messagesJump to Present)"); + await click(".o-mail-Thread-jumpPresent"); + await nextAnimationFrame(); + assert.containsNone($, ".o-mail-Thread:contains(You're viewing older messagesJump to Present)"); + assert.ok( + $(".o-mail-Thread")[0].scrollHeight - $(".o-mail-Thread")[0].scrollTop <= + $(".o-mail-Thread")[0].clientHeight + ); +}); diff --git a/addons/mail/static/tests/discuss/pinned_messages_tests.js b/addons/mail/static/tests/discuss/pinned_messages_tests.js new file mode 100644 index 00000000000..cafdfbbab01 --- /dev/null +++ b/addons/mail/static/tests/discuss/pinned_messages_tests.js @@ -0,0 +1,147 @@ +/** @odoo-module */ + +import { click, start, startServer } from "@mail/../tests/helpers/test_utils"; +import { nextTick, patchWithCleanup } from "@web/../tests/helpers/utils"; + +QUnit.module("pinned messages"); + +QUnit.test("Pin message", async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ name: "General" }); + pyEnv["mail.message"].create({ + body: "Hello world!", + model: "mail.channel", + res_id: channelId, + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Discuss-header button[title='Pinned messages']"); + assert.containsOnce( + $, + ".o-mail-PinnedMessagesPanel:contains(This channel doesn't have any pinned messages.)" + ); + await click(".o-mail-Message [title='Expand']"); + await click(".dropdown-item:contains(Pin)"); + await click(".modal-footer button:contains(Confirm)"); + assert.containsOnce($, ".o-mail-PinnedMessagesPanel .o-mail-Message:contains(Hello world)"); +}); + +QUnit.test("Unpin message", async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ name: "General" }); + pyEnv["mail.message"].create({ + body: "Hello world!", + model: "mail.channel", + res_id: channelId, + pinned_at: "2023-03-30 11:27:11", + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Discuss-header button[title='Pinned messages']"); + assert.containsOnce($, ".o-mail-PinnedMessagesPanel .o-mail-Message"); + await click(".o-mail-Message [title='Expand']"); + await click(".dropdown-item:contains(Unpin)"); + await click(".modal-footer button:contains(Confirm)"); + assert.containsNone($, ".o-mail-PinnedMessagesPanel .o-mail-Message"); +}); + +QUnit.test("Deleted messages are not pinned", async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ name: "General" }); + pyEnv["mail.message"].create({ + body: "Hello world!", + message_type: "comment", + model: "mail.channel", + res_id: channelId, + pinned_at: "2023-03-30 11:27:11", + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Discuss-header button[title='Pinned messages']"); + assert.containsOnce($, ".o-mail-PinnedMessagesPanel .o-mail-Message"); + await click(".o-mail-Message [title='Expand']"); + await click(".dropdown-item:contains(Delete)"); + await click("button:contains(Confirm)"); + assert.containsNone($, ".o-mail-PinnedMessagesPanel .o-mail-Message"); +}); + +QUnit.test("Open pinned panel from notification", async (assert) => { + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ name: "General" }); + pyEnv["mail.message"].create({ + body: "Hello world!", + model: "mail.channel", + res_id: channelId, + }); + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Message:eq(0) [title='Expand']"); + await click(".dropdown-item:contains(Pin)"); + await click(".modal-footer button:contains(Confirm)"); + assert.containsNone($, ".o-mail-PinnedMessagesPanel"); + await click(".o_mail_notification a:contains(See all pinned messages)"); + assert.containsOnce($, ".o-mail-PinnedMessagesPanel"); +}); + +QUnit.test("Jump to message", async (assert) => { + // make scroll behavior instantaneous. + patchWithCleanup(Element.prototype, { + scrollIntoView() { + return this._super(true); + }, + }); + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ name: "General" }); + pyEnv["mail.message"].create({ + body: "Hello world!", + model: "mail.channel", + res_id: channelId, + pinned_at: "2023-04-03 08:15:04", + }); + for (let i = 0; i < 20; i++) { + pyEnv["mail.message"].create({ + body: "Non Empty Body ".repeat(25), + message_type: "comment", + model: "mail.channel", + res_id: channelId, + }); + } + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Discuss-header button[title='Pinned messages']"); + await click(".o-mail-PinnedMessagesPanel button:contains(Jump)"); + await nextTick(); + assert.isVisible($(".o-mail-Message:contains(Hello world!)")); +}); + +QUnit.test("Jump to message from notification", async (assert) => { + // make scroll behavior instantaneous. + patchWithCleanup(Element.prototype, { + scrollIntoView() { + return this._super(true); + }, + }); + const pyEnv = await startServer(); + const channelId = pyEnv["mail.channel"].create({ name: "General" }); + pyEnv["mail.message"].create({ + body: "Hello world!", + model: "mail.channel", + res_id: channelId, + }); + for (let i = 0; i < 20; i++) { + pyEnv["mail.message"].create({ + body: "Non Empty Body ".repeat(25), + message_type: "comment", + model: "mail.channel", + res_id: channelId, + }); + } + const { openDiscuss } = await start(); + await openDiscuss(channelId); + await click(".o-mail-Message:eq(0) [title='Expand']"); + await click(".dropdown-item:contains(Pin)"); + await click(".modal-footer button:contains(Confirm)"); + await click(".o_mail_notification a:contains(message)"); + await nextTick(); + assert.isVisible($(".o-mail-Message:contains(Hello world!)")); +}); 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 585f1e6d640..e49443852c8 100644 --- a/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js +++ b/addons/mail/static/tests/helpers/mock_server/controllers/discuss.js @@ -40,8 +40,12 @@ patch(MockServer.prototype, "mail/controllers/discuss", { return this._mockRouteMailAttachmentRemove(attachment_id); } if (route === "/mail/channel/messages") { - const { channel_id, after, before, limit } = args; - return this._mockRouteMailChannelMessages(channel_id, before, after, limit); + const { channel_id, after, around, before, limit } = args; + return this._mockRouteMailChannelMessages(channel_id, before, after, around, limit); + } + if (route === "/mail/channel/pinned_messages") { + const { channel_id } = args; + return this._mockRouteMailChannelPins(channel_id); } if (route === "/mail/channel/notify_typing") { const id = args.channel_id; @@ -57,7 +61,7 @@ patch(MockServer.prototype, "mail/controllers/discuss", { } if (route === "/mail/channel/members") { const { channel_id, known_member_ids } = args; - return this._mockMailChannelLoadMoreMembers([channel_id], known_member_ids); + return this._mockMailChannelloadOlderMembers([channel_id], known_member_ids); } if (route === "/mail/history/messages") { const { after, before, limit } = args; @@ -67,8 +71,8 @@ patch(MockServer.prototype, "mail/controllers/discuss", { return this._mockRouteMailInitMessaging(); } if (route === "/mail/inbox/messages") { - const { after, before, limit } = args; - return this._mockRouteMailMessageInbox(after, before, limit); + const { after, around, before, limit } = args; + return this._mockRouteMailMessageInbox(after, before, around, limit); } if (route === "/mail/link_preview") { return this._mockRouteMailLinkPreview(args.message_id); @@ -153,17 +157,29 @@ patch(MockServer.prototype, "mail/controllers/discuss", { ); } if (route === "/mail/thread/messages") { - const { after, before, limit, thread_model, thread_id } = args; + const { after, around, before, limit, thread_model, thread_id } = args; return this._mockRouteMailThreadFetchMessages( thread_model, thread_id, before, after, + around, limit ); } return this._super(route, args); }, + /** + * Simulates the `/mail/channel/pinned_messages` route. + */ + _mockRouteMailChannelPins(channel_id) { + const messageIds = this.pyEnv["mail.message"].search([ + ["model", "=", "mail.channel"], + ["res_id", "=", channel_id], + ["pinned_at", "!=", false], + ]); + return this._mockMailMessageMessageFormat(messageIds); + }, /** * Simulates the `/mail/init_messaging` route. * @@ -193,16 +209,25 @@ patch(MockServer.prototype, "mail/controllers/discuss", { * @param {integer} limit * @param {integer} before * @param {integer} after + * @param {integer} around * @returns {Object} list of messages */ - async _mockRouteMailChannelMessages(channel_id, before = false, after = false, limit = 30) { + async _mockRouteMailChannelMessages( + channel_id, + before = false, + after = false, + around = false, + limit = 30 + ) { const domain = [ ["res_id", "=", channel_id], ["model", "=", "mail.channel"], ["message_type", "!=", "user_notification"], ]; - const messages = this._mockMailMessage_MessageFetch(domain, before, after, limit); - this._mockMailMessageSetMessageDone(messages.map((message) => message.id)); + const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit); + if (!around) { + this._mockMailMessageSetMessageDone(messages.map((message) => message.id)); + } return this._mockMailMessageMessageFormat(messages.map((message) => message.id)); }, /** @@ -299,7 +324,7 @@ patch(MockServer.prototype, "mail/controllers/discuss", { */ _mockRouteMailMessageHistory(after = false, before = false, limit = 30) { const domain = [["needaction", "=", false]]; - const messages = this._mockMailMessage_MessageFetch(domain, before, after, limit); + const messages = this._mockMailMessage_MessageFetch(domain, before, after, false, limit); const messagesWithNotification = messages.filter((message) => { const notifs = this.pyEnv["mail.notification"].searchRead([ ["mail_message_id", "=", message.id], @@ -319,9 +344,9 @@ patch(MockServer.prototype, "mail/controllers/discuss", { * @private * @returns {Object} */ - _mockRouteMailMessageInbox(after = false, before = false, limit = 30) { + _mockRouteMailMessageInbox(after = false, before = false, around = false, limit = 30) { const domain = [["needaction", "=", true]]; - const messages = this._mockMailMessage_MessageFetch(domain, before, after, limit); + const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit); return this._mockMailMessageMessageFormat(messages.map((message) => message.id)); }, /** @@ -332,7 +357,7 @@ patch(MockServer.prototype, "mail/controllers/discuss", { */ _mockRouteMailMessageStarredMessages(after = false, before = false, limit = 30) { const domain = [["starred_partner_ids", "in", [this.currentPartnerId]]]; - const messages = this._mockMailMessage_MessageFetch(domain, before, after, limit); + const messages = this._mockMailMessage_MessageFetch(domain, before, after, false, limit); return this._mockMailMessageMessageFormat(messages.map((message) => message.id)); }, /** @@ -524,6 +549,7 @@ patch(MockServer.prototype, "mail/controllers/discuss", { res_id, before = false, after = false, + around = false, limit = 30 ) { const domain = [ @@ -531,7 +557,7 @@ patch(MockServer.prototype, "mail/controllers/discuss", { ["model", "=", res_model], ["message_type", "!=", "user_notification"], ]; - const messages = this._mockMailMessage_MessageFetch(domain, before, after, limit); + const messages = this._mockMailMessage_MessageFetch(domain, before, after, around, limit); this._mockMailMessageSetMessageDone(messages.map((message) => message.id)); return this._mockMailMessageMessageFormat(messages.map((message) => message.id)); }, diff --git a/addons/mail/static/tests/helpers/mock_server/models/mail_channel.js b/addons/mail/static/tests/helpers/mock_server/models/mail_channel.js index 48147388210..d55ad18cd25 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/mail_channel.js +++ b/addons/mail/static/tests/helpers/mock_server/models/mail_channel.js @@ -5,6 +5,7 @@ import { MockServer } from "@web/../tests/helpers/mock_server"; import { datetime_to_str } from "web.time"; import { assignDefined } from "@mail/utils/misc"; +import { formatDate } from "@web/core/l10n/dates"; patch(MockServer.prototype, "mail/models/mail_channel", { async _performRPC(route, args) { @@ -30,6 +31,13 @@ patch(MockServer.prototype, "mail/models/mail_channel", { const groupId = args.args[1]; return this._mockMailChannelChannelCreate(name, groupId); } + if (args.model === "mail.channel" && args.method === "set_message_pin") { + return this._mockMailChannelSetMessagePin( + args.args[0], + args.kwargs.message_id, + args.kwargs.pinned + ); + } if (args.model === "mail.channel" && args.method === "channel_get") { const partners_to = args.args[0] || args.kwargs.partners_to; const pin = @@ -95,7 +103,7 @@ patch(MockServer.prototype, "mail/models/mail_channel", { if (args.model === "mail.channel" && args.method === "load_more_members") { const [channel_ids] = args.args; const { known_member_ids } = args.kwargs; - return this._mockMailChannelLoadMoreMembers(channel_ids, known_member_ids); + return this._mockMailChannelloadOlderMembers(channel_ids, known_member_ids); } if (args.model === "mail.channel" && args.method === "get_mention_suggestions") { return this._mockMailChannelGetMentionSuggestions(args); @@ -263,6 +271,35 @@ patch(MockServer.prototype, "mail/models/mail_channel", { }); } }, + /** + * Simulates `set_message_pin` on `mail.channel`. + * + * @param {number} ids + * @param {number} message_id + * @param {boolean} pinned + */ + _mockMailChannelSetMessagePin(id, message_id, pinned) { + const pinnedAt = pinned ? formatDate(luxon.DateTime.now()) : false; + this.pyEnv["mail.message"].write([message_id], { + pinned_at: pinnedAt, + }); + const notification = `
+ ${this.pyEnv.currentPartner.display_name} pinned a + message to this channel. + See all pinned messages +
`; + this._mockMailChannelMessagePost(id, { + body: notification, + message_type: "notification", + subtype_xmlid: "mail.mt_comment", + }); + this.pyEnv["bus.bus"]._sendone(id, "mail.record/insert", { + Message: { + id: message_id, + pinned_at: pinnedAt, + }, + }); + }, /** * Simulates `_broadcast` on `mail.channel`. * @@ -868,7 +905,7 @@ patch(MockServer.prototype, "mail/models/mail_channel", { * @param {integer[]} channel_ids * @param {integer[]} known_member_ids */ - _mockMailChannelLoadMoreMembers(channel_ids, known_member_ids) { + _mockMailChannelloadOlderMembers(channel_ids, known_member_ids) { const members = this.pyEnv["mail.channel.member"].searchRead( [ ["id", "not in", known_member_ids], diff --git a/addons/mail/static/tests/helpers/mock_server/models/mail_message.js b/addons/mail/static/tests/helpers/mock_server/models/mail_message.js index ee80556c3a4..727bcba8b08 100644 --- a/addons/mail/static/tests/helpers/mock_server/models/mail_message.js +++ b/addons/mail/static/tests/helpers/mock_server/models/mail_message.js @@ -139,7 +139,24 @@ patch(MockServer.prototype, "mail/models/mail_message", { * @param {integer} [limit=30] * @returns {Object[]} */ - _mockMailMessage_MessageFetch(domain, before, after, limit = 30) { + _mockMailMessage_MessageFetch(domain, before, after, around, limit = 30) { + if (around) { + const messagesBefore = this._mockMailMessage_MessageFetch( + domain.concat([["id", "<=", around]]), + before, + after, + false, + limit / 2 + ); + const messagesAfter = this._mockMailMessage_MessageFetch( + domain.concat([["id", ">", around]]), + before, + after, + false, + limit / 2 + ); + return messagesAfter.concat(messagesBefore.reverse()); + } if (before) { domain.push(["id", "<", before]); } @@ -255,8 +272,12 @@ patch(MockServer.prototype, "mail/models/mail_message", { attachment_ids: formattedAttachments, author: formattedAuthor, history_partner_ids: historyPartnerIds, - default_subject: message.model && message.res_id && - this.mockMailThread_MessageComputeSubject(message.model, [message.res_id]).get(message.res_id), + default_subject: + message.model && + message.res_id && + this.mockMailThread_MessageComputeSubject(message.model, [message.res_id]).get( + message.res_id + ), linkPreviews: linkPreviewsFormatted, messageReactionGroups: reactionGroups, needaction_partner_ids: needactionPartnerIds, @@ -268,6 +289,7 @@ patch(MockServer.prototype, "mail/models/mail_message", { record_name: thread && (thread.name !== undefined ? thread.name : thread.display_name), trackingValues: formattedTrackingValues, + pinned_at: message.pinned_at, }); delete response["author_id"]; if (message.subtype_id) { diff --git a/addons/mail/static/tests/helpers/model_definitions_setup.js b/addons/mail/static/tests/helpers/model_definitions_setup.js index 4cfe51defc1..5de0ab2517d 100644 --- a/addons/mail/static/tests/helpers/model_definitions_setup.js +++ b/addons/mail/static/tests/helpers/model_definitions_setup.js @@ -85,6 +85,7 @@ insertModelFields("mail.message", { string: "Partners with History", type: "many2many", }, + pinned_at: { default: () => false }, is_discussion: { string: "Discussion", type: "boolean" }, is_note: { string: "Discussion", type: "boolean" }, needaction_partner_ids: { diff --git a/addons/mail/static/tests/message/message_reply_tests.js b/addons/mail/static/tests/message/message_reply_tests.js index 78ee73a43c1..71c18027c8c 100644 --- a/addons/mail/static/tests/message/message_reply_tests.js +++ b/addons/mail/static/tests/message/message_reply_tests.js @@ -60,14 +60,5 @@ QUnit.test("click on message in reply to scroll to the parent message", async (a ".o-mail-Message:contains(Response to first message) .o-mail-MessageInReply-message" ); await nextTick(); - const thread = $(".o-mail-Thread")[0]; - const oldestMsg = $(".o-mail-Message:eq(0)")[0]; - const oldestMsgTop = oldestMsg.offsetTop; - const oldestMsgBottom = oldestMsgTop + oldestMsg.offsetHeight; - const threadTop = thread.offsetTop; - const threadBottom = threadTop + thread.offsetHeight; - assert.ok( - oldestMsgBottom <= threadBottom && oldestMsgTop >= threadTop, - "Should have scrolled to oldest message" - ); + assert.isVisible($(".o-mail-Message:eq(0)")); }); diff --git a/addons/mail/static/tests/message/message_tests.js b/addons/mail/static/tests/message/message_tests.js index c1173f81a7b..4b63f037ff1 100644 --- a/addons/mail/static/tests/message/message_tests.js +++ b/addons/mail/static/tests/message/message_tests.js @@ -372,8 +372,7 @@ QUnit.test("Deleting parent message of a reply should adapt reply visual", async await triggerHotkey("Enter", false); await click(".o-mail-Message [title='Expand']"); await click(".o-mail-Message [title='Delete']"); - $('button:contains("Delete")').click(); - await nextTick(); + await click("button:contains(Confirm)"); assert.containsOnce($, ".o-mail-MessageInReply:contains(Original message was deleted)"); }); diff --git a/addons/mail/tests/test_rtc.py b/addons/mail/tests/test_rtc.py index 0016ec4ac0c..8b222039c62 100644 --- a/addons/mail/tests/test_rtc.py +++ b/addons/mail/tests/test_rtc.py @@ -109,7 +109,6 @@ class TestChannelInternals(MailCommon): with self.assertBus( [ (self.cr.dbname, 'mail.channel', channel.id), # update new session - (self.cr.dbname, 'res.partner', test_user.partner_id.id), # mark below message as read (not asserted below) (self.cr.dbname, 'mail.channel', channel.id), # message_post "started a live conference" (not asserted below) (self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # update of last interest (not asserted below) (self.cr.dbname, 'res.partner', test_user.partner_id.id), # update of last interest (not asserted below) @@ -195,7 +194,6 @@ class TestChannelInternals(MailCommon): with self.assertBus( [ (self.cr.dbname, 'mail.channel', channel.id), # update new session - (self.cr.dbname, 'res.partner', test_user.partner_id.id), # mark below message as read (not asserted below) (self.cr.dbname, 'mail.channel', channel.id), # message_post "started a live conference" (not asserted below) (self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # update of last interest (not asserted below) (self.cr.dbname, 'res.partner', test_user.partner_id.id), # update of last interest (not asserted below) @@ -666,12 +664,10 @@ class TestChannelInternals(MailCommon): found_bus_notifs = self.assertBusNotifications( [ (self.cr.dbname, 'res.partner', test_user.partner_id.id), # channel joined (not asserted below) - (self.cr.dbname, 'res.partner', test_user.partner_id.id), # mark below message as read (not asserted below) (self.cr.dbname, 'mail.channel', channel.id), # message_post "invited" (not asserted below) (self.cr.dbname, 'res.partner', self.user_employee.partner_id.id), # update of last interest (not asserted below) (self.cr.dbname, 'res.partner', test_user.partner_id.id), # update of last interest (not asserted below) (self.cr.dbname, 'mail.channel', channel.id), # new members (not asserted below) - (self.cr.dbname, 'res.partner', test_user.partner_id.id), # mark below message as read (not asserted below) (self.cr.dbname, 'res.partner', test_user.partner_id.id), # incoming invitation (self.cr.dbname, 'mail.guest', test_guest.id), # incoming invitation (self.cr.dbname, 'mail.channel', channel.id), # update list of invitations