[IMP] mail: message action registry + reorder actions in inbox

Before this commit, message actions were all defined in template
of `Message` component. It's good when there are few actions and
the ordering of actions is always the same, but there are now
many actions and we want to order these actions depending on context.

This commit fixes the issue by introducing a special registry to add
message actions. This registry is quite similar to thread actions:
each item define conditions to be shown, behaviour on click, and
their sequence which can depend on the context where the message is
being viewed.

In addition to this message action registry, this commit also
reorder message actions in inbox as follow:

1. Quick actions:
 - Add a Reaction
 - Mark as To do
 - Mark as Read
2. More menu:
 - View reactions
 - Reply
 - Unfollow
 - Edit
 - Delete

Task-3458086

closes odoo/odoo#131426

Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Didier (did)
2023-09-05 18:44:24 +00:00
committed by Alexandre Kühn
parent 71705e4ea3
commit 6aa3a1e243
9 changed files with 210 additions and 47 deletions
+6 -16
View File
@@ -2,7 +2,6 @@
import { AttachmentList } from "@mail/core/common/attachment_list";
import { Composer } from "@mail/core/common/composer";
import { useEmojiPicker } from "@web/core/emoji_picker/emoji_picker";
import { ImStatus } from "@mail/core/common/im_status";
import { LinkPreviewList } from "@mail/core/common/link_preview_list";
import { MessageConfirmDialog } from "@mail/core/common/message_confirm_dialog";
@@ -34,6 +33,7 @@ import { _t } from "@web/core/l10n/translation";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
import { url } from "@web/core/utils/urls";
import { useMessageActions } from "./message_actions";
/**
* @typedef {Object} Props
@@ -91,6 +91,7 @@ export class Message extends Component {
this.root = useRef("root");
this.hasTouch = hasTouch;
this.messageBody = useRef("body");
this.messageActions = useMessageActions();
this.store = useState(useService("mail.store"));
this.rpc = useService("rpc");
this.threadService = useState(useService("mail.thread"));
@@ -116,21 +117,6 @@ export class Message extends Component {
this.root.el.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
this.emojiPickerRef = useRef("emoji-picker");
if (this.props.hasActions && this.canAddReaction) {
this.emojiPicker = useEmojiPicker(this.emojiPickerRef, {
onSelect: (emoji) => {
const reaction = this.message.reactions.find(
({ content, personas }) =>
content === emoji &&
personas.find((persona) => persona.eq(this.store.self))
);
if (!reaction) {
this.messageService.react(this.message, emoji);
}
},
});
}
onMounted(() => {
if (this.messageBody.el) {
$(this.messageBody.el).find(".o-mail-read-more-less").remove();
@@ -189,6 +175,10 @@ export class Message extends Component {
return this.props.message;
}
get quickActionCount() {
return this.env.inChatter ? 2 : 3;
}
get showSubtypeDescription() {
return (
this.message.subtypeDescription &&
+13 -12
View File
@@ -135,22 +135,23 @@
'flex-row-reverse': env.inChatWindow and isAlignedRight,
}"
>
<button t-if="canAddReaction and !isInInbox" class="btn px-1 py-0 rounded-0" tabindex="1" title="Add a Reaction" aria-label="Add a Reaction" t-ref="emoji-picker"><i class="fa fa-lg fa-smile-o"/></button>
<span t-else="" t-ref="emoji-picker"/> <!-- anchor of emoji picker when in dropdown -->
<button t-if="canReplyTo" class="btn px-1 py-0 rounded-0" t-on-click.stop="onClickReplyTo" tabindex="1" title="Reply" aria-label="Reply"><i class="fa fa-lg fa-reply"/></button>
<button t-if="canToggleStar" class="btn px-1 py-0 rounded-0" t-on-click="() => messageService.toggleStar(props.message)" tabindex="0" title="Mark as Todo" aria-label="Mark as Todo"><i class="fa fa-lg" t-att-class="message.isStarred ? 'fa-star o-mail-Message-starred' : 'fa-star-o'"/></button>
<button t-if="isInInbox" class="btn px-1 py-0 rounded-0" t-on-click.stop="() => messageService.setDone(props.message)" title="Mark as Read" aria-label="Mark as Read"><i class="fa fa-lg fa-check"/></button>
<Dropdown t-if="message.reactions.length or editable or deletable or (canAddReaction and isInInbox) or (props.thread?.model === 'discuss.channel' and store.user)" onStateChanged="state => this.state.expandOptions = state.open" position="props.message.eq(props.thread?.newestMessage) ? 'top-start' : 'bottom-start'" togglerClass="`btn p-0 ${ state.expandOptions ? 'bg-200' : '' }`" menuClass="'d-flex flex-column py-0 o-mail-Message-moreMenu'" class="'d-flex rounded-0'" title="expandText">
<t t-foreach="messageActions.actions.slice(0, quickActionCount)" t-as="action" t-key="action.id">
<t t-if="action.callComponent" t-component="action.callComponent" t-props="action.props"/>
<button t-else="" class="btn px-1 py-0 rounded-0" t-att-title="action.title" t-att-name="action.id" t-on-click.stop="action.onClick">
<i class="fa fa-lg" t-att-class="action.icon"/>
</button>
</t>
<Dropdown t-if="messageActions.actions.length gt quickActionCount" onStateChanged="state => this.state.expandOptions = state.open" position="props.message.eq(props.thread?.newestMessage) ? 'top-start' : 'bottom-start'" togglerClass="`btn p-0 rounded-0 ${ state.expandOptions ? 'bg-200' : '' }`" menuClass="'d-flex flex-column py-0 o-mail-Message-moreMenu'" class="'d-flex rounded-0'" title="expandText">
<t t-set-slot="toggler">
<i class="btn px-1 py-0 fa fa-lg fa-ellipsis-h rounded-0" t-att-class="{ 'order-1': props.isInChatWindow }" tabindex="1"/>
</t>
<t t-set-slot="default">
<DropdownItem t-if="message.reactions.length" class="'px-2 d-flex align-items-center rounded-0'" title="'Reactions'" onSelected="() => this.openReactionMenu()"><i class="fa fa-lg fa-fw fa-smile-o pe-2"/>View Reactions</DropdownItem>
<DropdownItem t-if="canAddReaction and isInInbox" class="'px-2 d-flex align-items-center rounded-0'" title="'Add a Reaction'" onSelected="() => this.emojiPickerRef.el.click()"><i class="fa fa-lg fa-fw fa-smile-o pe-2"/>Add a Reaction</DropdownItem>
<DropdownItem t-if="showUnfollow" class="'px-2 d-flex align-items-center rounded-0'" title="'Unfollow'" onSelected="() => messageService.unfollow(props.message)"><i class="fa fa-lg fa-fw fa-user-times pe-2"/>Unfollow</DropdownItem>
<DropdownItem t-if="props.thread.model === 'discuss.channel' and store.user" class="'px-2 d-flex align-items-center rounded-0'" title="'Mark as Unread'" onSelected="() => this.onClickMarkAsUnread()"><i class="fa fa-lg fa-fw fa-eye-slash pe-2"/>Mark as Unread</DropdownItem>
<DropdownItem t-if="editable" class="'px-2 d-flex align-items-center rounded-0'" title="'Edit'" onSelected="() => this.onClickEdit()"><i class="fa fa-lg fa-fw fa-pencil pe-2"/>Edit</DropdownItem>
<DropdownItem t-if="deletable" class="'px-2 d-flex align-items-center rounded-0'" title="'Delete'" onSelected="() => this.onClickDelete()"><i class="fa fa-lg fa-fw fa-trash pe-2"/>Delete</DropdownItem>
<t t-foreach="messageActions.actions.slice(quickActionCount)" t-as="action" t-key="action.id">
<DropdownItem class="'px-2 d-flex align-items-center rounded-0'" title="action.title" onSelected="action.onClick">
<i class="fa fa-lg fa-fw pe-2" t-att-class="action.icon"/>
<t t-esc="action.title"/>
</DropdownItem>
</t>
</t>
</Dropdown>
</div>
@@ -0,0 +1,131 @@
/* @odoo-module */
import { useComponent, useState } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { MessageReactionButton } from "./message_reaction_button";
export const messageActionsRegistry = registry.category("mail.message/actions");
messageActionsRegistry
.add("reaction", {
callComponent: MessageReactionButton,
props: (component) => ({ message: component.props.message }),
condition: (component) => component.canAddReaction,
sequence: 10,
})
.add("reply-to", {
condition: (component) => component.canReplyTo,
icon: "fa-reply",
title: _t("Reply"),
onClick: (component) => component.onClickReplyTo(),
sequence: (component) => (component.isInInbox ? 55 : 20),
})
.add("toggle-star", {
condition: (component) => component.canToggleStar,
icon: (component) =>
component.props.message.isStarred ? "fa-star o-mail-Message-starred" : "fa-star-o",
title: _t("Mark as Todo"),
onClick: (component) => component.messageService.toggleStar(component.props.message),
sequence: 30,
})
.add("mark-as-read", {
condition: (component) => component.isInInbox,
icon: "fa-check",
title: _t("Mark as Read"),
onClick: (component) => component.messageService.setDone(component.props.message),
sequence: 40,
})
.add("reactions", {
condition: (component) => component.message.reactions.length,
icon: "fa-smile-o",
title: _t("View Reactions"),
onClick: (component) => component.openReactionMenu(),
sequence: 50,
dropdown: true,
})
.add("unfollow", {
condition: (component) => component.showUnfollow,
icon: "fa-user-times",
title: _t("Unfollow"),
onClick: (component) => component.messageService.unfollow(component.props.message),
sequence: 60,
})
.add("mark-as-unread", {
condition: (component) =>
component.props.thread.model === "discuss.channel" && component.store.user,
icon: "fa-eye-slash",
title: _t("Mark as Unread"),
onClick: (component) => component.onClickMarkAsUnread(),
sequence: 70,
})
.add("edit", {
condition: (component) => component.editable,
icon: "fa-pencil",
title: _t("Edit"),
onClick: (component) => component.onClickEdit(),
sequence: 80,
})
.add("delete", {
condition: (component) => component.deletable,
icon: "fa-trash",
title: _t("Delete"),
onClick: (component) => component.onClickDelete(),
sequence: 90,
});
function transformAction(component, id, action) {
return {
component: action.component,
id,
/** Condition to display this action. */
get condition() {
return action.condition(component);
},
/** Icon for the button this action. */
get icon() {
return typeof action.icon === "function" ? action.icon(component) : action.icon;
},
/** title of this action, displayed to the user. */
get title() {
return typeof action.title === "function" ? action.title(component) : action.title;
},
callComponent: action.callComponent,
get props() {
return action.props(component);
},
/**
* Action to execute when this action is click.
*
* @param {object} [param0]
* @param {boolean} [param0.keepPrevious] Whether the previous action
* should be kept so that closing the current action goes back
* to the previous one.
* */
onClick() {
action.onClick?.(component);
},
/** Determines the order of this action (smaller first). */
get sequence() {
return typeof action.sequence === "function"
? action.sequence(component)
: action.sequence;
},
};
}
export function useMessageActions() {
const component = useComponent();
const transformedActions = messageActionsRegistry
.getEntries()
.map(([id, action]) => transformAction(component, id, action));
const state = useState({
get actions() {
return transformedActions
.filter((action) => action.condition)
.sort((a1, a2) => a1.sequence - a2.sequence);
},
});
return state;
}
@@ -0,0 +1,32 @@
/* @odoo-module */
import { Component, useRef, useState } from "@odoo/owl";
import { useEmojiPicker } from "@web/core/emoji_picker/emoji_picker";
import { useService } from "@web/core/utils/hooks";
/**
* @typedef {Object} Props
* @property {import("@mail/core/common/message_model").Message} message
* @extends {Component<Props, Env>}
*/
export class MessageReactionButton extends Component {
static template = "mail.MessageReactionButton";
static props = ["message"];
setup() {
this.messageService = useState(useService("mail.message"));
this.store = useState(useService("mail.store"));
this.emojiPickerRef = useRef("emoji-picker");
this.emojiPicker = useEmojiPicker(this.emojiPickerRef, {
onSelect: (emoji) => {
const reaction = this.props.message.reactions.find(
({ content, personas }) =>
content === emoji && personas.find((persona) => persona.eq(this.store.self))
);
if (!reaction) {
this.messageService.react(this.props.message, emoji);
}
},
});
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="mail.MessageReactionButton" owl="1">
<button class="btn px-1 py-0 rounded-0" tabindex="1" title="Add a Reaction" aria-label="Add a Reaction" t-ref="emoji-picker"><i class="fa fa-lg fa-smile-o"/></button>
</t>
</templates>
@@ -0,0 +1,12 @@
/* @odoo-module */
import { messageActionsRegistry } from "@mail/core/common/message_actions";
messageActionsRegistry.add("pin", {
condition: (component) =>
component.store.user && component.props.thread.model === "discuss.channel",
icon: "fa-thumb-tack",
title: (component) => component.getPinOptionText(),
onClick: (component) => component.onClickPin(),
sequence: 65,
});
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.Message.actions" t-inherit-mode="extension">
<xpath expr="//DropdownItem[@t-if='showUnfollow']" position="after">
<DropdownItem
t-if="store.user and props.thread.model === 'discuss.channel'"
class="'px-2 d-flex align-items-center rounded-0'"
title="getPinOptionText()"
onSelected="() => this.onClickPin()"
>
<span><i class="fa fa-lg fa-fw fa-thumb-tack pe-2"/><t t-esc="getPinOptionText()"/></span>
</DropdownItem>
</xpath>
</t>
</templates>
@@ -328,6 +328,7 @@ QUnit.test("reply to message from inbox (message linked to document) [REQUIRE FO
openDiscuss();
await contains(".o-mail-Message");
await contains(".o-mail-Message-header small", { text: "on Refactoring" });
await click("[title='Expand']");
await click("[title='Reply']");
await contains(".o-mail-Message.o-selected");
await contains(".o-mail-Composer");
@@ -613,17 +614,15 @@ QUnit.test("rendering of inbox message", async () => {
await contains(".o-mail-Message");
await contains(".o-mail-Message-header small", { text: "on Refactoring" });
await contains(".o-mail-Message-actions i", { count: 4 });
await contains("[title='Reply']");
await contains("[title='Add a Reaction']");
await contains("[title='Mark as Todo']");
await contains("[title='Mark as Read']");
await contains("[title='Expand']");
await click("[title='Expand']");
await contains(".o-mail-Message-actions i", { count: 5 });
await contains("[title='Reply']");
await contains("[title='Mark as Todo']");
await contains("[title='Mark as Read']");
await contains("[title='Expand']");
await contains("[title='Add a Reaction']");
});
QUnit.test("Unfollow message", async function () {
@@ -26,8 +26,10 @@ QUnit.test("reply: discard on reply button toggle", async (assert) => {
openDiscuss();
await contains(".o-mail-Message");
await click("[title='Expand']");
await click("[title='Reply']");
await contains(".o-mail-Composer");
await click("[title='Expand']");
await click("[title='Reply']");
await contains(".o-mail-Composer", { count: 0 });
});
@@ -54,7 +56,7 @@ QUnit.test("reply: discard on pressing escape", async () => {
const { openDiscuss } = await start();
openDiscuss();
await contains(".o-mail-Message");
await click(".o-mail-Message [title='Expand']");
await click(".o-mail-Message [title='Reply']");
await contains(".o-mail-Composer");
@@ -107,6 +109,7 @@ QUnit.test(
});
openDiscuss();
await contains(".o-mail-Message");
await click("[title='Expand']");
await click("[title='Reply']");
await contains(".o-mail-Composer [placeholder='Log an internal note…']");
await insertText(".o-mail-Composer-input", "Test");
@@ -146,6 +149,7 @@ QUnit.test(
});
openDiscuss();
await contains(".o-mail-Message");
await click("[title='Expand']");
await click("[title='Reply']");
await contains(".o-mail-Composer [placeholder='Send a message to followers…']");
await insertText(".o-mail-Composer-input", "Test");
@@ -552,6 +556,7 @@ QUnit.test("reply: stop replying button click", async () => {
openDiscuss();
await contains(".o-mail-Message");
await click("[title='Expand']");
await click("[title='Reply']");
await contains(".o-mail-Composer");
await contains("i[title='Stop replying']");