[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:
committed by
Alexandre Kühn
parent
71705e4ea3
commit
6aa3a1e243
@@ -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 &&
|
||||
|
||||
@@ -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']");
|
||||
|
||||
Reference in New Issue
Block a user