[IMP] mail, im_livechat: add pin messages feature to channels

With this commit, messages can be pinned on a channel and
chat conversation. There's a new menu listing all pinned
messages on a conversation, and we can jump to these messages
in the current conversation.

As this feature incentives to see older messages, this commit
also adds a "Jump to Present" button when looking at old messages.

closes odoo/odoo#116666

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
tsm-odoo
2023-04-13 04:08:11 +02:00
committed by Alexandre Kühn
parent fc9d46f3b9
commit 1c1604466f
44 changed files with 1040 additions and 182 deletions
+1
View File
@@ -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,
+15 -9
View File
@@ -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()
+42
View File
@@ -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 = '''
<div data-oe-type="pin" class="o_mail_notification">
%(user_pinned_a_message_to_this_channel)s
<a href="#" data-oe-type="pin-menu">%(see_all_pins)s</a>
</div>
'''
notification = Markup(notification_text) % {
'user_pinned_a_message_to_this_channel': _(
Markup('%(user_name)s pinned a <a href="#" data-oe-type="highlight" data-oe-id="%(message_id)s">message</a> 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
+12 -4
View File
@@ -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
+3 -1
View File
@@ -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)
+4 -3
View File
@@ -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();
+1 -1
View File
@@ -16,7 +16,7 @@
<img class="o-mail-Composer-avatar mt-1 rounded-circle" t-att-src="threadService.avatarUrl(store.self, props.composer.thread)" alt="Avatar of user"/>
</div>
<div class="o-mail-Composer-coreHeader text-truncate small p-2" t-if="props.composer.thread and props.messageToReplyTo?.thread === props.composer.thread">
<span class="cursor-pointer" t-on-click="() => props.highlightReplyTo and props.highlightReplyTo(props.messageToReplyTo.message.id)">
<span class="cursor-pointer" t-on-click="() => env.messageHighlight?.highlightMessage(props.messageToReplyTo.message.id, props.composer.thread)">
Replying to <b t-esc="props.messageToReplyTo.message.author.name"/>
</span>
<span t-if="props.messageToReplyTo.message.originThread !== thread">
@@ -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());
}
}
@@ -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) {
@@ -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) {
+16 -1
View File
@@ -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} */
+150 -50
View File
@@ -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);
}
+26 -5
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+5 -3
View File
@@ -5,13 +5,14 @@
<ActionSwiper onRightSwipe="hasTouch() and isInInbox ? { action: () => this.messageService.setDone(this.message), bgColor: 'bg-success', icon: 'fa-check-circle' } : undefined">
<div class="o-mail-Message position-relative py-1"
t-att-class="{
'o-pinnedPanel': env.pinnedPanel,
'o-highlighted-from-mention': message.isHighlightedFromMention,
'o-highlighted bg-view shadow-lg': props.highlighted,
'o-selfAuthored': message.isSelfAuthored,
'o-selected': props.messageToReplyTo?.isSelected(props.thread, props.message),
'o-squashed pt-1': props.squashed,
'mt-1': !props.squashed and props.thread,
'px-3': !props.isInChatWindow,
'mt-1': !props.squashed and props.thread and !env.pinnedPanel,
'px-3': !props.isInChatWindow and !env.pinnedPanel,
'px-1': props.isInChatWindow,
'opacity-50': props.messageToReplyTo?.isNotSelected(props.thread, props.message),
}"
@@ -152,9 +153,10 @@
<t t-set-slot="default">
<DropdownItem t-if="message.reactions.length" class="'px-2 d-flex align-items-center'" title="'Reactions'" onSelected="() => this.openReactionMenu()"><i class="fa fa-lg fa-smile-o pe-2"/>View Reactions</DropdownItem>
<DropdownItem t-if="isInInbox" class="'px-2 d-flex align-items-center'" title="'Mark as Read'" onSelected="() => messageService.setDone(props.message)"><i class="fa fa-lg fa-check pe-2"/>Mark as Read</DropdownItem>
<DropdownItem t-if="store.user and props.thread.model === 'mail.channel'" class="'px-2 d-flex align-items-center'" title="pinOptionText" onSelected="() => this.onClickPin()"><i class="fa fa-lg fa-thumb-tack pe-2"/><t t-esc="pinOptionText"/></DropdownItem>
<DropdownItem t-if="props.thread.model === 'mail.channel'" class="'px-2 d-flex align-items-center'" title="'Mark as Unread'" onSelected="() => this.onClickMarkAsUnread()"><i class="fa fa-lg fa-eye-slash pe-2"/>Mark as Unread</DropdownItem>
<DropdownItem t-if="editable" class="'px-2 d-flex align-items-center'" title="'Edit'" onSelected="() => this.onClickEdit()"><i class="fa fa-lg fa-pencil pe-2"/>Edit</DropdownItem>
<DropdownItem t-if="deletable" class="'px-2 d-flex align-items-center'" title="'Delete'" onSelected="() => this.onClickDelete()"><i class="fa fa-lg fa-trash pe-2"/>Delete</DropdownItem>
<DropdownItem t-if="deletable" class="'px-2 d-flex align-items-center'" title="'Delete'" onSelected="() => this.onClickDelete()"><i class="fa fa-lg fa-trash pe-2"/>Delete</DropdownItem>
</t>
</Dropdown>
</div>
@@ -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();
}
}
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.MessageDeleteDialog" owl="1">
<t t-name="mail.MessageConfirmDialog" owl="1">
<Dialog size="'xl'" title="title">
<p class="mx-3 mb-3">Are you sure you want to delete this message?</p>
<p class="mx-3 mb-3" t-esc="props.prompt"/>
<blockquote class="mx-3 mb-3 fst-normal" style="pointer-events:none;">
<t t-component="props.messageComponent" message="props.message" hasActions="false"/>
</blockquote>
<t t-set-slot="footer">
<button class="btn btn-primary me-2" t-on-click="onClickDelete">Delete</button>
<button class="btn btn-primary me-2" t-on-click="onClickConfirm">Confirm</button>
<button class="btn btn-secondary me-2" t-on-click="props.close">Cancel</button>
</t>
</Dialog>
@@ -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() {
@@ -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,
}">
<small class="position-relative d-block text-small mb-1" t-attf-class="{{ env.inChatWindow and props.alignedRight ? 'justify-content-end pe-5': 'ps-5' }}" t-on-click="props.onClick">
<small class="position-relative d-block text-small mb-1" t-attf-class="{{ env.inChatWindow and props.alignedRight ? 'justify-content-end pe-5': 'ps-5' }}" t-on-click="() => this.props.onClick?.()">
<span class="o-mail-MessageInReply-corner position-absolute bottom-0 top-50 pe-4 border-top text-300" t-attf-class="{{ env.inChatWindow and props.alignedRight ? 'o-isRightAlign border-end' : 'o-isLeftAlign border-start' }}" t-att-class="{ 'ms-n2': store.discuss.isActive }"/>
<span t-if="!message.parentMessage.isEmpty" class="cursor-pointer d-flex align-items-center text-muted opacity-75 opacity-100-hover" t-attf-class="{{ env.inChatWindow and props.alignedRight ? 'pe-3': 'ps-3' }}">
<span t-if="!message.parentMessage.isEmpty" class="d-flex align-items-center text-muted opacity-75" t-att-class="{ 'cursor-pointer opacity-100-hover': props.onClick }" t-attf-class="{{ env.inChatWindow and props.alignedRight ? 'pe-3': 'ps-3' }}">
<img class="o-mail-MessageInReply-avatar me-2 rounded-circle" t-att-src="authorAvatarUrl" t-att-title="message.parentMessage.author.name" alt="Avatar"/>
<span class="o-mail-MessageInReply-content overflow-hidden">
<b>@<t t-out="message.parentMessage.author.name"/></b>:
+94 -20
View File
@@ -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;
@@ -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;
}
+37 -14
View File
@@ -1,16 +1,22 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.Thread" owl="1">
<div class="o-mail-Thread overflow-auto pb-4 flex-grow-1 bg-view" t-ref="messages">
<t t-if="!props.thread.isEmpty or props.thread.loadMore or props.thread.hasLoadingFailed" name="content">
<div class="d-flex flex-column" t-att-class="{'justify-content-end': !env.inChatter and props.thread.type !== 'mailbox'}" style="min-height:100%">
<t t-if="env.inChatter" t-call="mail.Thread.jumpPresent"/> <!-- chatter has its own scrollable, this ensures proper sticky showing -->
<div class="o-mail-Thread flex-grow-1 bg-view" t-att-class="{ 'overflow-auto': props.hasScrollAdjust, 'pb-4': !state.showJumpPresent }" t-ref="messages">
<t t-if="!props.thread.isEmpty or props.thread.loadOlder or props.thread.hasLoadingFailed" name="content">
<div class="d-flex flex-column position-relative" t-att-class="{'justify-content-end': !env.inChatter and props.thread.type !== 'mailbox'}" style="min-height:100%">
<span class="position-absolute w-100 invisible" t-att-class="props.order === 'asc' ? 'bottom-0' : 'top-0'" t-ref="present-treshold" t-att-style="`height: ${PRESENT_THRESHOLD}px`"/>
<t t-set="currentDay" t-value="0"/>
<t t-set="prevMsg" t-value="0"/>
<t t-if="props.order === 'asc'">
<t t-if="props.thread.loadMore and !props.thread.isTransient and !props.thread.hasLoadingFailed" t-call="mail.Thread.loadMore"/>
<t t-if="props.thread.loadOlder and !props.thread.isTransient and !props.thread.hasLoadingFailed" t-call="mail.Thread.loadOlder"/>
<t t-if="props.thread.hasLoadingFailed" t-call="mail.Thread.loadingError"/>
</t>
<t t-else="">
<span t-ref="load-newer"/>
<t t-if="!env.inChatter" t-call="mail.Thread.jumpPresent"/>
</t>
<t t-foreach="props.order === 'asc' ? props.thread.nonEmptyMessages : [...props.thread.nonEmptyMessages].reverse()" t-as="msg" t-key="msg.id">
<t t-if="msg.dateDay !== currentDay">
<div class="o-mail-Thread-date d-flex align-items-center fw-bolder pt-4">
@@ -33,15 +39,19 @@
message="msg"
messageToReplyTo="props.messageToReplyTo"
squashed="isSquashed(msg, prevMsg)"
highlighted="props.messageHighlight and props.messageHighlight.highlightedMessageId === msg.id"
onParentMessageClick="() => props.messageHighlight and msg.parentMessage and props.messageHighlight.highlightMessage(msg.parentMessage.id)"
highlighted="messageHighlight?.highlightedMessageId === msg.id"
onParentMessageClick="() => msg.parentMessage and env.messageHighlight?.highlightMessage(msg.parentMessage.id, props.thread)"
thread="props.thread"
messageEdition="props.messageEdition"
/>
<t t-set="prevMsg" t-value="msg"/>
</t>
<t t-if="props.order !== 'asc'">
<t t-if="props.thread.loadMore and !props.thread.isTransient and !props.thread.hasLoadingFailed" t-call="mail.Thread.loadMore"/>
<t t-if="props.order === 'asc'">
<span t-ref="load-newer"/>
<t t-call="mail.Thread.jumpPresent"/>
</t>
<t t-else="">
<t t-if="props.thread.loadOlder and !props.thread.isTransient and !props.thread.hasLoadingFailed" t-call="mail.Thread.loadOlder"/>
<t t-if="props.thread.hasLoadingFailed" t-call="mail.Thread.loadingError"/>
</t>
</div>
@@ -79,8 +89,17 @@
</div>
</t>
<t t-name="mail.Thread.loadMore" owl="1">
<button class="btn btn-link" t-on-click="onClickLoadMore" t-ref="load-more">Load More</button>
<t t-name="mail.Thread.jumpPresent" owl="1">
<span t-if="state.showJumpPresent" class="o-mail-Thread-jumpPresent position-sticky btn btn-link alert alert-info d-flex cursor-pointer align-items-center py-2 m-0" t-att-class="{ 'px-4': !env.inChatWindow, 'px-2': env.inChatWindow, 'top-0': props.order !== 'asc', 'bottom-0': props.order === 'asc' }" role="button" t-on-click="onClickJumpPresent">
<span class="small">You're viewing older messages</span>
<span class="flex-grow-1"/>
<span class="fw-bolder small pe-2">Jump to Present</span>
<i class="fa" t-att-class="{ 'fa-caret-up': props.order !== 'asc', 'fa-caret-down': props.order === 'asc' }"/>
</span>
</t>
<t t-name="mail.Thread.loadOlder" owl="1">
<button class="btn btn-link" t-on-click="onClickLoadOlder" t-ref="load-older">Load More</button>
</t>
<t t-name="mail.Thread.loadingError" owl="1">
@@ -88,15 +107,19 @@
<div class="o-mail-Thread-error">
An error occurred while fetching messages.
</div>
<button class="btn btn-link" t-on-click="onClickLoadMore">
<button class="btn btn-link" t-on-click="onClickLoadOlder">
Click here to retry
</button>
</div>
</t>
<t t-name="mail.NotificationMessage" owl="1">
<div class="d-flex justify-content-center text-500 small px-3">
<span class="d-inline mx-1" t-esc="msg.author.name"/> <t t-out="msg.body"/>
<div class="mx-auto text-500 small px-3 text-center" t-on-click="onClickNotification" t-att-class="{
'o-mail-Thread-noPinnedPanel': env.inChatWindow and !env.isSmall,
'mt-2': prevMsg and !prevMsg.isNotification,
}">
<i t-if="msg.notificationIcon" t-attf-class="{{ msg.notificationIcon }} me-1"/>
<span t-if="!msg.body.includes(escape(msg.author.name))" class="d-inline mx-1" t-esc="msg.author.name"/> <t t-out="msg.body"/>
</div>
</t>
+22 -1
View File
@@ -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();
+6 -2
View File
@@ -28,6 +28,9 @@
<div class="o-mail-Discuss-header flex-shrink-0 d-flex align-items-center ms-1">
<button t-if="thread.id === 'inbox'" class="btn btn-secondary" t-att-disabled="thread.isEmpty" t-on-click="markAllAsRead">Mark all read</button>
<button t-if="thread.id === 'starred'" class="btn btn-secondary" t-att-disabled="thread.isEmpty" t-on-click="messageService.unstarAll">Unstar all</button>
<button t-if="thread.model === 'mail.channel'" class="btn rounded px-2 shadow-none" t-att-class="{ 'o-isActive': state.activeMode === MODES.PINNED_MESSAGES }" title="Pinned messages" t-on-click.stop="togglePinMenu">
<i class="fa fa-lg fa-thumb-tack text-700"/>
</button>
<button t-if="thread.allowCalls and thread.type !== 'mailbox' and thread !== rtc.state.channel" class="btn rounded px-2 shadow-none" title="Start a Call" t-on-click="() => this.rtc.toggleCall(this.thread)">
<i class="fa fa-lg fa-phone text-700"/>
</button>
@@ -57,9 +60,10 @@
<div class="overflow-auto bg-100 d-flex flex-grow-1">
<div class="bg-white d-flex flex-column flex-grow-1">
<Call t-if="Object.keys(thread.rtcSessions).length > 0" thread="thread"/>
<Thread thread="thread" t-key="thread.localId" messageEdition="messageEdition" messageHighlight="messageHighlight" messageToReplyTo="messageToReplyTo"/>
<Composer t-if="thread.type !== 'mailbox' or messageToReplyTo.thread === thread" t-key="thread.localId" composer="thread.composer" autofocus="true" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo" highlightReplyTo="messageHighlight.highlightMessage" onDiscardCallback="() => messageToReplyTo.cancel()" dropzoneRef="contentRef"/>
<Thread thread="thread" t-key="thread.localId" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo"/>
<Composer t-if="thread.type !== 'mailbox' or messageToReplyTo.thread === thread" t-key="thread.localId" composer="thread.composer" autofocus="true" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo" onDiscardCallback="() => messageToReplyTo.cancel()" dropzoneRef="contentRef"/>
</div>
<PinnedMessagesPanel t-if="state.activeMode === MODES.PINNED_MESSAGES and thread.model === 'mail.channel'" thread="thread"/>
<ChannelMemberList t-if="thread.hasMemberList and state.activeMode === MODES.MEMBER_LIST" thread="thread" className="'o-mail-Discuss-channelMemberList flex-shrink-0 border-start'"/>
<CallSettings t-if="thread.allowCalls and state.activeMode === MODES.SETTINGS" thread="thread" className="'o-mail-Discuss-callSettings border-start'"/>
</div>
@@ -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.");
}
}
}
@@ -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;
}
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mail.PinnedMessagesPanel" owl="1">
<div class="o-mail-PinnedMessagesPanel overflow-auto d-flex flex-column flex-shrink-0 bg-100 position-relative border-start" t-att-class="{
'flex-grow-1': env.inChatWindow,
}">
<p class="o-mail-PinnedMessagesPanel-title position-sticky top-0 py-3 mb-0 fw-bolder text-center text-uppercase text-700 bg-100">
Pinned messages
</p>
<t t-set="pinnedMessages" t-value="props.thread.pinnedMessages"/>
<div class="d-flex flex-column" t-att-class="{ 'justify-content-center flex-grow-1': pinnedMessages.length === 0 }" t-ref="message-list">
<div class="card m-3 mt-0" t-foreach="pinnedMessages" t-as="message" t-key="message.id">
<div class="card-body">
<div class="d-flex justify-content-end">
<button class="o-mail-PinnedMessagesPanel-jump btn rounded bg-400 badge opacity-0 flex-shrink-0" t-att-class="{ 'opacity-100 py-1 px-2': env.isSmall }" t-on-click="() => this.onClickJump(message)">Jump</button>
<button class="btn ms-2 p-0 text-700 shadow-none" t-att-class="{ 'fs-5 align-self-end': env.isSmall }" title="Unpin"
t-on-click="() => this.onClickUnpin(message)">
<i class="d-block fa fa-times"/>
</button>
</div>
<Message hasActions="false" message="message" thread="props.thread"/>
</div>
</div>
<p t-if="pinnedMessages.length === 0" t-esc="emptyMessage" class="text-center fst-italic text-500 fs-6"/>
</div>
</div>
</t>
</templates>
+6 -3
View File
@@ -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) {
@@ -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",
});
@@ -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";
}
@@ -21,10 +21,13 @@
'ms-3 me-1 my-0': !thread,
}"
/>
<div t-if="thread and thread.isChannel and threadService.unreadCounter(thread) > 0" class="mx-1 my-0">
(<t t-out="threadService.unreadCounter(thread)"/>)
<div t-if="thread and thread.isChannel and thread.message_unread_counter > 0" class="mx-1 my-0">
(<t t-out="thread.message_unread_counter"/>)
</div>
<div class="flex-grow-1"/>
<div t-if="thread?.model === 'mail.channel' and env.isSmall" class="o-mail-ChatWindow-command d-flex align-items-center h-100 px-3 py-0 m-0 opacity-50 opacity-100-hover" title="Pinned messages" t-on-click.stop="togglePinMenu">
<i class="fa fa-lg fa-thumb-tack"/>
</div>
<div t-if="thread and thread.allowCalls and thread !== rtc.state.channel and !props.chatWindow.hidden" class="o-mail-ChatWindow-command d-flex align-items-center h-100 px-3 py-0 m-0 opacity-50 opacity-100-hover" title="Start a Call" t-on-click.stop="() => this.rtc.toggleCall(this.props.chatWindow.thread)">
<i class="fa fa-phone"/>
</div>
@@ -48,13 +51,14 @@
</div>
<div t-if="!props.chatWindow.folded" class="o-mail-ChatWindow-content bg-view d-flex flex-column h-100 overflow-auto border-start border-end" t-ref="content">
<t t-if="thread">
<CallSettings t-if="state.activeMode === 'in-settings'" thread="thread" className="'flex-grow-1'"/>
<PinnedMessagesPanel t-if="state.activeMode === 'pinned-messages'" thread="thread"/>
<CallSettings t-elif="state.activeMode === 'in-settings'" thread="thread" className="'flex-grow-1'"/>
<ChannelMemberList t-elif="state.activeMode === 'member-list'" thread="thread" className="'o-mail-Discuss-channelMemberList flex-shrink-0 border-start w-100 h-100'"/>
<ChannelInvitation t-elif="state.activeMode === 'add-users'" thread="thread" chatState="state"/>
<t t-else="">
<Call t-if="Object.keys(thread.rtcSessions).length > 0" thread="thread" compact="true"/>
<Thread isInChatWindow="true" thread="thread" t-key="thread.localId" messageEdition="messageEdition" messageHighlight="messageHighlight" messageToReplyTo="messageToReplyTo"/>
<Composer composer="thread.composer" autofocus="props.chatWindow.autofocus" mode="'compact'" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo" highlightReplyTo="messageHighlight.highlightMessage" dropzoneRef="contentRef"/>
<Thread isInChatWindow="true" thread="thread" t-key="thread.localId" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo"/>
<Composer composer="thread.composer" autofocus="props.chatWindow.autofocus" mode="'compact'" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo" dropzoneRef="contentRef"/>
</t>
</t>
<div t-else="" class="d-flex align-items-center m-3">
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="mail.Chatter" owl="1">
<div class="o-mail-Chatter bg-view h-100 flex-grow-1 d-flex flex-column overflow-auto" t-att-class="props.threadId === false ? 'o-chatter-disabled' : ''" t-ref="root">
<div class="o-mail-Chatter bg-view h-100 flex-grow-1 d-flex flex-column" t-att-class="{ 'overflow-auto': props.hasMessageListScrollAdjust, 'o-chatter-disabled': props.threadId === false }" t-ref="root">
<div class="o-mail-Chatter-topbar d-flex flex-shrink-0 flex-grow-0">
<button t-if="props.hasMessageList" class="o-mail-Chatter-sendMessage btn text-nowrap me-2" t-att-class="{ 'btn-odoo': state.thread.composer.type !== 'note', 'btn-light': state.thread.composer.type === 'note', 'o-active': state.thread.composer.type === 'message', 'my-2': !props.compactHeight }" t-att-disabled="!state.thread.hasWriteAccess and !(state.thread.hasReadAccess and state.thread.canPostOnReadonly) and props.threadId" data-hotkey="m" t-on-click="() => this.toggleComposer('message')">
Send message
@@ -83,7 +83,7 @@
<Composer composer="state.thread.composer" autofocus="true" mode="'extended'" onPostCallback.bind="onPostCallback" placeholder="placeholder" dropzoneRef="rootRef" className="state.thread.composer.type === 'message' ? 'pt-0' : ''"/>
<hr/>
</t>
<div class="o-mail-Chatter-scrollable overflow-auto" t-ref="scrollable">
<div class="o-mail-Chatter-scrollable" t-att-class="{ 'overflow-auto': props.hasMessageListScrollAdjust }" t-ref="scrollable">
<t t-if="props.hasActivities and activities.length">
<t t-call="mail.ActivityList"/>
</t>
@@ -111,7 +111,7 @@
</FileUploader>
</div>
</div>
<Thread t-if="props.hasMessageList" thread="state.thread" t-key="state.thread.localId" order="'desc'" hasMessageScrollAdjustInChatter="props.hasMessageScrollAdjustInChatter"/>
<Thread t-if="props.hasMessageList" thread="state.thread" t-key="state.thread.localId" order="'desc'" hasScrollAdjust="!props.hasMessageListScrollAdjust"/>
</div>
</div>
</t>
@@ -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 @@
<img class="w-100 h-100 rounded-circle" t-att-src="thread.imgUrl" alt="Thread Image"/>
<ThreadIcon t-if="thread.type === 'chat' or (thread.type === 'channel' and !thread.authorizedGroupFullName)" thread="thread" size="'small'" className="'o-mail-DiscussCategoryItem-threadIcon position-absolute bottom-0 end-0 d-flex align-items-center justify-content-center rounded-circle bg-100'"/>
</div>
<span class="ms-3 me-2 text-truncate" t-att-class="{ 'o-item-unread fw-bolder': threadService.isUnread(thread) }">
<span class="ms-3 me-2 text-truncate" t-att-class="{ 'o-item-unread fw-bolder': thread.message_unread_counter > 0 }">
<t t-esc="thread.displayName"/>
</span>
<div class="flex-grow-1"/>
@@ -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),
@@ -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
);
});
@@ -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!)"));
});
@@ -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));
},
@@ -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 = `<div data-oe-type="pin" class="o_mail_notification">
${this.pyEnv.currentPartner.display_name} pinned a
<a href="#" data-oe-type="highlight" data-oe-id='${message_id}'>message</a> to this channel.
<a href="#" data-oe-type="pin-menu">See all pinned messages</a>
</div>`;
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],
@@ -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) {
@@ -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: {
@@ -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)"));
});
@@ -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)");
});
-4
View File
@@ -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