[FIX] mail: Send message/log note in chatter style with MILK

- Send button has text "Send" or "Log" depending on composer mode
- When button is active, it has `btn-primary` and composer mode
  buttons use `btn-outline-primary`
- Add hint to use Send keyboard shortcut for fast send in chatter

Other changes:

- Re-introduce missing feature to jump to present when posting
  a new message in a thread.
- In chatter, the topbar and composer are sticky. Whole chatter
  is scrollable rather than just a part of it. As a result,
  `Chatter-scrollable` becomes `Chatter-content`.
- Editing a message in chatter now uses the extended composer

Task-3356271

closes odoo/odoo#124905

X-original-commit: 894ec7e7757d2bb78574e8cd210012b83fad1cf6
Related: odoo/enterprise#42508
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Alexandre Kühn
2023-06-15 18:51:14 +02:00
parent 05079fe8e3
commit 8a9bc4c55e
25 changed files with 298 additions and 186 deletions
@@ -41,6 +41,9 @@ patch(ThreadService.prototype, "im_livechat/disabled", {
async fetchNewMessages(thread) {
return;
},
async loadAround() {
return;
},
});
patch(Store.prototype, "im_livechat/disabled", {
@@ -48,7 +48,7 @@ export class ChatWindow extends Component {
this.messageEdition = useMessageEdition();
this.messageHighlight = useMessageHighlight();
this.messageToReplyTo = useMessageToReplyTo();
this.state = useState({ moreActionsExpanded: false });
this.state = useState({ moreActionsExpanded: false, jumpThreadPresent: 0 });
this.ui = useState(useService("ui"));
this.contentRef = useRef("content");
this.threadActions = useThreadActions();
@@ -60,8 +60,8 @@
<t t-if="thread" name="thread content">
<t t-if="threadActions.activeAction?.componentCondition" t-component="threadActions.activeAction.component" thread="thread" t-props="threadActions.activeAction.componentProps"/>
<t t-else="">
<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"/>
<Thread isInChatWindow="true" thread="thread" t-key="thread.localId" jumpPresent="state.jumpThreadPresent" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo"/>
<Composer composer="thread.composer" autofocus="props.chatWindow.autofocus" mode="'compact'" messageEdition="messageEdition" messageToReplyTo="messageToReplyTo" onPostCallback.bind="() => this.state.jumpThreadPresent++" dropzoneRef="contentRef"/>
</t>
</t>
</div>
+54 -1
View File
@@ -16,13 +16,26 @@ import {
markEventHandled,
} from "@mail/utils/common/misc";
import { Component, onMounted, useChildSubEnv, useEffect, useRef, useState } from "@odoo/owl";
import {
Component,
markup,
onMounted,
useChildSubEnv,
useEffect,
useRef,
useState,
} from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { FileUploader } from "@web/views/fields/file_handler";
const EDIT_CLICK_TYPE = {
CANCEL: "cancel",
SAVE: "save",
};
/**
* @typedef {Object} Props
* @property {import("@mail/core/common/composer_model").Composer} composer
@@ -47,6 +60,7 @@ export class Composer extends Component {
static defaultProps = {
mode: "normal",
className: "",
sidebar: true,
};
static props = [
"composer",
@@ -60,10 +74,16 @@ export class Composer extends Component {
"messageEdition?",
"messageComponent?",
"className?",
"sidebar?",
];
static template = "mail.Composer";
setup() {
this.SEND_KEYBIND_TO_SEND = markup(
sprintf(_t("<samp>%(send_keybind)s</samp><i> to send</i>"), {
send_keybind: this.sendKeybind,
})
);
this.messaging = useMessaging();
this.store = useStore();
if (this.allowUpload) {
@@ -180,6 +200,39 @@ export class Composer extends Component {
return "";
}
onClickCancelOrSaveEditText(ev) {
if (this.props.composer.message && ev.target.dataset?.type === EDIT_CLICK_TYPE.CANCEL) {
this.props.onDiscardCallback(ev);
}
if (this.props.composer.message && ev.target.dataset?.type === EDIT_CLICK_TYPE.SAVE) {
this.editMessage(ev);
}
}
get CANCEL_OR_SAVE_EDIT_TEXT() {
return markup(
sprintf(
_t(
"<samp>%(cancel_keybind)s</samp><i> to <a href='#' data-type='%(cancel_type)s'>cancel</a></i>, <samp>%(save_keybind)s</samp><i> to <a href='#' data-type='%(save_type)s'>save</a></i>"
),
{
cancel_keybind: _t("Escape"),
cancel_type: EDIT_CLICK_TYPE.CANCEL,
save_keybind: this.sendKeybind,
save_type: EDIT_CLICK_TYPE.SAVE,
}
)
);
}
get SEND_TEXT() {
return this.props.composer.type === "note" ? _t("Log") : _t("Send");
}
get sendKeybind() {
return this.props.mode === "extended" ? _t("CTRL-Enter") : _t("Enter");
}
get thread() {
return this.props.composer.thread ?? null;
}
@@ -46,10 +46,14 @@
max-height: 100px;
resize: none;
.o-extended & {
.o-extended:not(.o-isUiSmall) & {
max-height: 400px;
}
.o-extended.o-isUiSmall & {
max-height: Min(400px, 30vh);
}
&::placeholder {
@include text-truncate();
}
@@ -8,11 +8,15 @@
<div t-ref="composer">
<div class="o-mail-Composer d-grid flex-shrink-0"
t-att-class="{
'px-3 py-4 o-extended': extended,
'pt-0': props.composer.type === 'message',
'pt-4': props.composer.type !== 'message',
'px-3 pb-2': extended and !props.composer.message,
'o-extended': extended,
'o-isUiSmall': ui.isSmall,
'p-3': normal,
'o-hasSelfAvatar px-3': !env.inChatWindow and thread,
}" t-attf-class="{{ props.className }}">
<div class="o-mail-Composer-sidebarMain flex-shrink-0" t-if="!compact">
<div class="o-mail-Composer-sidebarMain flex-shrink-0" t-if="!compact and props.sidebar">
<img class="o-mail-Composer-avatar o_avatar rounded" 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">
@@ -81,7 +85,10 @@
</div>
<t t-if="!extended" t-call="mail.Composer.sendButton"/>
</div>
<t t-if="extended" t-call="mail.Composer.sendButton"/>
<div t-if="extended and !props.composer.message" class="d-flex align-items-center mt-2 gap-2">
<t t-call="mail.Composer.sendButton"/>
<span t-if="!isSendButtonDisabled and !props.composer.message" t-out="SEND_KEYBIND_TO_SEND"/>
</div>
</div>
<div class="o-mail-Composer-footer overflow-auto">
<AttachmentList
@@ -91,20 +98,20 @@
imagesHeight="50"/>
</div>
</div>
<span t-if="props.composer.message" class="text-muted">escape to <a href="#" t-on-click="props.onDiscardCallback">cancel</a>, enter to <a href="#" t-on-click="editMessage">save</a></span>
<span t-if="props.composer.message" class="text-muted" t-out="CANCEL_OR_SAVE_EDIT_TEXT" t-on-click="onClickCancelOrSaveEditText"/>
</div>
<NavigableList t-if="suggestion" class="'o-mail-Composer-suggestionList'" t-props="navigableListProps"/>
</t>
<t t-name="mail.Composer.sendButton" owl="1">
<button t-if="!compact or ui.isSmall" class="o-mail-Composer-send btn btn-link o-last border-start-0"
t-att-class="{'mt-2': extended, 'rounded-0 rounded-end-3': !extended and !compact, 'align-self-stretch': !extended, 'border-start-0': env.inDiscussApp}"
t-att-class="{'btn-primary': extended, 'rounded-0 rounded-end-3': !extended and !compact, 'align-self-stretch': !extended, 'border-start-0': env.inDiscussApp}"
t-on-click="sendMessage"
type="button"
t-att-disabled="isSendButtonDisabled"
aria-label="Send"
t-att-aria-label="SEND_TEXT"
>
<t t-if="ui.isSmall"><i class="fa fa-paper-plane-o"/></t>
<t t-if="thread and thread.type === 'chatter'" t-out="SEND_TEXT"/>
<t t-elif="ui.isSmall"><i class="fa fa-paper-plane-o"/></t>
<t t-else="">Send</t>
</button>
</t>
@@ -51,6 +51,7 @@ export class Discuss extends Component {
this.messageToReplyTo = useMessageToReplyTo();
this.contentRef = useRef("content");
this.root = useRef("root");
this.state = useState({ jumpThreadPresent: 0 });
this.orm = useService("orm");
this.effect = useService("effect");
this.ui = useState(useService("ui"));
@@ -62,8 +62,8 @@
</div>
<div class="overflow-auto bg-view d-flex flex-grow-1" t-ref="core">
<div class="d-flex flex-column flex-grow-1">
<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"/>
<Thread thread="thread" t-key="thread.localId" jumpPresent="state.jumpThreadPresent" 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()" onPostCallback.bind="() => this.state.jumpThreadPresent++" dropzoneRef="contentRef"/>
</div>
<t t-if="threadActions.activeAction?.componentCondition" t-component="threadActions.activeAction.component" thread="thread" className="'o-mail-Discuss-inspector border-start'" t-props="threadActions.activeAction.componentProps"/>
</div>
@@ -238,8 +238,10 @@ export class Message extends Component {
return Boolean(!this.message.isTransient && this.message.resId);
}
get canUnfollow() {
return Boolean(this.message.originThread?.followerOfSelf);
get showUnfollow() {
return Boolean(
this.message.originThread?.followerOfSelf && this.props.thread?.model === "mail.box"
);
}
get isActive() {
@@ -51,10 +51,10 @@
}
.o-mail-Message-actions {
z-index: $o-mail-NavigableList-zIndex - 2;
z-index: $o-mail-NavigableList-zIndex - 3;
&.o-expanded {
z-index: $o-mail-NavigableList-zIndex - 1;
z-index: $o-mail-NavigableList-zIndex - 2;
}
button:hover, .focus {
@@ -79,7 +79,7 @@
<t t-else="">
<div class="position-relative">
<div class="o-mail-Message-bubble rounded-bottom-3 position-absolute top-0 start-0 w-100 h-100" t-att-class="{
'border': state.isEditing,
'border': state.isEditing and !message.isNote,
'bg-info-light border border-info opacity-25': !message.isSelfAuthored and !message.isNote and !message.isHighlightedFromMention,
'bg-success-light border border-success opacity-25': message.isSelfAuthored and !message.isNote and !message.isHighlightedFromMention,
'bg-warning-light border border-warning opacity-50': message.isHighlightedFromMention,
@@ -90,7 +90,7 @@
'align-self-start rounded-end-3 rounded-bottom-3': !state.isEditing and !message.isNote,
'o-mail-Message-editable flex-grow-1': state.isEditing,
}" t-ref="body">
<Composer t-if="state.isEditing" autofocus="true" composer="message.composer" messageComponent="constructor" onDiscardCallback.bind="exitEditMode" onPostCallback.bind="exitEditMode" mode="'compact'"/>
<Composer t-if="state.isEditing" autofocus="true" composer="message.composer" messageComponent="constructor" onDiscardCallback.bind="exitEditMode" onPostCallback.bind="exitEditMode" mode="env.inChatter ? 'extended' : 'compact'" sidebar="false"/>
<t t-else="">
<em t-if="message.subject and !message.isSubjectSimilarToOriginThreadName and !message.isSubjectDefault" class="mb-1 me-2">Subject: <t t-esc="message.subject"/></em>
<t t-if="message.body" t-out="message.body"/>
@@ -131,7 +131,7 @@
<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="isInInbox" class="'px-2 d-flex align-items-center rounded-0'" title="'Mark as Read'" onSelected="() => messageService.setDone(props.message)"><i class="fa fa-lg fa-fw fa-check pe-2"/>Mark as Read</DropdownItem>
<DropdownItem t-if="canUnfollow" 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="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'" 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>
@@ -237,6 +237,7 @@ export class Messaging {
Object.assign(notif.payload, { body: markup(notif.payload.body) })
);
channel.messages.push(message);
channel.transientMessages.push(message);
break;
}
case "mail.link.preview/delete":
+19 -3
View File
@@ -28,6 +28,7 @@ export const PRESENT_THRESHOLD = 2500;
/**
* @typedef {Object} Props
* @property {boolean} [isInChatWindow=false]
* @property {number} [jumpPresent=0]
* @property {import("@mail/utils/common/hooks").MessageEdition} [messageEdition]
* @property {import("@mail/utils/common/hooks").MessageToReplyTo} [messageToReplyTo]
* @property {"asc"|"desc"} [order="asc"]
@@ -39,6 +40,7 @@ export class Thread extends Component {
static props = [
"isInChatWindow?",
"hasScrollAdjust?",
"jumpPresent?",
"thread",
"messageEdition?",
"messageToReplyTo?",
@@ -47,6 +49,7 @@ export class Thread extends Component {
static defaultProps = {
isInChatWindow: false,
hasScrollAdjust: true,
jumpPresent: 0,
order: "asc", // 'asc' or 'desc'
};
static template = "mail.Thread";
@@ -127,6 +130,15 @@ export class Thread extends Component {
() => this.updateShowJumpPresent(),
() => [this.props.thread.loadNewer]
);
useEffect(
() => {
if (this.props.jumpPresent !== this.lastJumpPresent) {
this.jumpToPresent("instant");
this.lastJumpPresent = this.props.jumpPresent;
}
},
() => [this.props.jumpPresent]
);
onMounted(() => {
this.oldestPersistentMessage = this.props.thread.oldestPersistentMessage?.id;
if (!this.env.inChatter || !this.props.hasScrollAdjust) {
@@ -135,9 +147,13 @@ export class Thread extends Component {
}
});
onWillStart(() => {
this.lastJumpPresent = this.props.jumpPresent;
this.threadService.fetchNewMessages(this.props.thread);
});
onWillUpdateProps((nextProps) => {
if (nextProps.thread !== this.props.thread) {
this.lastJumpPresent = nextProps.jumpPresent;
}
this.threadService.fetchNewMessages(nextProps.thread);
});
}
@@ -155,12 +171,12 @@ export class Thread extends Component {
this.threadService.fetchMoreMessages(this.props.thread);
}
async onClickJumpPresent() {
async jumpToPresent(behavior) {
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
this.present.el?.scrollIntoView({
behavior: 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
@@ -27,7 +27,7 @@
.o-mail-Thread-jumpPresent {
background-color: mix(map-get($theme-colors, 'info'), $o-webclient-background-color, 5%);
z-index: $o-mail-NavigableList-zIndex - 2;
z-index: $o-mail-NavigableList-zIndex - 1;
}
.o-mail-NotificationMessage p {
@@ -3,7 +3,7 @@
<t t-name="mail.Thread" owl="1">
<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" t-att-class="{ 'overflow-auto': props.hasScrollAdjust, 'pb-4': !state.showJumpPresent }" t-ref="messages">
<div class="o-mail-Thread position-relative flex-grow-1" 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 flex-grow-1" t-att-class="{'justify-content-end': !env.inChatter and props.thread.type !== 'mailbox'}">
<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: Min(${PRESENT_THRESHOLD}px, 100%)`"/>
@@ -90,7 +90,7 @@
</t>
<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 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="jumpToPresent">
<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>
@@ -106,6 +106,7 @@ export class Thread {
/** @type {ScrollPosition} */
scrollPosition = new ScrollPosition();
showOnlyVideo = false;
transientMessages = [];
/** @type {import("@mail/core/common/store_service").Store} */
_store;
/** @type {string} */
@@ -322,7 +322,7 @@ export class ThreadService {
body: message.body ? markup(message.body) : message.body,
})
);
thread.loadNewer = true;
thread.loadNewer = messageId ? true : false;
thread.loadOlder = true;
if (messages.length < FETCH_LIMIT) {
const olderMessagesCount = messages.filter(({ id }) => id < messageId).length;
@@ -332,6 +332,7 @@ export class ThreadService {
thread.loadNewer = false;
}
}
this._enrichMessagesWithTransient(thread);
// Give some time to the UI to update.
await new Promise((resolve) => setTimeout(() => requestAnimationFrame(resolve)));
}
@@ -417,6 +418,7 @@ export class ThreadService {
}
}
}
this._enrichMessagesWithTransient(thread);
} catch {
// handled in fetchMessages
}
@@ -1076,6 +1078,28 @@ export class ThreadService {
data,
});
}
/**
* Following a load more or load around, listing of messages contains persistent messages.
* Transient messages are missing, so this function puts known transient messages at the
* right place in message list of thread.
*
* @param {Thread} thread
*/
_enrichMessagesWithTransient(thread) {
for (const message of thread.transientMessages) {
if (message.id < thread.oldestPersistentMessage && !thread.loadOlder) {
thread.messages.unshift(message);
} else if (message.id > thread.newestPersistentMessage && !thread.loadNewer) {
thread.messages.push(message);
} else {
const afterIndex = thread.messages.findIndex(
(msg) => msg.id > message.id && !msg.isTransient
);
thread.messages.splice(afterIndex - 1, 0, message);
}
}
}
}
export const threadService = {
+19 -2
View File
@@ -20,6 +20,7 @@ import {
onWillStart,
onWillUpdateProps,
useChildSubEnv,
useEffect,
useRef,
useState,
} from "@odoo/owl";
@@ -28,6 +29,7 @@ import { Dropdown } from "@web/core/dropdown/dropdown";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { escape, sprintf } from "@web/core/utils/strings";
import { useThrottleForAnimation } from "@web/core/utils/timing";
import { FileUploader } from "@web/views/fields/file_handler";
/**
@@ -80,6 +82,7 @@ export class Chatter extends Component {
setup() {
this.action = useService("action");
this.attachmentBox = useRef("attachment-box");
this.messaging = useMessaging();
/** @type {import("@mail/activity/activity_service").ActivityService} */
this.activityService = useState(useService("mail.activity"));
@@ -90,6 +93,7 @@ export class Chatter extends Component {
this.rpc = useService("rpc");
this.state = useState({
isAttachmentBoxOpened: this.props.isAttachmentBoxVisibleInitially,
jumpThreadPresent: 0,
showActivities: true,
/** @type {import("@mail/core/common/thread_model").Thread} */
thread: undefined,
@@ -98,8 +102,9 @@ export class Chatter extends Component {
this.attachmentUploader = useAttachmentUploader(
this.threadService.getThread(this.props.threadModel, this.props.threadId)
);
this.scrollPosition = useScrollPosition("scrollable", undefined, "top");
this.scrollPosition = useScrollPosition("root", undefined, "top");
this.rootRef = useRef("root");
this.onScrollDebounced = useThrottleForAnimation(this.onScroll);
useChildSubEnv({ inChatter: true });
useDropzone(
this.rootRef,
@@ -153,6 +158,14 @@ export class Chatter extends Component {
}
}
});
useEffect(
(opened) => {
if (opened) {
this.attachmentBox.el.scrollIntoView({ block: "center" });
}
},
() => [this.state.isAttachmentBoxOpened]
);
}
/**
@@ -292,6 +305,7 @@ export class Chatter extends Component {
this.reloadParentView();
}
this.toggleComposer();
this.state.jumpThreadPresent++;
// Load new messages to fetch potential new messages from other users (useful due to lack of auto-sync in chatter).
this.load(this.props.threadId, ["followers", "messages", "suggestedRecipients"]);
}
@@ -380,7 +394,6 @@ export class Chatter extends Component {
return;
}
this.state.isAttachmentBoxOpened = !this.state.isAttachmentBoxOpened;
this.scrollPosition.ref.el.scrollTop = 0;
}
async onClickAttachFile(ev) {
@@ -392,4 +405,8 @@ export class Chatter extends Component {
return false;
}
}
onScroll() {
this.state.isTopStickyPinned = this.rootRef.el.scrollTop !== 0;
}
}
@@ -10,6 +10,15 @@
width: $o-statusbar-height;
}
.o-mail-Chatter-top {
z-index: $o-mail-NavigableList-zIndex - 2;
background-color: $o-webclient-background-color;
&.shadow-sm {
background-image: linear-gradient(90deg, transparent, $o-view-background-color, transparent);
}
}
.o-mail-Followers-button:focus {
background-color: $gray-200;
}
+94 -84
View File
@@ -2,101 +2,111 @@
<templates xml:space="preserve">
<t t-name="mail.Chatter" owl="1">
<div class="o-mail-Chatter w-100 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 px-3 overflow-x-auto">
<button t-if="props.hasMessageList" class="o-mail-Chatter-sendMessage btn text-nowrap me-1" t-att-class="{ 'btn-primary': state.thread.composer.type !== 'note', 'btn-secondary': 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
</button>
<button t-if="props.hasMessageList" class="o-mail-Chatter-logNote btn text-nowrap me-2" t-att-class="{ 'btn-primary o-active': state.thread.composer.type === 'note', 'btn-secondary': state.thread.composer.type !== 'note', 'my-2': !props.compactHeight }" data-hotkey="shift+m" t-on-click="() => this.toggleComposer('note')">
Log note
</button>
<div class="flex-grow-1 d-flex">
<button t-if="props.hasActivities" class="btn btn-secondary text-nowrap" t-att-class="{ 'my-2': !props.compactHeight }" data-hotkey="shift+a" t-on-click="scheduleActivity">
<i class="fa fa-clock-o me-1"/>
<span>Activities</span>
<div class="o-mail-Chatter w-100 h-100 flex-grow-1 d-flex flex-column" t-att-class="{ 'overflow-auto': props.hasMessageListScrollAdjust, 'o-chatter-disabled': props.threadId === false }" t-on-scroll="onScrollDebounced" t-ref="root">
<div class="o-mail-Chatter-top position-sticky top-0" t-att-class="{ 'shadow-sm': state.isTopStickyPinned }" t-ref="top">
<div class="o-mail-Chatter-topbar d-flex flex-shrink-0 flex-grow-0 px-3 overflow-x-auto">
<button t-if="props.hasMessageList" class="o-mail-Chatter-sendMessage btn text-nowrap me-1" t-att-class="{
'btn-primary': state.thread.composer.type !== 'note',
'btn-secondary': state.thread.composer.type === 'note',
'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
</button>
<span class="o-mail-Chatter-topbarGrow flex-grow-1 pe-2"/>
<FileUploader t-if="attachments.length === 0" fileUploadClass="'o-mail-Chatter-fileUploader'" multiUpload="true" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
<t t-set-slot="toggler">
<t t-call="mail.Chatter.attachFiles"/>
</t>
</FileUploader>
<t t-else="" t-call="mail.Chatter.attachFiles"/>
<Dropdown t-if="props.hasFollowers" position="'bottom-end'" disabled="isDisabled" class="'o-mail-Followers d-flex me-1'" menuClass="'o-mail-Followers-dropdown flex-column p-0'" menuDisplay="'d-flex'" title="followerButtonLabel" togglerClass="'o-mail-Followers-button btn btn-link text-action px-1 ' + (props.compactHeight ? '' : 'my-2')">
<t t-set-slot="toggler">
<i class="fa fa-user-o" role="img"/>
<span class="o-mail-Followers-counter ps-1" t-esc="state.thread.followers.length"/>
</t>
<t t-set-slot="default">
<t t-if="this.props.threadModel !== 'discuss.channel' and state.thread.hasWriteAccess">
<a class="dropdown-item" href="#" role="menuitem" t-on-click.prevent="onClickAddFollowers">
Add Followers
</a>
<div t-if="state.thread.followers.length > 0" role="separator" class="dropdown-divider"/>
<button t-if="props.hasMessageList" class="o-mail-Chatter-logNote btn text-nowrap me-2" t-att-class="{
'btn-primary active': state.thread.composer.type === 'note',
'btn-secondary': state.thread.composer.type !== 'note',
'my-2': !props.compactHeight
}" data-hotkey="shift+m" t-on-click="() => this.toggleComposer('note')">
Log note
</button>
<div class="flex-grow-1 d-flex">
<button t-if="props.hasActivities" class="btn btn-secondary text-nowrap" t-att-class="{ 'my-2': !props.compactHeight }" data-hotkey="shift+a" t-on-click="scheduleActivity">
<i class="fa fa-clock-o me-1"/>
<span>Activities</span>
</button>
<span class="o-mail-Chatter-topbarGrow flex-grow-1 pe-2"/>
<FileUploader t-if="attachments.length === 0" fileUploadClass="'o-mail-Chatter-fileUploader'" multiUpload="true" onUploaded.bind="onUploaded" onClick="(ev) => this.onClickAttachFile(ev)">
<t t-set-slot="toggler">
<t t-call="mail.Chatter.attachFiles"/>
</t>
<t t-if="state.thread.followers.length > 0">
<div t-foreach="state.thread.followers" t-as="follower" t-key="follower.id" class="dropdown-item o-mail-Follower d-flex justify-content-between p-0">
<a class="o-mail-Follower-details d-flex align-items-center flex-grow-1 px-3 text-700 o-min-width-0" t-att-class="{ 'o-inactive fst-italic opacity-25': !follower.isActive }" href="#" role="menuitem" t-on-click.prevent="(ev) => this.onClickDetails(ev, follower)">
<img class="o-mail-Follower-avatar me-2 rounded" t-att-src="threadService.avatarUrl(follower.partner, state.thread)" alt=""/>
<span class="flex-shrink text-truncate" t-esc="follower.partner.name"/>
</FileUploader>
<t t-else="" t-call="mail.Chatter.attachFiles"/>
<Dropdown t-if="props.hasFollowers" position="'bottom-end'" disabled="isDisabled" class="'o-mail-Followers d-flex me-1'" menuClass="'o-mail-Followers-dropdown flex-column p-0'" menuDisplay="'d-flex'" title="followerButtonLabel" togglerClass="'o-mail-Followers-button btn btn-link text-action px-1 ' + (props.compactHeight ? '' : 'my-2')">
<t t-set-slot="toggler">
<i class="fa fa-user-o" role="img"/>
<span class="o-mail-Followers-counter ps-1" t-esc="state.thread.followers.length"/>
</t>
<t t-set-slot="default">
<t t-if="this.props.threadModel !== 'discuss.channel' and state.thread.hasWriteAccess">
<a class="dropdown-item" href="#" role="menuitem" t-on-click.prevent="onClickAddFollowers">
Add Followers
</a>
<t t-if="follower.isEditable">
<button class="o-mail-Follower-action btn btn-icon rounded-0" title="Edit subscription" aria-label="Edit subscription" t-on-click="(ev) => this.onClickEdit(ev, follower)">
<i class="fa fa-pencil"/>
</button>
<button class="o-mail-Follower-action btn btn-icon rounded-0" title="Remove this follower" aria-label="Remove this follower" t-on-click="(ev) => this.onClickRemove(ev, follower)">
<i class="fa fa-remove"/>
</button>
</t>
<div t-if="state.thread.followers.length > 0" role="separator" class="dropdown-divider"/>
</t>
<t t-if="state.thread.followers.length > 0">
<div t-foreach="state.thread.followers" t-as="follower" t-key="follower.id" class="dropdown-item o-mail-Follower d-flex justify-content-between p-0">
<a class="o-mail-Follower-details d-flex align-items-center flex-grow-1 px-3 text-700 o-min-width-0" t-att-class="{ 'o-inactive fst-italic opacity-25': !follower.isActive }" href="#" role="menuitem" t-on-click.prevent="(ev) => this.onClickDetails(ev, follower)">
<img class="o-mail-Follower-avatar me-2 rounded" t-att-src="threadService.avatarUrl(follower.partner, state.thread)" alt=""/>
<span class="flex-shrink text-truncate" t-esc="follower.partner.name"/>
</a>
<t t-if="follower.isEditable">
<button class="o-mail-Follower-action btn btn-icon rounded-0" title="Edit subscription" aria-label="Edit subscription" t-on-click="(ev) => this.onClickEdit(ev, follower)">
<i class="fa fa-pencil"/>
</button>
<button class="o-mail-Follower-action btn btn-icon rounded-0" title="Remove this follower" aria-label="Remove this follower" t-on-click="(ev) => this.onClickRemove(ev, follower)">
<i class="fa fa-remove"/>
</button>
</t>
</div>
</t>
<div t-elif="!state.thread.hasWriteAccess" class="dropdown-item disabled">
No Followers
</div>
</t>
<div t-elif="!state.thread.hasWriteAccess" class="dropdown-item disabled">
No Followers
</Dropdown>
<button t-if="props.hasFollowers and state.thread.followerOfSelf" class="btn px-0" t-att-class="{ 'text-success': !unfollowHover.isHover, 'text-warning': unfollowHover.isHover, 'my-2': !props.compactHeight }" t-att-disabled="!props.threadId" t-on-click="onClickUnfollow" t-ref="unfollow">
<div class="position-relative">
<!-- Hidden element used to set the button maximum size -->
<span class="d-flex invisible text-nowrap">
<i class="me-1 fa fa-fw fa-eye-slash"/><t t-out="followingText.length > unfollowText.length ? followingText : unfollowText"/>
</span>
<span class="position-absolute end-0 top-0" t-att-class="{ 'text-warning': unfollowHover.isHover }">
<i class="me-1" t-att-class="{ 'fa fa-fw fa-times': unfollowHover.isHover, 'fa fa-fw fa-check': !unfollowHover.isHover }"/><span t-out="unfollowHover.isHover ? unfollowText : followingText"/>
</span>
</div>
</t>
</Dropdown>
<button t-if="props.hasFollowers and state.thread.followerOfSelf" class="btn px-0" t-att-class="{ 'text-success': !unfollowHover.isHover, 'text-warning': unfollowHover.isHover, 'my-2': !props.compactHeight }" t-att-disabled="!props.threadId" t-on-click="onClickUnfollow" t-ref="unfollow">
<div class="position-relative">
<!-- Hidden element used to set the button maximum size -->
<span class="d-flex invisible text-nowrap">
<i class="me-1 fa fa-fw fa-eye-slash"/><t t-out="followingText.length > unfollowText.length ? followingText : unfollowText"/>
</span>
<span class="position-absolute end-0 top-0" t-att-class="{ 'text-warning': unfollowHover.isHover }">
<i class="me-1" t-att-class="{ 'fa fa-fw fa-times': unfollowHover.isHover, 'fa fa-fw fa-check': !unfollowHover.isHover }"/><span t-out="unfollowHover.isHover ? unfollowText : followingText"/>
</span>
</div>
</button>
<button t-if="!state.thread.followerOfSelf" class="o-mail-Chatter-follow btn btn-link px-0 text-600" t-on-click="onClickFollow">
<div class="position-relative">
<span class="d-flex invisible text-nowrap">
<i class="me-1 fa fa-fw fa-eye-slash"/><t t-out="followingText.length > unfollowText.length ? followingText : unfollowText"/>
</span>
<span class="position-absolute end-0 top-0">
Follow
</span>
</div>
</button>
<button t-if="!state.thread.followerOfSelf" class="o-mail-Chatter-follow btn btn-link px-0 text-600" t-on-click="onClickFollow">
<div class="position-relative">
<span class="d-flex invisible text-nowrap">
<i class="me-1 fa fa-fw fa-eye-slash"/><t t-out="followingText.length > unfollowText.length ? followingText : unfollowText"/>
</span>
<span class="position-absolute end-0 top-0">
Follow
</span>
</div>
</button>
</div>
<button t-if="props.close" class="o-mail-Chatter-close btn btn-dark flex-shrink-0 rounded-bottom-3" aria-label="Close" t-on-click="props.close">
<i class="oi oi-large oi-close"/>
</button>
</div>
<button t-if="props.close" class="o-mail-Chatter-close btn btn-dark flex-shrink-0 rounded-bottom-3" aria-label="Close" t-on-click="props.close">
<i class="oi oi-large oi-close"/>
</button>
</div>
<t t-if="state.thread.composer.type">
<t t-if="state.thread.composer.type === 'message'">
<div class="flex-shrink-0 px-3 pt-3 text-truncate small" style="margin-left:48px" t-out="toFollowersText"/>
<t t-if="state.thread.composer.type">
<t t-if="state.thread.composer.type === 'message'">
<div class="flex-shrink-0 px-3 pt-3 text-truncate small" style="margin-left:48px" t-out="toFollowersText"/>
</t>
<t t-if="state.thread.composer.type === 'message'" t-set="placeholder">Send a message to followers...</t>
<t t-else="" t-set="placeholder">Log an internal note...</t>
<t t-set="type" t-value="state.thread.composer.type === 'message' ? 'message' : 'note'"/>
<SuggestedRecipientsList t-if="props.hasFollowers and state.thread.composer.type !== 'note'" className="'px-3'" styleString="'margin-left:48px;'" thread="state.thread"/>
<Composer composer="state.thread.composer" autofocus="true" mode="'extended'" onPostCallback.bind="onPostCallback" placeholder="placeholder" dropzoneRef="rootRef"/>
</t>
<t t-if="state.thread.composer.type === 'message'" t-set="placeholder">Send a message to followers...</t>
<t t-else="" t-set="placeholder">Log an internal note...</t>
<t t-set="type" t-value="state.thread.composer.type === 'message' ? 'message' : 'note'"/>
<SuggestedRecipientsList t-if="props.hasFollowers and state.thread.composer.type !== 'note'" className="'px-3'" styleString="'margin-left:48px;'" thread="state.thread"/>
<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" t-att-class="{ 'overflow-auto': props.hasMessageListScrollAdjust }" t-ref="scrollable">
</div>
<div class="o-mail-Chatter-content">
<t t-if="props.hasActivities and activities.length">
<t t-call="mail.ActivityList"/>
</t>
<div t-if="state.isAttachmentBoxOpened" class="o-mail-AttachmentBox position-relative">
<div t-if="state.isAttachmentBoxOpened" class="o-mail-AttachmentBox position-relative" t-ref="attachment-box">
<div class="d-flex align-items-center">
<hr class="flex-grow-1"/>
<span class="p-3 fw-bold">
@@ -120,7 +130,7 @@
</FileUploader>
</div>
</div>
<Thread t-if="props.hasMessageList" thread="state.thread" t-key="state.thread.localId" order="'desc'" hasScrollAdjust="!props.hasMessageListScrollAdjust"/>
<Thread t-if="props.hasMessageList" thread="state.thread" t-key="state.thread.localId" order="'desc'" hasScrollAdjust="!props.hasMessageListScrollAdjust" jumpPresent="state.jumpThreadPresent"/>
</div>
</div>
</t>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.Message" t-inherit-mode="extension" owl="1">
<xpath expr="//DropdownItem[@t-if='canUnfollow']" position="after">
<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'"
@@ -697,6 +697,7 @@ export function waitUntil(selector, count = 1) {
});
observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
});
@@ -1,7 +1,7 @@
/* @odoo-module */
import { patchUiSize, SIZES } from "@mail/../tests/helpers/patch_ui_size";
import { afterNextRender, click, start, startServer } from "@mail/../tests/helpers/test_utils";
import { click, nextAnimationFrame, start, startServer } from "@mail/../tests/helpers/test_utils";
QUnit.module("attachment box");
@@ -171,38 +171,8 @@ QUnit.test("scroll to attachment box when toggling on", async (assert) => {
res_model: "res.partner",
views: [[false, "form"]],
});
$(".o-mail-Chatter-scrollable").scrollTop(10 * 1000); // to bottom
assert.notEqual($(".o-mail-Chatter-scrollable").scrollTop(), 0);
await click("i.fa-paperclip");
assert.strictEqual($(".o-mail-Chatter-scrollable").scrollTop(), 0);
});
QUnit.test("scroll to attachment box when toggling on", async (assert) => {
patchUiSize({ size: SIZES.XXL });
const pyEnv = await startServer();
const partnerId = pyEnv["res.partner"].create({});
for (let i = 0; i < 30; i++) {
pyEnv["mail.message"].create({
body: "not empty".repeat(50),
model: "res.partner",
res_id: partnerId,
});
}
pyEnv["ir.attachment"].create({
mimetype: "text/plain",
name: "Blah.txt",
res_id: partnerId,
res_model: "res.partner",
});
const { openView } = await start();
await openView({
res_id: partnerId,
res_model: "res.partner",
views: [[false, "form"]],
});
await afterNextRender(() => {
$(".o-mail-Chatter-scrollable").scrollTop(10 * 1000); // to bottom
});
$(".o-mail-Chatter").scrollTop(10 * 1000); // to bottom
await click("button[aria-label='Attach files']");
assert.strictEqual($(".o-mail-Chatter-scrollable").scrollTop(), 0);
await nextAnimationFrame();
assert.isVisible($(".o-mail-AttachmentBox"));
});
+13 -20
View File
@@ -10,7 +10,6 @@ import {
isScrolledTo,
start,
startServer,
waitFormViewLoaded,
waitUntil,
} from "@mail/../tests/helpers/test_utils";
@@ -118,7 +117,7 @@ QUnit.test("can post a note on a record thread", async (assert) => {
await editInput(document.body, ".o-mail-Composer-input", "hey");
assert.containsNone($, ".o-mail-Message");
await click(".o-mail-Composer button:contains(Send)");
await click(".o-mail-Composer button:contains(Log)");
assert.containsOnce($, ".o-mail-Message");
assert.verifySteps(["/mail/message/post"]);
});
@@ -155,10 +154,8 @@ QUnit.test("Composer toggle state is kept when switching from aside to bottom",
await openFormView("res.partner", partnerId);
await click("button:contains(Send message)");
patchUiSize({ size: SIZES.LG });
await waitFormViewLoaded(() => window.dispatchEvent(new Event("resize")), {
resId: partnerId,
resModel: "res.partner",
});
window.dispatchEvent(new Event("resize"));
await waitUntil(".o-mail-Form-chatter:not(.o-aside) .o-mail-Composer-input");
assert.containsOnce($, ".o-mail-Composer-input");
});
@@ -170,10 +167,8 @@ QUnit.test("Textarea content is kept when switching from aside to bottom", async
await click("button:contains(Send message)");
await editInput(document.body, ".o-mail-Composer-input", "Hello world !");
patchUiSize({ size: SIZES.LG });
await waitFormViewLoaded(() => window.dispatchEvent(new Event("resize")), {
resId: partnerId,
resModel: "res.partner",
});
window.dispatchEvent(new Event("resize"));
await waitUntil(".o-mail-Form-chatter:not(.o-aside) .o-mail-Composer-input");
assert.strictEqual($(".o-mail-Composer-input").val(), "Hello world !");
});
@@ -184,10 +179,8 @@ QUnit.test("Composer type is kept when switching from aside to bottom", async (a
await openFormView("res.partner", partnerId);
await click("button:contains(Log note)");
patchUiSize({ size: SIZES.LG });
await waitFormViewLoaded(() => window.dispatchEvent(new Event("resize")), {
resId: partnerId,
resModel: "res.partner",
});
window.dispatchEvent(new Event("resize"));
await waitUntil(".o-mail-Form-chatter:not(.o-aside) .o-mail-Composer-input");
assert.hasClass(
$("button:contains(Log note)"),
"btn-primary",
@@ -478,16 +471,16 @@ QUnit.test("scroll position is kept when navigating from one record to another",
);
const { openFormView } = await start();
await openFormView("res.partner", partnerId_1);
const scrolltop_1 = $(".o-mail-Chatter-scrollable")[0].scrollHeight / 2;
$(".o-mail-Chatter-scrollable")[0].scrollTo({ top: scrolltop_1 });
const scrolltop_1 = $(".o-mail-Chatter")[0].scrollHeight / 2;
$(".o-mail-Chatter")[0].scrollTo({ top: scrolltop_1 });
await openFormView("res.partner", partnerId_2);
const scrolltop_2 = $(".o-mail-Chatter-scrollable")[0].scrollHeight / 3;
$(".o-mail-Chatter-scrollable")[0].scrollTo({ top: scrolltop_2 });
const scrolltop_2 = $(".o-mail-Chatter")[0].scrollHeight / 3;
$(".o-mail-Chatter")[0].scrollTo({ top: scrolltop_2 });
await openFormView("res.partner", partnerId_1);
assert.ok(isScrolledTo($(".o-mail-Chatter-scrollable")[0], scrolltop_1));
assert.ok(isScrolledTo($(".o-mail-Chatter")[0], scrolltop_1));
await openFormView("res.partner", partnerId_2);
assert.ok(isScrolledTo($(".o-mail-Chatter-scrollable")[0], scrolltop_2));
assert.ok(isScrolledTo($(".o-mail-Chatter")[0], scrolltop_2));
});
QUnit.test("basic chatter rendering", async (assert) => {
@@ -76,18 +76,18 @@ QUnit.test("log note toggling", async (assert) => {
views: [[false, "form"]],
});
assert.containsOnce($, "button:contains(Log note)");
assert.doesNotHaveClass($("button:contains(Log note)"), "o-active");
assert.doesNotHaveClass($("button:contains(Log note)"), "active");
assert.containsNone($, ".o-mail-Composer");
await click("button:contains(Log note)");
assert.hasClass($("button:contains(Log note)"), "o-active");
assert.hasClass($("button:contains(Log note)"), "active");
assert.containsOnce(
$,
".o-mail-Composer .o-mail-Composer-input[placeholder='Log an internal note...']"
);
await click("button:contains(Log note)");
assert.doesNotHaveClass($("button:contains(Log note)"), "o-active");
assert.doesNotHaveClass($("button:contains(Log note)"), "active");
assert.containsNone($, ".o-mail-Composer");
});
@@ -101,15 +101,15 @@ QUnit.test("send message toggling", async (assert) => {
views: [[false, "form"]],
});
assert.containsOnce($, "button:contains(Send message)");
assert.doesNotHaveClass($("button:contains(Send message)"), "o-active");
assert.doesNotHaveClass($("button:contains(Send message)"), "active");
assert.containsNone($, ".o-mail-Composer");
await click("button:contains(Send message)");
assert.hasClass($("button:contains(Send message)"), "o-active");
assert.hasClass($("button:contains(Send message)"), "active");
assert.containsOnce($, ".o-mail-Composer-input[placeholder='Send a message to followers...']");
await click("button:contains(Send message)");
assert.doesNotHaveClass($("button:contains(Send message)"), "o-active");
assert.doesNotHaveClass($("button:contains(Send message)"), "active");
assert.containsNone($, ".o-mail-Composer");
});
@@ -123,19 +123,19 @@ QUnit.test("log note/send message switching", async (assert) => {
views: [[false, "form"]],
});
assert.containsOnce($, "button:contains(Send message)");
assert.doesNotHaveClass($("button:contains(Send message)"), "o-active");
assert.doesNotHaveClass($("button:contains(Send message)"), "active");
assert.containsOnce($, "button:contains(Log note)");
assert.doesNotHaveClass($("button:contains(Log note)"), "o-active");
assert.doesNotHaveClass($("button:contains(Log note)"), "active");
assert.containsNone($, ".o-mail-Composer");
await click("button:contains(Send message)");
assert.hasClass($("button:contains(Send message)"), "o-active");
assert.doesNotHaveClass($("button:contains(Log note)"), "o-active");
assert.hasClass($("button:contains(Send message)"), "active");
assert.doesNotHaveClass($("button:contains(Log note)"), "active");
assert.containsOnce($, ".o-mail-Composer-input[placeholder='Send a message to followers...']");
await click("button:contains(Log note)");
assert.doesNotHaveClass($("button:contains(Send message)"), "o-active");
assert.hasClass($("button:contains(Log note)"), "o-active");
assert.doesNotHaveClass($("button:contains(Send message)"), "active");
assert.hasClass($("button:contains(Log note)"), "active");
assert.containsOnce($, ".o-mail-Composer-input[placeholder='Log an internal note...']");
});
@@ -271,11 +271,11 @@ QUnit.test("composer state conserved when clicking on another topbar button", as
assert.containsOnce($, "button[aria-label='Attach files']");
await click("button:contains(Log note)");
assert.containsOnce($, "button:contains(Log note).o-active");
assert.containsNone($, "button:contains(Send message).o-active");
assert.containsOnce($, "button:contains(Log note).active");
assert.containsNone($, "button:contains(Send message).active");
$(`button[aria-label='Attach files']`)[0].click();
await nextAnimationFrame();
assert.containsOnce($, "button:contains(Log note).o-active");
assert.containsNone($, "button:contains(Send message).o-active");
assert.containsOnce($, "button:contains(Log note).active");
assert.containsNone($, "button:contains(Send message).active");
});