[IMP] mail: introduce view models (step 6)
This commit introduces models that define records being 1:1 map with components, as a step to move further to having essentially all business code in models. Having code in models is desirable to have very maintainable code, thanks to robust and declarative code with an ORM-like architecture. Task-2831082 closes odoo/odoo#90569 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
@@ -25,7 +25,7 @@ export class AutocompleteInput extends Component {
|
||||
if (!this.root.el) {
|
||||
return;
|
||||
}
|
||||
if (this.props.isFocusOnMount) {
|
||||
if (this.autocompleteInputView.isFocusOnMount) {
|
||||
this.root.el.focus();
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class AutocompleteInput extends Component {
|
||||
select: (ev, ui) => this._onAutocompleteSelect(ev, ui),
|
||||
source: (req, res) => this._onAutocompleteSource(req, res),
|
||||
focus: ev => this._onAutocompleteFocus(ev),
|
||||
html: this.props.isHtml || false,
|
||||
html: this.autocompleteInputView.isHtml,
|
||||
};
|
||||
|
||||
if (this.props.customClass) {
|
||||
@@ -158,10 +158,6 @@ export class AutocompleteInput extends Component {
|
||||
}
|
||||
|
||||
Object.assign(AutocompleteInput, {
|
||||
defaultProps: {
|
||||
isFocusOnMount: false,
|
||||
isHtml: false,
|
||||
},
|
||||
props: {
|
||||
customClass: {
|
||||
type: String,
|
||||
@@ -171,23 +167,11 @@ Object.assign(AutocompleteInput, {
|
||||
type: Function,
|
||||
optional: true,
|
||||
},
|
||||
isFocusOnMount: {
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
},
|
||||
isHtml: {
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
},
|
||||
localId: String,
|
||||
onHide: {
|
||||
type: Function,
|
||||
optional: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
select: {
|
||||
type: Function,
|
||||
optional: true,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerMessagingComponent } from '@mail/utils/messaging_component';
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
export class ChannelMember extends Component {
|
||||
|
||||
/**
|
||||
* @returns {Thread}
|
||||
*/
|
||||
get channel() {
|
||||
return this.messaging.models['Thread'].get(this.props.channelLocalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Partner}
|
||||
*/
|
||||
get member() {
|
||||
return this.messaging.models['Partner'].get(this.props.memberLocalId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(ChannelMember, {
|
||||
props: {
|
||||
channelLocalId: String,
|
||||
memberLocalId: String,
|
||||
},
|
||||
template: 'mail.ChannelMember',
|
||||
});
|
||||
|
||||
registerMessagingComponent(ChannelMember);
|
||||
@@ -0,0 +1,12 @@
|
||||
.o_ChannelMember_avatar {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.o_ChannelMember_avatarContainer {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.o_ChannelMember_name {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.ChannelMember" owl="1">
|
||||
<div class="o_ChannelMember d-flex align-items-center mx-3 my-1" t-attf-class="{{ className }}" t-att-data-partner-local-id="member.localId" t-ref="root">
|
||||
<div class="o_ChannelMember_avatarContainer position-relative flex-shrink-0 o_cursor_pointer" t-on-click="() => channel.onClickMemberAvatar(member)">
|
||||
<img class="o_ChannelMember_avatar rounded-circle w-100 h-100" t-attf-src="/mail/channel/{{ channel.id }}/partner/{{ member.id }}/avatar_128" alt="Avatar"/>
|
||||
|
||||
<t t-if="member.im_status and member.im_status !== 'im_partner'">
|
||||
<PartnerImStatusIcon
|
||||
className="'o_ChannelMember_partnerImStatusIcon position-absolute bottom-0 end-0 d-flex align-items-center justify-content-center text-light'"
|
||||
classNameObj="{
|
||||
'o-mobile': messaging.device.isSmall,
|
||||
'small': !messaging.device.isSmall,
|
||||
}"
|
||||
partnerLocalId="member.localId"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
<span class="o_ChannelMember_name ml-2 flex-column-1 text-truncate o_cursor_pointer" t-on-click="() => channel.onClickMemberName(member)">
|
||||
<t t-esc="member.nameOrDisplayName"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,12 +0,0 @@
|
||||
.o_ChannelMemberList_avatar {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.o_ChannelMemberList_avatarContainer {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.o_ChannelMemberList_name {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -34,25 +34,7 @@
|
||||
<t t-name="mail.ChannelMemberList_memberList" owl="1">
|
||||
<h6 class="m-2"><t t-esc="title"/> - <t t-esc="members.length"/></h6>
|
||||
<t t-foreach="members" t-as="member" t-key="member.localId">
|
||||
<div class="o_ChannelMemberList_member d-flex align-items-center mx-3 my-1">
|
||||
<div class="o_ChannelMemberList_avatarContainer position-relative flex-shrink-0 o_cursor_pointer" t-on-click="() => channelMemberListView.channel.onClickMemberAvatar(member)">
|
||||
<img class="o_ChannelMemberList_avatar rounded-circle w-100 h-100" t-attf-src="/mail/channel/{{ channelMemberListView.channel.id }}/partner/{{ member.id }}/avatar_128" alt="Avatar"/>
|
||||
|
||||
<t t-if="member.im_status and member.im_status !== 'im_partner'">
|
||||
<PartnerImStatusIcon
|
||||
className="'o_ChannelMemberList_partnerImStatusIcon position-absolute bottom-0 end-0 d-flex align-items-center justify-content-center text-light'"
|
||||
classNameObj="{
|
||||
'o-mobile': messaging.device.isSmall,
|
||||
'small': !messaging.device.isSmall,
|
||||
}"
|
||||
partnerLocalId="member.localId"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
<span class="o_ChannelMemberList_name ml-2 flex-column-1 text-truncate o_cursor_pointer" t-on-click="() => channelMemberListView.channel.onClickMemberName(member)">
|
||||
<t t-esc="member.nameOrDisplayName"/>
|
||||
</span>
|
||||
</div>
|
||||
<ChannelMember channelLocalId="channelMemberListView.channel.localId" memberLocalId="member.localId"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
|
||||
@@ -20,8 +20,6 @@ export class ChatWindow extends Component {
|
||||
*/
|
||||
this._inputRef = { el: null };
|
||||
// the following are passed as props to children
|
||||
this._onAutocompleteSelect = this._onAutocompleteSelect.bind(this);
|
||||
this._onAutocompleteSource = this._onAutocompleteSource.bind(this);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -58,57 +56,6 @@ export class ChatWindow extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when selecting an item in the autocomplete input of the
|
||||
* 'new_message' chat window.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
async _onAutocompleteSelect(ev, ui) {
|
||||
const chat = await this.messaging.getChat({ partnerId: ui.item.id });
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
this.messaging.chatWindowManager.openThread(chat, {
|
||||
makeActive: true,
|
||||
replaceNewMessage: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when typing in the autocomplete input of the 'new_message' chat
|
||||
* window.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} req
|
||||
* @param {string} req.term
|
||||
* @param {function} res
|
||||
*/
|
||||
_onAutocompleteSource(req, res) {
|
||||
this.messaging.models['Partner'].imSearch({
|
||||
callback: (partners) => {
|
||||
const suggestions = partners.map(partner => {
|
||||
return {
|
||||
id: partner.id,
|
||||
value: partner.nameOrDisplayName,
|
||||
label: partner.nameOrDisplayName,
|
||||
};
|
||||
});
|
||||
res(_.sortBy(suggestions, 'label'));
|
||||
},
|
||||
keyword: _.escape(req.term),
|
||||
limit: 10,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(ChatWindow, {
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
<AutocompleteInput
|
||||
className="'o_ChatWindow_newMessageFormInput flex-grow-1 flex-shrink-1 border'"
|
||||
localId="chatWindow.newMessageAutocompleteInputView.localId"
|
||||
select="_onAutocompleteSelect"
|
||||
source="_onAutocompleteSource"
|
||||
select="chatWindow.onAutocompleteSelect"
|
||||
source="chatWindow.onAutocompleteSource"
|
||||
inputRef="_inputRef"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+4
-6
@@ -11,18 +11,16 @@ export class ComposerSuggestionList extends Component {
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {ComposerView}
|
||||
* @returns {ComposerSuggestionListView}
|
||||
*/
|
||||
get composerView() {
|
||||
return this.messaging && this.messaging.models['ComposerView'].get(this.props.composerViewLocalId);
|
||||
get composerSuggestionListView() {
|
||||
return this.messaging && this.messaging.models['ComposerSuggestionListView'].get(this.props.localId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(ComposerSuggestionList, {
|
||||
props: {
|
||||
composerViewLocalId: String,
|
||||
},
|
||||
props: { localId: String },
|
||||
template: 'mail.ComposerSuggestionList',
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -2,17 +2,17 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="mail.ComposerSuggestionList" owl="1">
|
||||
<t t-if="composerView">
|
||||
<div class="o_ComposerSuggestionList position-absolute w-100" t-att-class="{ 'o-lowPosition bottom-0': composerView.hasMentionSuggestionsBelowPosition }" t-attf-class="{{ className }}" t-ref="root">
|
||||
<div class="o_ComposerSuggestionList_drop w-100" t-att-class="{ 'dropdown': composerView.hasMentionSuggestionsBelowPosition, 'dropup': !composerView.hasMentionSuggestionsBelowPosition }">
|
||||
<t t-if="composerSuggestionListView">
|
||||
<div class="o_ComposerSuggestionList position-absolute w-100" t-att-class="{ 'o-lowPosition bottom-0': composerSuggestionListView.composerViewOwner.hasMentionSuggestionsBelowPosition }" t-attf-class="{{ className }}" t-ref="root">
|
||||
<div class="o_ComposerSuggestionList_drop w-100" t-att-class="{ 'dropdown': composerSuggestionListView.composerViewOwner.hasMentionSuggestionsBelowPosition, 'dropup': !composerSuggestionListView.composerViewOwner.hasMentionSuggestionsBelowPosition }">
|
||||
<div class="o_ComposerSuggestionList_list dropdown-menu show w-100 overflow-auto">
|
||||
<t t-foreach="composerView.mainSuggestions" t-as="suggestion" t-key="suggestion.localId">
|
||||
<t t-foreach="composerSuggestionListView.composerViewOwner.mainSuggestions" t-as="suggestion" t-key="suggestion.localId">
|
||||
<ComposerSuggestion localId="suggestion.localId"/>
|
||||
</t>
|
||||
<t t-if="composerView.mainSuggestions.length > 0 and composerView.extraSuggestions.length > 0">
|
||||
<t t-if="composerSuggestionListView.composerViewOwner.mainSuggestions.length > 0 and composerSuggestionListView.composerViewOwner.extraSuggestions.length > 0">
|
||||
<div role="separator" class="dropdown-divider"/>
|
||||
</t>
|
||||
<t t-foreach="composerView.extraSuggestions" t-as="suggestion" t-key="suggestion.localId">
|
||||
<t t-foreach="composerSuggestionListView.composerViewOwner.extraSuggestions" t-as="suggestion" t-key="suggestion.localId">
|
||||
<ComposerSuggestion localId="suggestion.localId"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
@@ -20,11 +20,6 @@ export class ComposerTextInput extends Component {
|
||||
* as textarea content can't be changed from the DOM.
|
||||
*/
|
||||
useUpdate({ func: () => this._update() });
|
||||
/**
|
||||
* Last content of textarea from input event. Useful to determine
|
||||
* whether the current partner is typing something.
|
||||
*/
|
||||
this._textareaLastInputValue = "";
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
@@ -107,10 +102,10 @@ export class ComposerTextInput extends Component {
|
||||
return;
|
||||
}
|
||||
this.composerView.saveStateInStore();
|
||||
if (this._textareaLastInputValue !== this.composerView.textareaRef.el.value) {
|
||||
if (this.composerView.textareaLastInputValue !== this.composerView.textareaRef.el.value) {
|
||||
this.composerView.handleCurrentPartnerIsTyping();
|
||||
}
|
||||
this._textareaLastInputValue = this.composerView.textareaRef.el.value;
|
||||
this.composerView.update({ textareaLastInputValue: this.composerView.textareaRef.el.value });
|
||||
this._updateHeight();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<t t-name="mail.ComposerTextInput" owl="1">
|
||||
<t t-if="composerView">
|
||||
<div class="o_ComposerTextInput" t-attf-class="{{ className }}" t-on-paste="composerView.onPasteTextInput" t-ref="root">
|
||||
<t t-if="composerView.hasSuggestions">
|
||||
<ComposerSuggestionList composerViewLocalId="composerView.localId"/>
|
||||
<t t-if="composerView.composerSuggestionListView">
|
||||
<ComposerSuggestionList localId="composerView.composerSuggestionListView.localId"/>
|
||||
</t>
|
||||
<textarea class="o_ComposerTextInput_textarea o_ComposerTextInput_textareaStyle" t-att-class="{ 'o-composer-is-compact': composerView.isCompact }" t-esc="composerView.composer.textInputContent" t-att-placeholder="composerView.composer.placeholder" t-on-click="composerView.onClickTextarea" t-on-focusin="composerView.onFocusinTextarea" t-on-focusout="composerView.onFocusoutTextarea" t-on-keydown="composerView.onKeydownTextarea" t-on-keyup="composerView.onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
|
||||
<!--
|
||||
|
||||
@@ -12,9 +12,7 @@ export class Discuss extends LegacyComponent {
|
||||
setup() {
|
||||
this._updateLocalStoreProps();
|
||||
// bind since passed as props
|
||||
this._onMobileAddItemHeaderInputSource = this._onMobileAddItemHeaderInputSource.bind(this);
|
||||
useUpdate({ func: () => this._update() });
|
||||
this._onHideMobileAddItemHeader = this._onHideMobileAddItemHeader.bind(this);
|
||||
}
|
||||
|
||||
_update() {
|
||||
@@ -78,37 +76,6 @@ export class Discuss extends LegacyComponent {
|
||||
);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onHideMobileAddItemHeader() {
|
||||
if (!this.discussView) {
|
||||
return;
|
||||
}
|
||||
this.discussView.discuss.clearIsAddingItem();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} req
|
||||
* @param {string} req.term
|
||||
* @param {function} res
|
||||
*/
|
||||
_onMobileAddItemHeaderInputSource(req, res) {
|
||||
if (!this.discussView) {
|
||||
return;
|
||||
}
|
||||
if (this.discussView.discuss.isAddingChannel) {
|
||||
this.discussView.discuss.handleAddChannelAutocompleteSource(req, res);
|
||||
} else {
|
||||
this.discussView.discuss.handleAddChatAutocompleteSource(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(Discuss, {
|
||||
|
||||
@@ -31,12 +31,10 @@
|
||||
<div class="o_Discuss_mobileAddItemHeader border-bottom">
|
||||
<AutocompleteInput
|
||||
className="'o_Discuss_mobileAddItemHeaderInput'"
|
||||
isFocusOnMount="true"
|
||||
isHtml="discussView.discuss.isAddingChannel"
|
||||
localId="discussView.mobileAddItemHeaderAutocompleteInputView.localId"
|
||||
select="discussView.onMobileAddItemHeaderInputSelect"
|
||||
source="_onMobileAddItemHeaderInputSource"
|
||||
onHide="_onHideMobileAddItemHeader"
|
||||
source="discussView.onMobileAddItemHeaderInputSource"
|
||||
onHide="discussView.onHideMobileAddItemHeader"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
-2
@@ -32,8 +32,6 @@
|
||||
className="'o_DiscussSidebarCategory_addingItemInput flex-grow-1'"
|
||||
customClass="category === messaging.discuss.categoryChannel ? 'o_DiscussSidebarCategory_newChannelAutocompleteSuggestions' : False"
|
||||
localId="category.addingItemAutocompleteInputView.localId"
|
||||
isFocusOnMount="true"
|
||||
isHtml="category === messaging.discuss.categoryChannel ? true : False"
|
||||
select="category.onAddItemAutocompleteSelect"
|
||||
source="category.onAddItemAutocompleteSource"
|
||||
onHide="category.onHideAddingItem"
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
t-att-class="{
|
||||
'o_Message_active': messageView.isActive,
|
||||
'o-clicked': messageView.isClicked,
|
||||
'o-discussion': messageView.message.is_discussion or messageView.message.is_notification,
|
||||
'o-discussion': messageView.message.isDiscussionOrNotification,
|
||||
'o-has-message-selection': messageView.threadView and messageView.threadView.replyingToMessageView,
|
||||
'o-highlighted': messageView.message.isHighlighted or messageView.isHighlighted,
|
||||
'o-mobile': messaging.device.isSmall,
|
||||
'o-not-discussion': !(messageView.message.is_discussion or messageView.message.is_notification),
|
||||
'o-not-discussion': !messageView.message.isDiscussionOrNotification,
|
||||
'o-notification': messageView.message.message_type === 'notification',
|
||||
'o-selected': messageView.isSelected,
|
||||
'o-squashed': messageView.isSquashed,
|
||||
'o-starred': messageView.message.isStarred,
|
||||
'mt-3': !messageView.isSquashed and messageView.threadView,
|
||||
'bg-white': (messageView.message.is_discussion or messageView.message.is_notification) and !messageView.isSelected and !(messageView.message.isHighlighted or messageView.isHighlighted) and !messageView.isActive,
|
||||
'bg-100': (!(messageView.message.is_discussion or messageView.message.is_notification) and !messageView.isActive and !messageView.isSelected) or (messageView.isActive and !messageView.isSelected and (messageView.message.is_discussion or messageView.message.is_notification) and !(messageView.message.isHighlighted or messageView.isHighlighted)),
|
||||
'bg-200': !(messageView.message.is_discussion or messageView.message.is_notification) and messageView.isActive and !messageView.isSelected,
|
||||
'bg-white': messageView.message.isDiscussionOrNotification and !messageView.isSelected and !(messageView.message.isHighlighted or messageView.isHighlighted) and !messageView.isActive,
|
||||
'bg-100': (!messageView.message.isDiscussionOrNotification and !messageView.isActive and !messageView.isSelected) or (messageView.isActive and !messageView.isSelected and messageView.message.isDiscussionOrNotification and !(messageView.message.isHighlighted or messageView.isHighlighted)),
|
||||
'bg-200': !messageView.message.isDiscussionOrNotification and messageView.isActive and !messageView.isSelected,
|
||||
'bg-400': messageView.isSelected and !(messageView.message.isHighlighted or messageView.isHighlighted),
|
||||
'opacity-50': (messageView.threadView and messageView.threadView.replyingToMessageView) and !messageView.isSelected,
|
||||
}" t-attf-class="{{ messageView.extraClass }} {{ className }}" t-on-click="messageView.onClick" t-on-mouseenter="messageView.onMouseenter" t-on-mouseleave="messageView.onMouseleave" t-att-data-message-local-id="messageView.message.localId"
|
||||
@@ -42,8 +42,8 @@
|
||||
'o-message-selected text-400': messageView.isSelected,
|
||||
'o_Message_partnerImStatusIcon-mobile': messaging.device.isSmall,
|
||||
'small': !messaging.device.isSmall,
|
||||
'text-white': !messageView.isSelected and (messageView.message.is_discussion or messageView.message.is_notification),
|
||||
'text-100': !(messageView.message.is_discussion or messageView.message.is_notification) and !messageView.isSelected,
|
||||
'text-white': !messageView.isSelected and messageView.message.isDiscussionOrNotification,
|
||||
'text-100': !messageView.message.isDiscussionOrNotification and !messageView.isSelected,
|
||||
}"
|
||||
hasOpenChat="messageView.hasAuthorOpenChat"
|
||||
partnerLocalId="messageView.message.author.localId"
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
<AutocompleteInput
|
||||
className="'o_MessagingMenu_mobileNewMessageInput'"
|
||||
customClass="id + '_mobileNewMessageInputAutocomplete'"
|
||||
isFocusOnMount="true"
|
||||
localId="messagingMenu.mobileNewMessageAutocompleteInputView.localId"
|
||||
select="messagingMenu.onMobileNewMessageInputSelect"
|
||||
source="messagingMenu.onMobileNewMessageInputSource"
|
||||
|
||||
@@ -213,19 +213,6 @@ export class ModelManager {
|
||||
return Boolean(existingRecord && existingRecord === record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the record of provided model that has provided
|
||||
* criteria, if it exists.
|
||||
*
|
||||
* @param {Object} model
|
||||
* @param {function} findFunc
|
||||
* @returns {Record|undefined} the record of model matching criteria, if
|
||||
* exists.
|
||||
*/
|
||||
find(model, findFunc) {
|
||||
return this.all(model).find(findFunc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the unique record of provided model that matches the given
|
||||
* identifying data, if it exists.
|
||||
|
||||
@@ -21,6 +21,35 @@ registerModel({
|
||||
this.chatWindowOwnerAsNewMessage.onFocusInNewMessageFormInput(ev);
|
||||
return;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean|FieldCommand}
|
||||
*/
|
||||
_computeIsFocusOnMount() {
|
||||
if (this.discussViewOwnerAsMobileAddItemHeader) {
|
||||
return true;
|
||||
}
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem) {
|
||||
return true;
|
||||
}
|
||||
if (this.messagingMenuOwnerAsMobileNewMessageInput) {
|
||||
return true;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean|FieldCommand}
|
||||
*/
|
||||
_computeIsHtml() {
|
||||
if (this.discussViewOwnerAsMobileAddItemHeader) {
|
||||
return this.discussViewOwnerAsMobileAddItemHeader.discuss.isAddingChannel;
|
||||
}
|
||||
if (this.discussSidebarCategoryOwnerAsAddingItem) {
|
||||
return this.discussSidebarCategoryOwnerAsAddingItem === this.messaging.discuss.categoryChannel;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
@@ -59,6 +88,14 @@ registerModel({
|
||||
inverse: 'mobileAddItemHeaderAutocompleteInputView',
|
||||
readonly: true,
|
||||
}),
|
||||
isFocusOnMount: attr({
|
||||
compute: '_computeIsFocusOnMount',
|
||||
default: false,
|
||||
}),
|
||||
isHtml: attr({
|
||||
compute: '_computeIsHtml',
|
||||
default: false,
|
||||
}),
|
||||
messagingMenuOwnerAsMobileNewMessageInput: one('MessagingMenu', {
|
||||
inverse: 'mobileNewMessageAutocompleteInputView',
|
||||
readonly: true,
|
||||
|
||||
@@ -104,6 +104,49 @@ registerModel({
|
||||
const lastVisible = this.manager.lastVisible;
|
||||
this.manager.swap(this, lastVisible);
|
||||
},
|
||||
/**
|
||||
* Called when selecting an item in the autocomplete input of the
|
||||
* 'new_message' chat window.
|
||||
*
|
||||
* @param {Event} ev
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item
|
||||
* @param {integer} ui.item.id
|
||||
*/
|
||||
async onAutocompleteSelect(ev, ui) {
|
||||
const chat = await this.messaging.getChat({ partnerId: ui.item.id });
|
||||
if (!chat) {
|
||||
return;
|
||||
}
|
||||
this.messaging.chatWindowManager.openThread(chat, {
|
||||
makeActive: true,
|
||||
replaceNewMessage: true,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Called when typing in the autocomplete input of the 'new_message' chat
|
||||
* window.
|
||||
*
|
||||
* @param {Object} req
|
||||
* @param {string} req.term
|
||||
* @param {function} res
|
||||
*/
|
||||
onAutocompleteSource(req, res) {
|
||||
this.messaging.models['Partner'].imSearch({
|
||||
callback: (partners) => {
|
||||
const suggestions = partners.map(partner => {
|
||||
return {
|
||||
id: partner.id,
|
||||
value: partner.nameOrDisplayName,
|
||||
label: partner.nameOrDisplayName,
|
||||
};
|
||||
});
|
||||
res(_.sortBy(suggestions, 'label'));
|
||||
},
|
||||
keyword: _.escape(req.term),
|
||||
limit: 10,
|
||||
});
|
||||
},
|
||||
onClickFromChatWindowHiddenMenu() {
|
||||
this.makeActive();
|
||||
this.manager.closeHiddenMenu();
|
||||
|
||||
@@ -115,9 +115,7 @@ registerModel({
|
||||
if (notifyServer === undefined) {
|
||||
notifyServer = !this.messaging.device.isSmall;
|
||||
}
|
||||
let chatWindow = this.chatWindows.find(chatWindow =>
|
||||
chatWindow.thread === thread
|
||||
);
|
||||
let chatWindow = thread.chatWindow;
|
||||
if (!chatWindow) {
|
||||
chatWindow = this.messaging.models['ChatWindow'].create({
|
||||
isFolded,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerModel } from '@mail/model/model_core';
|
||||
import { one } from '@mail/model/model_field';
|
||||
|
||||
registerModel({
|
||||
name: 'ComposerSuggestionListView',
|
||||
identifyingFields: ['composerViewOwner'],
|
||||
fields: {
|
||||
composerViewOwner: one('ComposerView', {
|
||||
inverse: 'composerSuggestionListView',
|
||||
readonly: true,
|
||||
required: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1380,6 +1380,16 @@ registerModel({
|
||||
),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {FieldCommand}
|
||||
*/
|
||||
_computeComposerSuggestionListView() {
|
||||
if (this.hasSuggestions) {
|
||||
return insertAndReplace();
|
||||
}
|
||||
return clear();
|
||||
}
|
||||
},
|
||||
fields: {
|
||||
/**
|
||||
@@ -1427,6 +1437,11 @@ registerModel({
|
||||
inverse: 'composerViewOwner',
|
||||
isCausal: true,
|
||||
}),
|
||||
composerSuggestionListView: one('ComposerSuggestionListView', {
|
||||
compute: '_computeComposerSuggestionListView',
|
||||
inverse: 'composerViewOwner',
|
||||
isCausal: true,
|
||||
}),
|
||||
/**
|
||||
* Current partner image URL.
|
||||
*/
|
||||
@@ -1547,6 +1562,13 @@ registerModel({
|
||||
isFocused: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Last content of textarea from input event. Useful to determine
|
||||
* whether the current partner is typing something.
|
||||
*/
|
||||
textareaLastInputValue: attr({
|
||||
default: "",
|
||||
}),
|
||||
/**
|
||||
* Determines the main suggestions.
|
||||
*/
|
||||
|
||||
@@ -41,6 +41,12 @@ registerModel({
|
||||
}
|
||||
this.discuss.threadView.topbar.openInvitePopoverView();
|
||||
},
|
||||
onHideMobileAddItemHeader() {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
this.discuss.clearIsAddingItem();
|
||||
},
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
@@ -75,6 +81,21 @@ registerModel({
|
||||
this.discuss.handleAddChatAutocompleteSelect(ev, ui);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {Object} req
|
||||
* @param {string} req.term
|
||||
* @param {function} res
|
||||
*/
|
||||
_onMobileAddItemHeaderInputSource(req, res) {
|
||||
if (!this.exists()) {
|
||||
return;
|
||||
}
|
||||
if (this.discuss.isAddingChannel) {
|
||||
this.discuss.handleAddChannelAutocompleteSource(req, res);
|
||||
} else {
|
||||
this.discuss.handleAddChatAutocompleteSource(req, res);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {FieldCommand}
|
||||
|
||||
@@ -463,6 +463,16 @@ registerModel({
|
||||
_computeIsCurrentPartnerMentioned() {
|
||||
return this.recipients.includes(this.messaging.currentPartner);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean|FieldCommand}
|
||||
*/
|
||||
_computeIsDiscussionOrNotification() {
|
||||
if (this.is_discussion || this.is_notification) {
|
||||
return true;
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
/**
|
||||
* The method does not attempt to cover all possible cases of empty
|
||||
* messages, but mostly those that happen with a standard flow. Indeed
|
||||
@@ -801,6 +811,10 @@ registerModel({
|
||||
compute: '_computeIsBodyEqualSubtypeDescription',
|
||||
default: false,
|
||||
}),
|
||||
isDiscussionOrNotification: attr({
|
||||
compute: '_computeIsDiscussionOrNotification',
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* Determine whether the message has to be considered empty or not.
|
||||
*
|
||||
|
||||
@@ -418,6 +418,9 @@ registerModel({
|
||||
compute: '_computeDisplayName',
|
||||
default: "",
|
||||
}),
|
||||
dmChatWithCurrentPartner: one('Thread', {
|
||||
inverse: 'correspondentOfDmChat',
|
||||
}),
|
||||
email: attr(),
|
||||
/**
|
||||
* Whether an attempt was already made to fetch the user corresponding
|
||||
|
||||
@@ -63,15 +63,6 @@ registerModel({
|
||||
create(data) {
|
||||
return this.modelManager.create(this, data);
|
||||
},
|
||||
/**
|
||||
* Get the record that has provided criteria, if it exists.
|
||||
*
|
||||
* @param {function} findFunc
|
||||
* @returns {Record|undefined}
|
||||
*/
|
||||
find(findFunc) {
|
||||
return this.modelManager.find(this, findFunc);
|
||||
},
|
||||
/**
|
||||
* Gets the unique record that matches the given identifying data, if it
|
||||
* exists.
|
||||
|
||||
@@ -25,60 +25,6 @@ registerModel({
|
||||
identifyingFields: ['model', 'id'],
|
||||
lifecycleHooks: {
|
||||
_created() {
|
||||
/**
|
||||
* Timer of current partner that was currently typing something, but
|
||||
* there is no change on the input for 5 seconds. This is used
|
||||
* in order to automatically notify other members that current
|
||||
* partner has stopped typing something, due to making no changes
|
||||
* on the composer for some time.
|
||||
*/
|
||||
this._currentPartnerInactiveTypingTimer = new Timer(
|
||||
this.messaging,
|
||||
() => {
|
||||
if (this.messaging.currentPartner) {
|
||||
return this._onCurrentPartnerInactiveTypingTimeout();
|
||||
}
|
||||
},
|
||||
5 * 1000
|
||||
);
|
||||
/**
|
||||
* Last 'is_typing' status of current partner that has been notified
|
||||
* to other members. Useful to prevent spamming typing notifications
|
||||
* to other members if it hasn't changed. An exception is the
|
||||
* current partner long typing scenario where current partner has
|
||||
* to re-send the same typing notification from time to time, so
|
||||
* that other members do not assume he/she is no longer typing
|
||||
* something from not receiving any typing notifications for a
|
||||
* very long time.
|
||||
*
|
||||
* Supported values: true/false/undefined.
|
||||
* undefined makes only sense initially and during current partner
|
||||
* long typing timeout flow.
|
||||
*/
|
||||
this._currentPartnerLastNotifiedIsTyping = undefined;
|
||||
/**
|
||||
* Timer of current partner that is typing a very long text. When
|
||||
* the other members do not receive any typing notification for a
|
||||
* long time, they must assume that the related partner is no longer
|
||||
* typing something (e.g. they have closed the browser tab).
|
||||
* This is a timer to let other members know that current partner
|
||||
* is still typing something, so that they should not assume he/she
|
||||
* has stopped typing something.
|
||||
*/
|
||||
this._currentPartnerLongTypingTimer = new Timer(
|
||||
this.messaging,
|
||||
() => this._onCurrentPartnerLongTypingTimeout(),
|
||||
50 * 1000
|
||||
);
|
||||
/**
|
||||
* Determines whether the next request to notify current partner
|
||||
* typing status should always result to making RPC, regardless of
|
||||
* whether last notified current partner typing status is the same.
|
||||
* Most of the time we do not want to notify if value hasn't
|
||||
* changed, exception being the long typing scenario of current
|
||||
* partner.
|
||||
*/
|
||||
this._forceNotifyNextCurrentPartnerTypingStatus = false;
|
||||
/**
|
||||
* Registry of timers of partners currently typing in the thread,
|
||||
* excluding current partner. This is useful in order to
|
||||
@@ -91,29 +37,11 @@ registerModel({
|
||||
* @see unregisterOtherMemberTypingMember
|
||||
*/
|
||||
this._otherMembersLongTypingTimers = new Map();
|
||||
|
||||
/**
|
||||
* Clearable and cancellable throttled version of the
|
||||
* `_notifyCurrentPartnerTypingStatus` method.
|
||||
* This is useful when the current partner posts a message and
|
||||
* types something else afterwards: it must notify immediately that
|
||||
* he/she is typing something, instead of waiting for the throttle
|
||||
* internal timer.
|
||||
*
|
||||
* @see _notifyCurrentPartnerTypingStatus
|
||||
*/
|
||||
this._throttleNotifyCurrentPartnerTypingStatus = throttle(
|
||||
this.messaging,
|
||||
({ isTyping }) => {
|
||||
this._notifyCurrentPartnerTypingStatus({ isTyping });
|
||||
},
|
||||
2.5 * 1000
|
||||
);
|
||||
},
|
||||
_willDelete() {
|
||||
this._currentPartnerInactiveTypingTimer.clear();
|
||||
this._currentPartnerLongTypingTimer.clear();
|
||||
this._throttleNotifyCurrentPartnerTypingStatus.clear();
|
||||
this.currentPartnerInactiveTypingTimer.clear();
|
||||
this.currentPartnerLongTypingTimer.clear();
|
||||
this.throttleNotifyCurrentPartnerTypingStatus.clear();
|
||||
for (const timer of this._otherMembersLongTypingTimers.values()) {
|
||||
timer.clear();
|
||||
}
|
||||
@@ -972,7 +900,7 @@ registerModel({
|
||||
* Refresh the typing status of the current partner.
|
||||
*/
|
||||
refreshCurrentPartnerIsTyping() {
|
||||
this._currentPartnerInactiveTypingTimer.reset();
|
||||
this.currentPartnerInactiveTypingTimer.reset();
|
||||
},
|
||||
/**
|
||||
* Called to refresh a registered other member partner that is typing
|
||||
@@ -990,8 +918,8 @@ registerModel({
|
||||
*/
|
||||
async registerCurrentPartnerIsTyping() {
|
||||
// Handling of typing timers.
|
||||
this._currentPartnerInactiveTypingTimer.start();
|
||||
this._currentPartnerLongTypingTimer.start();
|
||||
this.currentPartnerInactiveTypingTimer.start();
|
||||
this.currentPartnerLongTypingTimer.start();
|
||||
// Manage typing member relation.
|
||||
const currentPartner = this.messaging.currentPartner;
|
||||
const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
|
||||
@@ -1002,7 +930,7 @@ registerModel({
|
||||
typingMembers: link(currentPartner),
|
||||
});
|
||||
// Notify typing status to other members.
|
||||
await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
|
||||
await this.throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
|
||||
},
|
||||
/**
|
||||
* Called to register a new other member partner that is typing
|
||||
@@ -1088,8 +1016,8 @@ registerModel({
|
||||
*/
|
||||
async unregisterCurrentPartnerIsTyping({ immediateNotify = false } = {}) {
|
||||
// Handling of typing timers.
|
||||
this._currentPartnerInactiveTypingTimer.clear();
|
||||
this._currentPartnerLongTypingTimer.clear();
|
||||
this.currentPartnerInactiveTypingTimer.clear();
|
||||
this.currentPartnerLongTypingTimer.clear();
|
||||
// Manage typing member relation.
|
||||
const currentPartner = this.messaging.currentPartner;
|
||||
const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds
|
||||
@@ -1100,9 +1028,9 @@ registerModel({
|
||||
});
|
||||
// Notify typing status to other members.
|
||||
if (immediateNotify) {
|
||||
this._throttleNotifyCurrentPartnerTypingStatus.clear();
|
||||
this.throttleNotifyCurrentPartnerTypingStatus.clear();
|
||||
}
|
||||
await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: false });
|
||||
await this.throttleNotifyCurrentPartnerTypingStatus({ isTyping: false });
|
||||
},
|
||||
/**
|
||||
* Called to unregister an other member partner that is no longer typing
|
||||
@@ -1178,6 +1106,32 @@ registerModel({
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Timer}
|
||||
*/
|
||||
_computeCurrentPartnerLongTypingTimer() {
|
||||
return new Timer(
|
||||
this.messaging,
|
||||
() => this._onCurrentPartnerLongTypingTimeout(),
|
||||
50 * 1000
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {FieldCommand}
|
||||
*/
|
||||
_computeCorrespondentOfDmChat() {
|
||||
if (
|
||||
this.channel_type === 'chat' &&
|
||||
this.correspondent &&
|
||||
this.model === 'mail.channel' &&
|
||||
this.public === 'private'
|
||||
) {
|
||||
return replace(this.correspondent);
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {FieldCommand}
|
||||
@@ -1335,6 +1289,21 @@ registerModel({
|
||||
follower.partner && follower.partner === this.messaging.currentPartner
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Timer}
|
||||
*/
|
||||
_computeCurrentPartnerInactiveTypingTimer() {
|
||||
return new Timer(
|
||||
this.messaging,
|
||||
() => {
|
||||
if (this.messaging.currentPartner) {
|
||||
return this._onCurrentPartnerInactiveTypingTimeout();
|
||||
}
|
||||
},
|
||||
5 * 1000
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
@@ -1610,6 +1579,17 @@ registerModel({
|
||||
}
|
||||
return clear();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Throttle}
|
||||
*/
|
||||
_computeThrottleNotifyCurrentPartnerTypingStatus() {
|
||||
return throttle(
|
||||
this.messaging,
|
||||
({ isTyping }) => this._notifyCurrentPartnerTypingStatus({ isTyping }),
|
||||
2.5 * 1000
|
||||
);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Activity[]}
|
||||
@@ -1694,8 +1674,8 @@ registerModel({
|
||||
*/
|
||||
async _notifyCurrentPartnerTypingStatus({ isTyping }) {
|
||||
if (
|
||||
this._forceNotifyNextCurrentPartnerTypingStatus ||
|
||||
isTyping !== this._currentPartnerLastNotifiedIsTyping
|
||||
this.forceNotifyNextCurrentPartnerTypingStatus ||
|
||||
isTyping !== this.currentPartnerLastNotifiedIsTyping
|
||||
) {
|
||||
if (this.model === 'mail.channel') {
|
||||
await this.messaging.rpc({
|
||||
@@ -1708,12 +1688,14 @@ registerModel({
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isTyping && this._currentPartnerLongTypingTimer.isRunning) {
|
||||
this._currentPartnerLongTypingTimer.reset();
|
||||
if (isTyping && this.currentPartnerLongTypingTimer.isRunning) {
|
||||
this.currentPartnerLongTypingTimer.reset();
|
||||
}
|
||||
}
|
||||
this._forceNotifyNextCurrentPartnerTypingStatus = false;
|
||||
this._currentPartnerLastNotifiedIsTyping = isTyping;
|
||||
this.update({
|
||||
currentPartnerLastNotifiedIsTyping: isTyping,
|
||||
forceNotifyNextCurrentPartnerTypingStatus: false,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
@@ -1860,9 +1842,9 @@ registerModel({
|
||||
* @private
|
||||
*/
|
||||
async _onCurrentPartnerLongTypingTimeout() {
|
||||
this._forceNotifyNextCurrentPartnerTypingStatus = true;
|
||||
this._throttleNotifyCurrentPartnerTypingStatus.clear();
|
||||
await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
|
||||
this.update({ forceNotifyNextCurrentPartnerTypingStatus: true });
|
||||
this.throttleNotifyCurrentPartnerTypingStatus.clear();
|
||||
await this.throttleNotifyCurrentPartnerTypingStatus({ isTyping: true });
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
@@ -1925,10 +1907,51 @@ registerModel({
|
||||
correspondent: one('Partner', {
|
||||
compute: '_computeCorrespondent',
|
||||
}),
|
||||
correspondentOfDmChat: one('Partner', {
|
||||
compute: '_computeCorrespondentOfDmChat',
|
||||
inverse: 'dmChatWithCurrentPartner',
|
||||
}),
|
||||
counter: attr({
|
||||
default: 0,
|
||||
}),
|
||||
creator: one('User'),
|
||||
/**
|
||||
* Timer of current partner that was currently typing something, but
|
||||
* there is no change on the input for 5 seconds. This is used
|
||||
* in order to automatically notify other members that current
|
||||
* partner has stopped typing something, due to making no changes
|
||||
* on the composer for some time.
|
||||
*/
|
||||
currentPartnerInactiveTypingTimer: attr({
|
||||
compute: '_computeCurrentPartnerInactiveTypingTimer',
|
||||
}),
|
||||
/**
|
||||
* Last 'is_typing' status of current partner that has been notified
|
||||
* to other members. Useful to prevent spamming typing notifications
|
||||
* to other members if it hasn't changed. An exception is the
|
||||
* current partner long typing scenario where current partner has
|
||||
* to re-send the same typing notification from time to time, so
|
||||
* that other members do not assume he/she is no longer typing
|
||||
* something from not receiving any typing notifications for a
|
||||
* very long time.
|
||||
*
|
||||
* Supported values: true/false/undefined.
|
||||
* undefined makes only sense initially and during current partner
|
||||
* long typing timeout flow.
|
||||
*/
|
||||
currentPartnerLastNotifiedIsTyping: attr(),
|
||||
/**
|
||||
* Timer of current partner that is typing a very long text. When
|
||||
* the other members do not receive any typing notification for a
|
||||
* long time, they must assume that the related partner is no longer
|
||||
* typing something (e.g. they have closed the browser tab).
|
||||
* This is a timer to let other members know that current partner
|
||||
* is still typing something, so that they should not assume he/she
|
||||
* has stopped typing something.
|
||||
*/
|
||||
currentPartnerLongTypingTimer: attr({
|
||||
compute: '_computeCurrentPartnerLongTypingTimer',
|
||||
}),
|
||||
custom_channel_name: attr(),
|
||||
/**
|
||||
* Determines the default display mode of this channel. Should contain
|
||||
@@ -1965,6 +1988,17 @@ registerModel({
|
||||
followers: many('Follower', {
|
||||
inverse: 'followedThread',
|
||||
}),
|
||||
/**
|
||||
* Determines whether the next request to notify current partner
|
||||
* typing status should always result to making RPC, regardless of
|
||||
* whether last notified current partner typing status is the same.
|
||||
* Most of the time we do not want to notify if value hasn't
|
||||
* changed, exception being the long typing scenario of current
|
||||
* partner.
|
||||
*/
|
||||
forceNotifyNextCurrentPartnerTypingStatus: attr({
|
||||
default: false,
|
||||
}),
|
||||
/**
|
||||
* States the `Activity` that belongs to `this` and that are
|
||||
* planned in the future (due later than today).
|
||||
@@ -2398,6 +2432,19 @@ registerModel({
|
||||
threadViews: many('ThreadView', {
|
||||
inverse: 'thread',
|
||||
}),
|
||||
/**
|
||||
* Clearable and cancellable throttled version of the
|
||||
* `_notifyCurrentPartnerTypingStatus` method.
|
||||
* This is useful when the current partner posts a message and
|
||||
* types something else afterwards: it must notify immediately that
|
||||
* he/she is typing something, instead of waiting for the throttle
|
||||
* internal timer.
|
||||
*
|
||||
* @see _notifyCurrentPartnerTypingStatus
|
||||
*/
|
||||
throttleNotifyCurrentPartnerTypingStatus: attr({
|
||||
compute: '_computeThrottleNotifyCurrentPartnerTypingStatus',
|
||||
}),
|
||||
/**
|
||||
* States the `Activity` that belongs to `this` and that are due
|
||||
* specifically today.
|
||||
|
||||
@@ -91,12 +91,7 @@ registerModel({
|
||||
return;
|
||||
}
|
||||
// in other cases a chat would be valid, find it or try to create it
|
||||
let chat = this.messaging.models['Thread'].find(thread =>
|
||||
thread.channel_type === 'chat' &&
|
||||
thread.correspondent === this.partner &&
|
||||
thread.model === 'mail.channel' &&
|
||||
thread.public === 'private'
|
||||
);
|
||||
let chat = this.partner.dmChatWithCurrentPartner;
|
||||
if (!chat || !chat.isPinned) {
|
||||
// if chat is not pinned then it has to be pinned client-side
|
||||
// and server-side, which is a side effect of following rpc
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
start,
|
||||
startServer,
|
||||
} from '@mail/../tests/helpers/test_utils';
|
||||
|
||||
QUnit.module('mail', {}, function () {
|
||||
QUnit.module('components', {}, function () {
|
||||
QUnit.module('discuss_tests.js', {
|
||||
beforeEach() {
|
||||
this.start = async params => {
|
||||
return start(Object.assign({}, params, {
|
||||
autoOpenDiscuss: true,
|
||||
hasDiscuss: true,
|
||||
}));
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test('there should be a button to show member list in the thread view topbar initially', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const resPartnerId1 = pyEnv['res.partner'].create({ name: "Demo" });
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
channel_last_seen_partner_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: resPartnerId1 }],
|
||||
],
|
||||
channel_type: 'group',
|
||||
public: 'private',
|
||||
});
|
||||
await this.start({
|
||||
discuss: {
|
||||
context: {
|
||||
active_id: `mail.channel_${mailChannelId1}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ThreadViewTopbar_showMemberListButton',
|
||||
"there should be a button to show member list in the thread view topbar initially",
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('should show member list when clicking on show member list button in thread view topbar', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const resPartnerId1 = pyEnv['res.partner'].create({ name: "Demo" });
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
channel_last_seen_partner_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: resPartnerId1 }],
|
||||
],
|
||||
channel_type: 'group',
|
||||
public: 'private',
|
||||
});
|
||||
const { click } = await this.start({
|
||||
discuss: {
|
||||
context: {
|
||||
active_id: `mail.channel_${mailChannelId1}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
await click('.o_ThreadViewTopbar_showMemberListButton');
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ChannelMemberList',
|
||||
"should show member list when clicking on show member list button in thread view topbar",
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('should have correct members in member list', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const resPartnerId1 = pyEnv['res.partner'].create({ name: "Demo" });
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
channel_last_seen_partner_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: resPartnerId1 }],
|
||||
],
|
||||
channel_type: 'group',
|
||||
public: 'private',
|
||||
});
|
||||
const { click, messaging } = await this.start({
|
||||
discuss: {
|
||||
context: {
|
||||
active_id: `mail.channel_${mailChannelId1}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
await click('.o_ThreadViewTopbar_showMemberListButton');
|
||||
assert.containsN(
|
||||
document.body,
|
||||
'.o_ChannelMember',
|
||||
2,
|
||||
"should have 2 members in member list",
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_ChannelMember[data-partner-local-id="${
|
||||
messaging.models['Partner'].findFromIdentifyingData({ id: pyEnv.currentPartnerId }).localId
|
||||
}"]`,
|
||||
"should have current partner in member list (current partner is a member)",
|
||||
);
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_ChannelMember[data-partner-local-id="${
|
||||
messaging.models['Partner'].findFromIdentifyingData({ id: resPartnerId1 }).localId
|
||||
}"]`,
|
||||
"should have 'Demo' in member list ('Demo' is a member)",
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test('there should be a button to hide member list in the thread view topbar when the member list is visible', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
const resPartnerId1 = pyEnv['res.partner'].create({ name: "Demo" });
|
||||
const mailChannelId1 = pyEnv['mail.channel'].create({
|
||||
channel_last_seen_partner_ids: [
|
||||
[0, 0, { partner_id: pyEnv.currentPartnerId }],
|
||||
[0, 0, { partner_id: resPartnerId1 }],
|
||||
],
|
||||
channel_type: 'group',
|
||||
public: 'private',
|
||||
});
|
||||
const { click } = await this.start({
|
||||
discuss: {
|
||||
context: {
|
||||
active_id: `mail.channel_${mailChannelId1}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
await click('.o_ThreadViewTopbar_showMemberListButton');
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
'.o_ThreadViewTopbar_hideMemberListButton',
|
||||
"there should be a button to hide member list in the thread view topbar when the member list is visible",
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -181,7 +181,7 @@ QUnit.test('toggle follower subtype checkbox', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const pyEnv = await startServer();
|
||||
pyEnv['mail.message.subtype'].create({
|
||||
const followerSubtypeId = pyEnv['mail.message.subtype'].create({
|
||||
default: true,
|
||||
name: 'TestSubtype',
|
||||
});
|
||||
@@ -224,7 +224,7 @@ QUnit.test('toggle follower subtype checkbox', async function (assert) {
|
||||
});
|
||||
await click('.o_FollowerListMenu_buttonFollowers');
|
||||
await click('.o_Follower_editButton');
|
||||
const followerSubtype = messaging.models['FollowerSubtype'].find(subtype => subtype.name === 'TestSubtype');
|
||||
const followerSubtype = messaging.models['FollowerSubtype'].findFromIdentifyingData({ id: followerSubtypeId });
|
||||
const followerSubtypeView = followerSubtype.followerSubtypeViews[0];
|
||||
assert.notOk(
|
||||
document.querySelector(`.o_FollowerSubtype[data-local-id="${followerSubtypeView.localId}"] .o_FollowerSubtype_checkbox`).checked,
|
||||
|
||||
@@ -1527,6 +1527,7 @@ QUnit.test('first unseen message should be directly preceded by the new message
|
||||
// send a command that leads to receiving a transient message
|
||||
await insertText('.o_ComposerTextInput_textarea', "/who");
|
||||
await click('.o_Composer_buttonSend');
|
||||
const transientMessage = threadViewer.threadView.messageViews[0].message;
|
||||
|
||||
// composer is focused by default, we remove that focus
|
||||
document.querySelector('.o_ComposerTextInput_textarea').blur();
|
||||
@@ -1555,7 +1556,7 @@ QUnit.test('first unseen message should be directly preceded by the new message
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
`.o_Message[data-message-local-id="${
|
||||
messaging.models['Message'].find(m => m.isTransient).localId
|
||||
transientMessage.localId
|
||||
}"] + .o_MessageList_separatorNewMessages`,
|
||||
"separator should be shown just after transient message"
|
||||
);
|
||||
|
||||
@@ -67,23 +67,12 @@ QUnit.test('openChat: open new chat for user', async function (assert) {
|
||||
pyEnv['res.users'].create({ partner_id: resPartnerId1 });
|
||||
|
||||
const { messaging } = await start({ data: this.data });
|
||||
const existingChat = messaging.models['Thread'].find(thread =>
|
||||
thread.channel_type === 'chat' &&
|
||||
thread.correspondent &&
|
||||
thread.correspondent.id === resPartnerId1 &&
|
||||
thread.model === 'mail.channel' &&
|
||||
thread.public === 'private'
|
||||
);
|
||||
const partner = messaging.models['Partner'].findFromIdentifyingData({ id: resPartnerId1 });
|
||||
const existingChat = partner ? partner.dmChatWithCurrentPartner : undefined;
|
||||
assert.notOk(existingChat, 'a chat should not exist with the target partner initially');
|
||||
|
||||
await messaging.openChat({ partnerId: resPartnerId1 });
|
||||
const chat = messaging.models['Thread'].find(thread =>
|
||||
thread.channel_type === 'chat' &&
|
||||
thread.correspondent &&
|
||||
thread.correspondent.id === resPartnerId1 &&
|
||||
thread.model === 'mail.channel' &&
|
||||
thread.public === 'private'
|
||||
);
|
||||
const chat = messaging.models['Partner'].findFromIdentifyingData({ id: resPartnerId1 }).dmChatWithCurrentPartner;
|
||||
assert.ok(chat, 'a chat should exist with the target partner');
|
||||
assert.strictEqual(chat.threadViews.length, 1, 'the chat should be displayed in a `ThreadView`');
|
||||
});
|
||||
@@ -103,13 +92,7 @@ QUnit.test('openChat: open existing chat for user', async function (assert) {
|
||||
public: 'private',
|
||||
});
|
||||
const { messaging } = await start();
|
||||
const existingChat = messaging.models['Thread'].find(thread =>
|
||||
thread.channel_type === 'chat' &&
|
||||
thread.correspondent &&
|
||||
thread.correspondent.id === resPartnerId1 &&
|
||||
thread.model === 'mail.channel' &&
|
||||
thread.public === 'private'
|
||||
);
|
||||
const existingChat = messaging.models['Partner'].findFromIdentifyingData({ id: resPartnerId1 }).dmChatWithCurrentPartner;
|
||||
assert.ok(existingChat, 'a chat should initially exist with the target partner');
|
||||
assert.strictEqual(existingChat.threadViews.length, 0, 'the chat should not be displayed in a `ThreadView`');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user