summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components/message/message.js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/message/message.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/message/message.js')
-rw-r--r--addons/mail/static/src/components/message/message.js680
1 files changed, 680 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js
new file mode 100644
index 00000000..a357c024
--- /dev/null
+++ b/addons/mail/static/src/components/message/message.js
@@ -0,0 +1,680 @@
+odoo.define('mail/static/src/components/message/message.js', function (require) {
+'use strict';
+
+const components = {
+ AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'),
+ MessageSeenIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'),
+ ModerationBanDialog: require('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js'),
+ ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'),
+ ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'),
+ NotificationPopover: require('mail/static/src/components/notification_popover/notification_popover.js'),
+ PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'),
+};
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js');
+const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js');
+
+const { _lt } = require('web.core');
+const { format } = require('web.field_utils');
+const { getLangDatetimeFormat } = require('web.time');
+
+const { Component, useState } = owl;
+const { useRef } = owl.hooks;
+
+const READ_MORE = _lt("read more");
+const READ_LESS = _lt("read less");
+const { isEventHandled, markEventHandled } = require('mail/static/src/utils/utils.js');
+
+class Message extends Component {
+
+ /**
+ * @override
+ */
+ constructor(...args) {
+ super(...args);
+ this.state = useState({
+ // Determine if the moderation ban dialog is displayed.
+ hasModerationBanDialog: false,
+ // Determine if the moderation discard dialog is displayed.
+ hasModerationDiscardDialog: false,
+ // Determine if the moderation reject dialog is displayed.
+ hasModerationRejectDialog: false,
+ /**
+ * Determine whether the message is clicked. When message is in
+ * clicked state, it keeps displaying the commands.
+ */
+ isClicked: false,
+ });
+ useShouldUpdateBasedOnProps();
+ useStore(props => {
+ const message = this.env.models['mail.message'].get(props.messageLocalId);
+ const author = message ? message.author : undefined;
+ const partnerRoot = this.env.messaging.partnerRoot;
+ const originThread = message ? message.originThread : undefined;
+ const threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId);
+ const thread = threadView ? threadView.thread : undefined;
+ return {
+ attachments: message
+ ? message.attachments.map(attachment => attachment.__state)
+ : [],
+ author,
+ authorAvatarUrl: author && author.avatarUrl,
+ authorImStatus: author && author.im_status,
+ authorNameOrDisplayName: author && author.nameOrDisplayName,
+ correspondent: thread && thread.correspondent,
+ hasMessageCheckbox: message ? message.hasCheckbox : false,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ isMessageChecked: message && threadView
+ ? message.isChecked(thread, threadView.stringifiedDomain)
+ : false,
+ message: message ? message.__state : undefined,
+ notifications: message ? message.notifications.map(notif => notif.__state) : [],
+ originThread,
+ originThreadModel: originThread && originThread.model,
+ originThreadName: originThread && originThread.name,
+ originThreadUrl: originThread && originThread.url,
+ partnerRoot,
+ thread,
+ threadHasSeenIndicators: thread && thread.hasSeenIndicators,
+ threadMassMailing: thread && thread.mass_mailing,
+ };
+ }, {
+ compareDepth: {
+ attachments: 1,
+ notifications: 1,
+ },
+ });
+ useUpdate({ func: () => this._update() });
+ /**
+ * The intent of the reply button depends on the last rendered state.
+ */
+ this._wasSelected;
+ /**
+ * Value of the last rendered prettyBody. Useful to compare to new value
+ * to decide if it has to be updated.
+ */
+ this._lastPrettyBody;
+ /**
+ * Reference to element containing the prettyBody. Useful to be able to
+ * replace prettyBody with new value in JS (which is faster than t-raw).
+ */
+ this._prettyBodyRef = useRef('prettyBody');
+ /**
+ * Reference to the content of the message.
+ */
+ this._contentRef = useRef('content');
+ /**
+ * To get checkbox state.
+ */
+ this._checkboxRef = useRef('checkbox');
+ /**
+ * Id of setInterval used to auto-update time elapsed of message at
+ * regular time.
+ */
+ this._intervalId = undefined;
+ this._constructor();
+ }
+
+ /**
+ * Allows patching constructor.
+ */
+ _constructor() {}
+
+ willUnmount() {
+ clearInterval(this._intervalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ get avatar() {
+ if (
+ this.message.author &&
+ this.message.author === this.env.messaging.partnerRoot
+ ) {
+ return '/mail/static/src/img/odoobot.png';
+ } else if (this.message.author) {
+ // TODO FIXME for public user this might not be accessible. task-2223236
+ // we should probably use the correspondig attachment id + access token
+ // or create a dedicated route to get message image, checking the access right of the message
+ return this.message.author.avatarUrl;
+ } else if (this.message.message_type === 'email') {
+ return '/mail/static/src/img/email_icon.png';
+ }
+ return '/mail/static/src/img/smiley/avatar.jpg';
+ }
+
+ /**
+ * Get the date time of the message at current user locale time.
+ *
+ * @returns {string}
+ */
+ get datetime() {
+ return this.message.date.format(getLangDatetimeFormat());
+ }
+
+ /**
+ * Determines whether author open chat feature is enabled on message.
+ *
+ * @returns {boolean}
+ */
+ get hasAuthorOpenChat() {
+ if (!this.message.author) {
+ return false;
+ }
+ if (
+ this.threadView &&
+ this.threadView.thread &&
+ this.threadView.thread.correspondent === this.message.author
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Tell whether the bottom of this message is visible or not.
+ *
+ * @param {Object} param0
+ * @param {integer} [offset=0]
+ * @returns {boolean}
+ */
+ isBottomVisible({ offset=0 } = {}) {
+ if (!this.el) {
+ return false;
+ }
+ const elRect = this.el.getBoundingClientRect();
+ if (!this.el.parentNode) {
+ return false;
+ }
+ const parentRect = this.el.parentNode.getBoundingClientRect();
+ // bottom with (double) 10px offset
+ return (
+ elRect.bottom < parentRect.bottom + offset &&
+ parentRect.top < elRect.bottom + offset
+ );
+ }
+
+ /**
+ * Tell whether the message is partially visible on browser window or not.
+ *
+ * @returns {boolean}
+ */
+ isPartiallyVisible() {
+ const elRect = this.el.getBoundingClientRect();
+ if (!this.el.parentNode) {
+ return false;
+ }
+ const parentRect = this.el.parentNode.getBoundingClientRect();
+ // intersection with 5px offset
+ return (
+ elRect.top < parentRect.bottom + 5 &&
+ parentRect.top < elRect.bottom + 5
+ );
+ }
+
+ /**
+ * @returns {mail.message}
+ */
+ get message() {
+ return this.env.models['mail.message'].get(this.props.messageLocalId);
+ }
+ /**
+ * @returns {string}
+ */
+ get OPEN_CHAT() {
+ return this.env._t("Open chat");
+ }
+
+ /**
+ * Make this message viewable in its enclosing scroll environment (usually
+ * message list).
+ *
+ * @param {Object} [param0={}]
+ * @param {string} [param0.behavior='auto']
+ * @param {string} [param0.block='end']
+ * @returns {Promise}
+ */
+ async scrollIntoView({ behavior = 'auto', block = 'end' } = {}) {
+ this.el.scrollIntoView({
+ behavior,
+ block,
+ inline: 'nearest',
+ });
+ if (behavior === 'smooth') {
+ return new Promise(resolve => setTimeout(resolve, 500));
+ } else {
+ return Promise.resolve();
+ }
+ }
+
+ /**
+ * Get the shorttime format of the message date.
+ *
+ * @returns {string}
+ */
+ get shortTime() {
+ return this.message.date.format('hh:mm');
+ }
+
+ /**
+ * @returns {mail.thread_view}
+ */
+ get threadView() {
+ return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId);
+ }
+
+ /**
+ * @returns {Object}
+ */
+ get trackingValues() {
+ return this.message.tracking_value_ids.map(trackingValue => {
+ const value = Object.assign({}, trackingValue);
+ value.changed_field = _.str.sprintf(this.env._t("%s:"), value.changed_field);
+ /**
+ * Maps tracked field type to a JS formatter. Tracking values are
+ * not always stored in the same field type as their origin type.
+ * Field types that are not listed here are not supported by
+ * tracking in Python. Also see `create_tracking_values` in Python.
+ */
+ switch (value.field_type) {
+ case 'boolean':
+ value.old_value = format.boolean(value.old_value, undefined, { forceString: true });
+ value.new_value = format.boolean(value.new_value, undefined, { forceString: true });
+ break;
+ /**
+ * many2one formatter exists but is expecting id/name_get or data
+ * object but only the target record name is known in this context.
+ *
+ * Selection formatter exists but requires knowing all
+ * possibilities and they are not given in this context.
+ */
+ case 'char':
+ case 'many2one':
+ case 'selection':
+ value.old_value = format.char(value.old_value);
+ value.new_value = format.char(value.new_value);
+ break;
+ case 'date':
+ if (value.old_value) {
+ value.old_value = moment.utc(value.old_value);
+ }
+ if (value.new_value) {
+ value.new_value = moment.utc(value.new_value);
+ }
+ value.old_value = format.date(value.old_value);
+ value.new_value = format.date(value.new_value);
+ break;
+ case 'datetime':
+ if (value.old_value) {
+ value.old_value = moment.utc(value.old_value);
+ }
+ if (value.new_value) {
+ value.new_value = moment.utc(value.new_value);
+ }
+ value.old_value = format.datetime(value.old_value);
+ value.new_value = format.datetime(value.new_value);
+ break;
+ case 'float':
+ value.old_value = format.float(value.old_value);
+ value.new_value = format.float(value.new_value);
+ break;
+ case 'integer':
+ value.old_value = format.integer(value.old_value);
+ value.new_value = format.integer(value.new_value);
+ break;
+ case 'monetary':
+ value.old_value = format.monetary(value.old_value, undefined, { forceString: true });
+ value.new_value = format.monetary(value.new_value, undefined, { forceString: true });
+ break;
+ case 'text':
+ value.old_value = format.text(value.old_value);
+ value.new_value = format.text(value.new_value);
+ break;
+ }
+ return value;
+ });
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Modifies the message to add the 'read more/read less' functionality
+ * All element nodes with 'data-o-mail-quote' attribute are concerned.
+ * All text nodes after a ``#stopSpelling`` element are concerned.
+ * Those text nodes need to be wrapped in a span (toggle functionality).
+ * All consecutive elements are joined in one 'read more/read less'.
+ *
+ * FIXME This method should be rewritten (task-2308951)
+ *
+ * @private
+ * @param {jQuery} $element
+ */
+ _insertReadMoreLess($element) {
+ const groups = [];
+ let readMoreNodes;
+
+ // nodeType 1: element_node
+ // nodeType 3: text_node
+ const $children = $element.contents()
+ .filter((index, content) =>
+ content.nodeType === 1 || (content.nodeType === 3 && content.nodeValue.trim())
+ );
+
+ for (const child of $children) {
+ let $child = $(child);
+
+ // Hide Text nodes if "stopSpelling"
+ if (
+ child.nodeType === 3 &&
+ $child.prevAll('[id*="stopSpelling"]').length > 0
+ ) {
+ // Convert Text nodes to Element nodes
+ $child = $('<span>', {
+ text: child.textContent,
+ 'data-o-mail-quote': '1',
+ });
+ child.parentNode.replaceChild($child[0], child);
+ }
+
+ // Create array for each 'read more' with nodes to toggle
+ if (
+ $child.attr('data-o-mail-quote') ||
+ (
+ $child.get(0).nodeName === 'BR' &&
+ $child.prev('[data-o-mail-quote="1"]').length > 0
+ )
+ ) {
+ if (!readMoreNodes) {
+ readMoreNodes = [];
+ groups.push(readMoreNodes);
+ }
+ $child.hide();
+ readMoreNodes.push($child);
+ } else {
+ readMoreNodes = undefined;
+ this._insertReadMoreLess($child);
+ }
+ }
+
+ for (const group of groups) {
+ // Insert link just before the first node
+ const $readMoreLess = $('<a>', {
+ class: 'o_Message_readMoreLess',
+ href: '#',
+ text: READ_MORE,
+ }).insertBefore(group[0]);
+
+ // Toggle All next nodes
+ let isReadMore = true;
+ $readMoreLess.click(e => {
+ e.preventDefault();
+ isReadMore = !isReadMore;
+ for (const $child of group) {
+ $child.hide();
+ $child.toggle(!isReadMore);
+ }
+ $readMoreLess.text(isReadMore ? READ_MORE : READ_LESS);
+ });
+ }
+ }
+
+ /**
+ * @private
+ */
+ _update() {
+ if (!this.message) {
+ return;
+ }
+ if (this._prettyBodyRef.el && this.message.prettyBody !== this._lastPrettyBody) {
+ this._prettyBodyRef.el.innerHTML = this.message.prettyBody;
+ this._lastPrettyBody = this.message.prettyBody;
+ }
+ // Remove all readmore before if any before reinsert them with _insertReadMoreLess.
+ // This is needed because _insertReadMoreLess is working with direct DOM mutations
+ // which are not sync with Owl.
+ if (this._contentRef.el) {
+ for (const el of [...this._contentRef.el.querySelectorAll(':scope .o_Message_readMoreLess')]) {
+ el.remove();
+ }
+ this._insertReadMoreLess($(this._contentRef.el));
+ this.env.messagingBus.trigger('o-component-message-read-more-less-inserted', {
+ message: this.message,
+ });
+ }
+ this._wasSelected = this.props.isSelected;
+ this.message.refreshDateFromNow();
+ clearInterval(this._intervalId);
+ this._intervalId = setInterval(() => {
+ this.message.refreshDateFromNow();
+ }, 60 * 1000);
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onChangeCheckbox() {
+ this.message.toggleCheck(this.threadView.thread, this.threadView.stringifiedDomain);
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClick(ev) {
+ if (ev.target.closest('.o_channel_redirect')) {
+ this.env.messaging.openProfile({
+ id: Number(ev.target.dataset.oeId),
+ model: 'mail.channel',
+ });
+ // avoid following dummy href
+ ev.preventDefault();
+ return;
+ }
+ if (ev.target.tagName === 'A') {
+ if (ev.target.dataset.oeId && ev.target.dataset.oeModel) {
+ this.env.messaging.openProfile({
+ id: Number(ev.target.dataset.oeId),
+ model: ev.target.dataset.oeModel,
+ });
+ // avoid following dummy href
+ ev.preventDefault();
+ }
+ return;
+ }
+ if (
+ !isEventHandled(ev, 'Message.ClickAuthorAvatar') &&
+ !isEventHandled(ev, 'Message.ClickAuthorName') &&
+ !isEventHandled(ev, 'Message.ClickFailure')
+ ) {
+ this.state.isClicked = !this.state.isClicked;
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAuthorAvatar(ev) {
+ markEventHandled(ev, 'Message.ClickAuthorAvatar');
+ if (!this.hasAuthorOpenChat) {
+ return;
+ }
+ this.message.author.openChat();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickAuthorName(ev) {
+ markEventHandled(ev, 'Message.ClickAuthorName');
+ if (!this.message.author) {
+ return;
+ }
+ this.message.author.openProfile();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickFailure(ev) {
+ markEventHandled(ev, 'Message.ClickFailure');
+ this.message.openResendAction();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationAccept(ev) {
+ ev.preventDefault();
+ this.message.moderate('accept');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationAllow(ev) {
+ ev.preventDefault();
+ this.message.moderate('allow');
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationBan(ev) {
+ ev.preventDefault();
+ this.state.hasModerationBanDialog = true;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationDiscard(ev) {
+ ev.preventDefault();
+ this.state.hasModerationDiscardDialog = true;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickModerationReject(ev) {
+ ev.preventDefault();
+ this.state.hasModerationRejectDialog = true;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickOriginThread(ev) {
+ // avoid following dummy href
+ ev.preventDefault();
+ this.message.originThread.open();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickStar(ev) {
+ ev.stopPropagation();
+ this.message.toggleStar();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMarkAsRead(ev) {
+ ev.stopPropagation();
+ this.message.markAsRead();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickReply(ev) {
+ // Use this._wasSelected because this.props.isSelected might be changed
+ // by a global capture click handler (for example the one from Composer)
+ // before the current handler is executed. Indeed because it does a
+ // toggle it needs to take into account the value before the click.
+ if (this._wasSelected) {
+ this.env.messaging.discuss.clearReplyingToMessage();
+ } else {
+ this.message.replyTo();
+ }
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationBan() {
+ this.state.hasModerationBanDialog = false;
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationDiscard() {
+ this.state.hasModerationDiscardDialog = false;
+ }
+
+ /**
+ * @private
+ */
+ _onDialogClosedModerationReject() {
+ this.state.hasModerationRejectDialog = false;
+ }
+
+}
+
+Object.assign(Message, {
+ components,
+ defaultProps: {
+ hasCheckbox: false,
+ hasMarkAsReadIcon: false,
+ hasReplyIcon: false,
+ isSelected: false,
+ isSquashed: false,
+ },
+ props: {
+ attachmentsDetailsMode: {
+ type: String,
+ optional: true,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ hasCheckbox: Boolean,
+ hasMarkAsReadIcon: Boolean,
+ hasReplyIcon: Boolean,
+ isSelected: Boolean,
+ isSquashed: Boolean,
+ messageLocalId: String,
+ threadViewLocalId: {
+ type: String,
+ optional: true,
+ },
+ },
+ template: 'mail.Message',
+});
+
+return Message;
+
+});