[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:
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user