[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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
+5
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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>:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user