+ Create ComposerTextInputView model + Replace uses of useRef with useRefToModel + Replace use of useUpdate with useUpdateToModel + Remove dead code (ComposerTextInput._isEmpty) Task-3001200. closes odoo/odoo#101707 Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
1460 lines
56 KiB
JavaScript
1460 lines
56 KiB
JavaScript
/** @odoo-module **/
|
|
|
|
import { registerModel } from '@mail/model/model_core';
|
|
import { attr, many, one } from '@mail/model/model_field';
|
|
import { clear, link } from '@mail/model/model_field_command';
|
|
import { addLink, escapeAndCompactTextContent, parseAndTransform } from '@mail/js/utils';
|
|
import { isEventHandled, markEventHandled } from '@mail/utils/utils';
|
|
|
|
import { escape, sprintf } from '@web/core/utils/strings';
|
|
import { url } from '@web/core/utils/urls';
|
|
|
|
registerModel({
|
|
name: 'ComposerView',
|
|
identifyingMode: 'xor',
|
|
lifecycleHooks: {
|
|
_created() {
|
|
document.addEventListener('click', this.onClickCaptureGlobal, true);
|
|
},
|
|
_willDelete() {
|
|
document.removeEventListener('click', this.onClickCaptureGlobal, true);
|
|
},
|
|
},
|
|
recordMethods: {
|
|
/**
|
|
* Closes the suggestion list.
|
|
*/
|
|
closeSuggestions() {
|
|
this.update({ suggestionDelimiterPosition: clear() });
|
|
},
|
|
/**
|
|
* Returns whether the given html element is inside this composer view,
|
|
* including whether it's inside the emoji popover when active.
|
|
*
|
|
* @param {Element} element
|
|
* @returns {boolean}
|
|
*/
|
|
contains(element) {
|
|
// emoji popover is outside but should be considered inside
|
|
if (this.emojisPopoverView && this.emojisPopoverView.contains(element)) {
|
|
return true;
|
|
}
|
|
return Boolean(this.component && this.component.root.el && this.component.root.el.contains(element));
|
|
},
|
|
/**
|
|
* Hides the composer, which only makes sense if the composer is
|
|
* currently used as a Discuss Inbox reply composer or as message
|
|
* editing.
|
|
*/
|
|
discard() {
|
|
if (this.messageViewInEditing) {
|
|
this.messageViewInEditing.stopEditing();
|
|
return;
|
|
}
|
|
if (this.threadView && this.threadView.replyingToMessageView) {
|
|
const { threadView } = this;
|
|
if (this.threadView.thread === this.messaging.inbox.thread) {
|
|
this.delete();
|
|
}
|
|
threadView.update({ replyingToMessageView: clear() });
|
|
}
|
|
},
|
|
/**
|
|
* Called when current partner is inserting some input in composer.
|
|
* Useful to notify current partner is currently typing something in the
|
|
* composer of this thread to all other members.
|
|
*/
|
|
handleCurrentPartnerIsTyping() {
|
|
if (!this.composer.thread || !this.composer.thread.channel) {
|
|
return; // not supported for non-thread composer (eg. messaging editing)
|
|
}
|
|
if (
|
|
this.suggestionModelName === 'ChannelCommand' ||
|
|
this._getCommandFromText(this.composer.textInputContent)
|
|
) {
|
|
return;
|
|
}
|
|
if (this.composer.thread.typingMembers.includes(this.composer.thread.channel.memberOfCurrentUser)) {
|
|
this.composer.thread.refreshCurrentPartnerIsTyping();
|
|
} else {
|
|
this.composer.thread.registerCurrentPartnerIsTyping();
|
|
}
|
|
},
|
|
/**
|
|
* Inserts text content in text input based on selection.
|
|
*
|
|
* @param {string} content
|
|
*/
|
|
insertIntoTextInput(content) {
|
|
const partA = this.composer.textInputContent.slice(0, this.composer.textInputCursorStart);
|
|
const partB = this.composer.textInputContent.slice(
|
|
this.composer.textInputCursorEnd,
|
|
this.composer.textInputContent.length
|
|
);
|
|
let suggestionDelimiterPosition = this.suggestionDelimiterPosition;
|
|
if (
|
|
suggestionDelimiterPosition !== undefined &&
|
|
suggestionDelimiterPosition >= this.composer.textInputCursorStart
|
|
) {
|
|
suggestionDelimiterPosition = suggestionDelimiterPosition + content.length;
|
|
}
|
|
this.composer.update({
|
|
textInputContent: partA + content + partB,
|
|
textInputCursorEnd: this.composer.textInputCursorStart + content.length,
|
|
textInputCursorStart: this.composer.textInputCursorStart + content.length,
|
|
});
|
|
for (const composerView of this.composer.composerViews) {
|
|
composerView.update({ hasToRestoreContent: true });
|
|
}
|
|
this.update({ suggestionDelimiterPosition });
|
|
},
|
|
/**
|
|
* Inserts the active suggestion at the current cursor position.
|
|
*/
|
|
insertSuggestion() {
|
|
const cursorPosition = this.composer.textInputCursorStart;
|
|
let textLeft = this.composer.textInputContent.substring(
|
|
0,
|
|
this.suggestionDelimiterPosition + 1
|
|
);
|
|
let textRight = this.composer.textInputContent.substring(
|
|
cursorPosition,
|
|
this.composer.textInputContent.length
|
|
);
|
|
if (this.suggestionDelimiter === ':') {
|
|
textLeft = this.composer.textInputContent.substring(
|
|
0,
|
|
this.suggestionDelimiterPosition
|
|
);
|
|
textRight = this.composer.textInputContent.substring(
|
|
cursorPosition,
|
|
this.composer.textInputContent.length
|
|
);
|
|
}
|
|
const recordReplacement = this.composerSuggestionListView.activeSuggestionView.mentionText;
|
|
const updateData = {
|
|
textInputContent: textLeft + recordReplacement + ' ' + textRight,
|
|
textInputCursorEnd: textLeft.length + recordReplacement.length + 1,
|
|
textInputCursorStart: textLeft.length + recordReplacement.length + 1,
|
|
};
|
|
// Specific cases for channel and partner mentions: the message with
|
|
// the mention will appear in the target channel, or be notified to
|
|
// the target partner.
|
|
if (this.composerSuggestionListView.activeSuggestionView.suggestable.thread) {
|
|
Object.assign(updateData, { rawMentionedChannels: link(this.composerSuggestionListView.activeSuggestionView.suggestable.thread) });
|
|
}
|
|
if (this.composerSuggestionListView.activeSuggestionView.suggestable.partner) {
|
|
Object.assign(updateData, { rawMentionedPartners: link(this.composerSuggestionListView.activeSuggestionView.suggestable.partner) });
|
|
}
|
|
this.composer.update(updateData);
|
|
for (const composerView of this.composer.composerViews) {
|
|
composerView.update({ hasToRestoreContent: true });
|
|
}
|
|
},
|
|
/**
|
|
* Called when clicking on attachment button.
|
|
*/
|
|
onClickAddAttachment() {
|
|
this.fileUploader.openBrowserFileUploader();
|
|
if (!this.messaging.device.isMobileDevice) {
|
|
this.update({ doFocus: true });
|
|
}
|
|
},
|
|
/**
|
|
* Handles click on the emojis button.
|
|
*/
|
|
onClickButtonEmojis() {
|
|
if (!this.emojisPopoverView) {
|
|
this.update({ emojisPopoverView: {} });
|
|
} else {
|
|
this.update({ emojisPopoverView: clear() });
|
|
}
|
|
},
|
|
/**
|
|
* Handles click on the cancel link.
|
|
*
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
onClickCancelLink(ev) {
|
|
ev.preventDefault();
|
|
if (this.exists()) {
|
|
this.discard();
|
|
}
|
|
},
|
|
/**
|
|
* Discards the composer when clicking away.
|
|
*
|
|
* @private
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
async onClickCaptureGlobal(ev) {
|
|
if (this.contains(ev.target)) {
|
|
return;
|
|
}
|
|
// Let event be handled by bubbling handlers first
|
|
await new Promise(this.messaging.browser.setTimeout);
|
|
if (isEventHandled(ev, 'MessageActionList.replyTo')) {
|
|
return;
|
|
}
|
|
if (this.exists()) {
|
|
this.discard();
|
|
}
|
|
},
|
|
/**
|
|
* Called when clicking on "discard" button.
|
|
*
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
onClickDiscard(ev) {
|
|
this.discard();
|
|
},
|
|
/**
|
|
* @private
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
onClickEmoji(ev) {
|
|
this.saveStateInStore();
|
|
this.insertIntoTextInput(ev.currentTarget.dataset.codepoints);
|
|
if (!this.messaging.device.isMobileDevice) {
|
|
this.update({ doFocus: true });
|
|
}
|
|
this.update({ emojisPopoverView: clear() });
|
|
},
|
|
/**
|
|
* Called when clicking on "expand" button.
|
|
*/
|
|
onClickFullComposer() {
|
|
this.openFullComposer();
|
|
},
|
|
/**
|
|
* @private
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
onClickReplyingToMessage(ev) {
|
|
this.threadView.replyingToMessageView.update({ doHighlight: true });
|
|
},
|
|
/**
|
|
* Handles click on the save link.
|
|
*
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
onClickSaveLink(ev) {
|
|
ev.preventDefault();
|
|
this.sendMessage();
|
|
},
|
|
/**
|
|
* Called when clicking on "send" button.
|
|
*/
|
|
onClickSend() {
|
|
this.sendMessage();
|
|
this.update({ doFocus: true });
|
|
},
|
|
/**
|
|
* Handles click on the "stop replying" button.
|
|
*
|
|
* @param {MouseEvent} ev
|
|
*/
|
|
onClickStopReplying(ev) {
|
|
this.threadView.update({
|
|
replyingToMessageView: clear(),
|
|
});
|
|
},
|
|
onClickTextarea() {
|
|
if (!this.exists()) {
|
|
return;
|
|
}
|
|
// clicking might change the cursor position
|
|
this.saveStateInStore();
|
|
},
|
|
onFocusinTextarea() {
|
|
if (!this.exists()) {
|
|
return;
|
|
}
|
|
this.update({ isFocused: true });
|
|
},
|
|
onFocusoutTextarea() {
|
|
if (!this.exists()) {
|
|
return;
|
|
}
|
|
this.saveStateInStore();
|
|
this.update({ isFocused: false });
|
|
},
|
|
onInputTextarea() {
|
|
if (!this.exists()) {
|
|
return;
|
|
}
|
|
this.saveStateInStore();
|
|
if (this.textareaLastInputValue !== this.textInput.textareaRef.el.value) {
|
|
this.handleCurrentPartnerIsTyping();
|
|
}
|
|
this.update({ textareaLastInputValue: this.textInput.textareaRef.el.value });
|
|
this.updateTextInputHeight();
|
|
},
|
|
/**
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
onKeydown(ev) {
|
|
if (ev.key === 'Escape') {
|
|
if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) {
|
|
return;
|
|
}
|
|
if (isEventHandled(ev, 'Composer.closeEmojisPopover')) {
|
|
return;
|
|
}
|
|
ev.preventDefault();
|
|
this.discard();
|
|
}
|
|
},
|
|
/**
|
|
* @private
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
onKeydownButtonEmojis(ev) {
|
|
if (ev.key === 'Escape' && this.emojisPopoverView) {
|
|
this.update({
|
|
doFocus: true,
|
|
emojisPopoverView: clear(),
|
|
});
|
|
markEventHandled(ev, 'Composer.closeEmojisPopover');
|
|
}
|
|
},
|
|
/**
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
onKeydownTextarea(ev) {
|
|
if (!this.exists()) {
|
|
return;
|
|
}
|
|
switch (ev.key) {
|
|
case 'Escape':
|
|
if (this.hasSuggestions) {
|
|
ev.preventDefault();
|
|
this.closeSuggestions();
|
|
markEventHandled(ev, 'ComposerTextInput.closeSuggestions');
|
|
}
|
|
break;
|
|
// UP, DOWN, TAB: prevent moving cursor if navigation in mention suggestions
|
|
case 'ArrowUp':
|
|
case 'PageUp':
|
|
case 'ArrowDown':
|
|
case 'PageDown':
|
|
case 'Home':
|
|
case 'End':
|
|
case 'Tab':
|
|
if (this.hasSuggestions) {
|
|
// We use preventDefault here to avoid keys native actions but actions are handled in keyUp
|
|
ev.preventDefault();
|
|
}
|
|
break;
|
|
// ENTER: submit the message only if the dropdown mention proposition is not displayed
|
|
case 'Enter':
|
|
this.onKeydownTextareaEnter(ev);
|
|
break;
|
|
}
|
|
},
|
|
/**
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
onKeydownTextareaEnter(ev) {
|
|
if (!this.exists()) {
|
|
return;
|
|
}
|
|
if (this.hasSuggestions) {
|
|
ev.preventDefault();
|
|
return;
|
|
}
|
|
if (
|
|
this.sendShortcuts.includes('ctrl-enter') &&
|
|
!ev.altKey &&
|
|
ev.ctrlKey &&
|
|
!ev.metaKey &&
|
|
!ev.shiftKey
|
|
) {
|
|
this.sendMessage();
|
|
ev.preventDefault();
|
|
return;
|
|
}
|
|
if (
|
|
this.sendShortcuts.includes('enter') &&
|
|
!ev.altKey &&
|
|
!ev.ctrlKey &&
|
|
!ev.metaKey &&
|
|
!ev.shiftKey
|
|
) {
|
|
this.sendMessage();
|
|
ev.preventDefault();
|
|
return;
|
|
}
|
|
if (
|
|
this.sendShortcuts.includes('meta-enter') &&
|
|
!ev.altKey &&
|
|
!ev.ctrlKey &&
|
|
ev.metaKey &&
|
|
!ev.shiftKey
|
|
) {
|
|
this.sendMessage();
|
|
ev.preventDefault();
|
|
return;
|
|
}
|
|
},
|
|
/**
|
|
* Key events management is performed in a Keyup to avoid intempestive RPC calls
|
|
*
|
|
* @param {KeyboardEvent} ev
|
|
*/
|
|
onKeyupTextarea(ev) {
|
|
if (!this.exists()) {
|
|
return;
|
|
}
|
|
switch (ev.key) {
|
|
case 'Escape':
|
|
// already handled in _onKeydownTextarea, break to avoid default
|
|
break;
|
|
// ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions
|
|
case 'Enter':
|
|
if (this.hasSuggestions) {
|
|
this.insertSuggestion();
|
|
this.closeSuggestions();
|
|
this.update({ doFocus: true });
|
|
}
|
|
break;
|
|
case 'ArrowUp':
|
|
case 'PageUp':
|
|
if (ev.key === 'ArrowUp' && !this.hasSuggestions && !this.composer.textInputContent && this.threadView) {
|
|
this.threadView.startEditingLastMessageFromCurrentUser();
|
|
break;
|
|
}
|
|
if (this.composerSuggestionListView) {
|
|
this.composerSuggestionListView.setPreviousSuggestionViewActive();
|
|
this.composerSuggestionListView.update({ hasToScrollToActiveSuggestionView: true });
|
|
}
|
|
break;
|
|
case 'ArrowDown':
|
|
case 'PageDown':
|
|
if (ev.key === 'ArrowDown' && !this.hasSuggestions && !this.composer.textInputContent && this.threadView) {
|
|
this.threadView.startEditingLastMessageFromCurrentUser();
|
|
break;
|
|
}
|
|
if (this.composerSuggestionListView) {
|
|
this.composerSuggestionListView.setNextSuggestionViewActive();
|
|
this.composerSuggestionListView.update({ hasToScrollToActiveSuggestionView: true });
|
|
}
|
|
break;
|
|
case 'Home':
|
|
if (this.composerSuggestionListView) {
|
|
this.composerSuggestionListView.setFirstSuggestionViewActive();
|
|
this.composerSuggestionListView.update({ hasToScrollToActiveSuggestionView: true });
|
|
}
|
|
break;
|
|
case 'End':
|
|
if (this.composerSuggestionListView) {
|
|
this.composerSuggestionListView.setLastSuggestionViewActive();
|
|
this.composerSuggestionListView.update({ hasToScrollToActiveSuggestionView: true });
|
|
}
|
|
break;
|
|
case 'Tab':
|
|
if (this.composerSuggestionListView) {
|
|
if (ev.shiftKey) {
|
|
this.composerSuggestionListView.setPreviousSuggestionViewActive();
|
|
this.composerSuggestionListView.update({ hasToScrollToActiveSuggestionView: true });
|
|
} else {
|
|
this.composerSuggestionListView.setNextSuggestionViewActive();
|
|
this.composerSuggestionListView.update({ hasToScrollToActiveSuggestionView: true });
|
|
}
|
|
}
|
|
break;
|
|
case 'Alt':
|
|
case 'AltGraph':
|
|
case 'CapsLock':
|
|
case 'Control':
|
|
case 'Fn':
|
|
case 'FnLock':
|
|
case 'Hyper':
|
|
case 'Meta':
|
|
case 'NumLock':
|
|
case 'ScrollLock':
|
|
case 'Shift':
|
|
case 'ShiftSuper':
|
|
case 'Symbol':
|
|
case 'SymbolLock':
|
|
// prevent modifier keys from resetting the suggestion state
|
|
break;
|
|
// Otherwise, check if a mention is typed
|
|
default:
|
|
this.saveStateInStore();
|
|
}
|
|
},
|
|
/**
|
|
* @param {ClipboardEvent} ev
|
|
*/
|
|
async onPasteTextInput(ev) {
|
|
if (!ev.clipboardData || !ev.clipboardData.files) {
|
|
return;
|
|
}
|
|
await this.fileUploader.uploadFiles(ev.clipboardData.files);
|
|
},
|
|
/**
|
|
* Open the full composer modal.
|
|
*/
|
|
async openFullComposer() {
|
|
const attachmentIds = this.composer.attachments.map(attachment => attachment.id);
|
|
const context = {
|
|
default_attachment_ids: attachmentIds,
|
|
default_body: escapeAndCompactTextContent(this.composer.textInputContent),
|
|
default_is_log: this.composer.isLog,
|
|
default_model: this.composer.activeThread.model,
|
|
default_partner_ids: this.composer.recipients.map(partner => partner.id),
|
|
default_res_id: this.composer.activeThread.id,
|
|
mail_post_autofollow: this.composer.activeThread.hasWriteAccess,
|
|
};
|
|
|
|
const action = {
|
|
type: 'ir.actions.act_window',
|
|
res_model: 'mail.compose.message',
|
|
view_mode: 'form',
|
|
views: [[false, 'form']],
|
|
target: 'new',
|
|
context: context,
|
|
};
|
|
const composer = this.composer;
|
|
const options = {
|
|
onClose: () => {
|
|
if (!composer.exists()) {
|
|
return;
|
|
}
|
|
composer._reset();
|
|
if (composer.activeThread) {
|
|
composer.activeThread.fetchData(['messages']);
|
|
}
|
|
},
|
|
};
|
|
await this.env.services.action.doAction(action, options);
|
|
},
|
|
/**
|
|
* Post a message in provided composer's thread based on current composer fields values.
|
|
*/
|
|
async postMessage() {
|
|
const composer = this.composer;
|
|
const postData = this._getMessageData();
|
|
const params = {
|
|
'post_data': postData,
|
|
'thread_id': composer.thread.id,
|
|
'thread_model': composer.thread.model,
|
|
};
|
|
try {
|
|
composer.update({ isPostingMessage: true });
|
|
if (composer.thread.model === 'mail.channel') {
|
|
Object.assign(postData, {
|
|
subtype_xmlid: 'mail.mt_comment',
|
|
});
|
|
} else {
|
|
Object.assign(postData, {
|
|
subtype_xmlid: composer.isLog ? 'mail.mt_note' : 'mail.mt_comment',
|
|
});
|
|
if (!composer.isLog) {
|
|
params.context = { mail_post_autofollow: this.composer.activeThread.hasWriteAccess };
|
|
}
|
|
}
|
|
if (this.threadView && this.threadView.replyingToMessageView && this.threadView.thread !== this.messaging.inbox.thread) {
|
|
postData.parent_id = this.threadView.replyingToMessageView.message.id;
|
|
}
|
|
const { threadView = {} } = this;
|
|
const chatter = this.chatter;
|
|
const { thread: chatterThread } = this.chatter || {};
|
|
const { thread: threadViewThread } = threadView;
|
|
// Keep a reference to messaging: composer could be
|
|
// unmounted while awaiting the prc promise. In this
|
|
// case, this would be undefined.
|
|
const messaging = this.messaging;
|
|
const messageData = await this.messaging.rpc({ route: `/mail/message/post`, params });
|
|
if (!messaging.exists()) {
|
|
return;
|
|
}
|
|
const message = messaging.models['Message'].insert(
|
|
messaging.models['Message'].convertData(messageData)
|
|
);
|
|
if (this.messaging.hasLinkPreviewFeature && !message.isBodyEmpty) {
|
|
this.messaging.rpc({
|
|
route: `/mail/link_preview`,
|
|
params: {
|
|
message_id: message.id
|
|
}
|
|
}, { shadow: true });
|
|
}
|
|
for (const threadView of message.originThread.threadViews) {
|
|
// Reset auto scroll to be able to see the newly posted message.
|
|
threadView.update({ hasAutoScrollOnMessageReceived: true });
|
|
threadView.addComponentHint('message-posted', { message });
|
|
}
|
|
if (chatter && chatter.exists() && chatter.hasParentReloadOnMessagePosted) {
|
|
chatter.reloadParentView();
|
|
}
|
|
if (chatterThread) {
|
|
if (this.exists()) {
|
|
this.delete();
|
|
}
|
|
if (chatterThread.exists()) {
|
|
// Load new messages to fetch potential new messages from other users (useful due to lack of auto-sync in chatter).
|
|
chatterThread.fetchData(['followers', 'messages', 'suggestedRecipients']);
|
|
}
|
|
}
|
|
if (threadViewThread) {
|
|
if (threadViewThread === messaging.inbox.thread) {
|
|
messaging.notify({
|
|
message: sprintf(messaging.env._t(`Message posted on "%s"`), message.originThread.displayName),
|
|
type: 'info',
|
|
});
|
|
if (this.exists()) {
|
|
this.delete();
|
|
}
|
|
}
|
|
if (threadView && threadView.exists()) {
|
|
threadView.update({ replyingToMessageView: clear() });
|
|
}
|
|
}
|
|
if (composer.exists()) {
|
|
composer._reset();
|
|
}
|
|
} finally {
|
|
if (composer.exists()) {
|
|
composer.update({ isPostingMessage: false });
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Saves the composer text input state in store
|
|
*/
|
|
saveStateInStore() {
|
|
this.composer.update({
|
|
textInputContent: this.textInput.textareaRef.el.value,
|
|
textInputCursorEnd: this.textInput.textareaRef.el.selectionEnd,
|
|
textInputCursorStart: this.textInput.textareaRef.el.selectionStart,
|
|
textInputSelectionDirection: this.textInput.textareaRef.el.selectionDirection,
|
|
});
|
|
},
|
|
/**
|
|
* Send a message in the composer on related thread.
|
|
*
|
|
* Sending of the message could be aborted if it cannot be posted like if there are attachments
|
|
* currently uploading or if there is no text content and no attachments.
|
|
*/
|
|
async sendMessage() {
|
|
if (!this.composer.canPostMessage) {
|
|
if (this.composer.hasUploadingAttachment) {
|
|
this.messaging.notify({
|
|
message: this.env._t("Please wait while the file is uploading."),
|
|
type: 'warning',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (this.messageViewInEditing) {
|
|
this.updateMessage();
|
|
return;
|
|
}
|
|
if (this.composer.thread.channel) {
|
|
const command = this._getCommandFromText(this.composer.textInputContent);
|
|
if (command) {
|
|
await command.execute({ channel: this.composer.thread, body: this.composer.textInputContent });
|
|
if (this.composer.exists()) {
|
|
this.composer._reset();
|
|
}
|
|
return;
|
|
}
|
|
this.composer.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true });
|
|
}
|
|
this.postMessage();
|
|
},
|
|
/**
|
|
* Updates the textarea height of text input.
|
|
*/
|
|
updateTextInputHeight() {
|
|
this.textInput.mirroredTextareaRef.el.value = this.composer.textInputContent;
|
|
this.textInput.textareaRef.el.style.height = this.textInput.mirroredTextareaRef.el.scrollHeight + 'px';
|
|
},
|
|
/**
|
|
* Update a posted message when the message is ready.
|
|
*/
|
|
async updateMessage() {
|
|
const composer = this.composer;
|
|
if (!composer.textInputContent) {
|
|
this.messageViewInEditing.messageActionList.update({ deleteConfirmDialog: {} });
|
|
return;
|
|
}
|
|
let data = {
|
|
body: this._generateMessageBody(),
|
|
attachment_ids: composer.attachments.concat(this.messageViewInEditing.message.attachments).map(attachment => attachment.id),
|
|
};
|
|
const messageViewInEditing = this.messageViewInEditing;
|
|
await messageViewInEditing.message.updateContent(data);
|
|
if (messageViewInEditing.exists()) {
|
|
messageViewInEditing.stopEditing();
|
|
}
|
|
},
|
|
/**
|
|
* Executes the given async function, only when the last function
|
|
* executed by this method terminates. If there is already a pending
|
|
* function it is replaced by the new one. This ensures the result of
|
|
* these function come in the same order as the call order, and it also
|
|
* allows to skip obsolete intermediate calls.
|
|
*
|
|
* @private
|
|
* @param {function} func
|
|
*/
|
|
async _executeOrQueueFunction(func) {
|
|
if (this.hasMentionRpcInProgress) {
|
|
this.update({ nextMentionRpcFunction: func });
|
|
return;
|
|
}
|
|
this.update({
|
|
hasMentionRpcInProgress: true,
|
|
nextMentionRpcFunction: clear(),
|
|
});
|
|
await func();
|
|
if (this.exists()) {
|
|
this.update({ hasMentionRpcInProgress: false });
|
|
if (this.nextMentionRpcFunction) {
|
|
this._executeOrQueueFunction(this.nextMentionRpcFunction);
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* @private
|
|
* @param {string} htmlString
|
|
* @returns {string}
|
|
*/
|
|
_generateEmojisOnHtml(htmlString) {
|
|
for (const emoji of this.messaging.emojiRegistry.allEmojis) {
|
|
for (const source of emoji.sources) {
|
|
const escapedSource = String(source).replace(
|
|
/([.*+?=^!:${}()|[\]/\\])/g,
|
|
'\\$1');
|
|
const regexp = new RegExp(
|
|
'(\\s|^)(' + escapedSource + ')(?=\\s|$)',
|
|
'g');
|
|
htmlString = htmlString.replace(regexp, '$1' + emoji.codepoints);
|
|
}
|
|
}
|
|
return htmlString;
|
|
},
|
|
/**
|
|
*
|
|
* Generates the html link related to the mentioned partner
|
|
*
|
|
* @private
|
|
* @param {string} body
|
|
* @returns {string}
|
|
*/
|
|
_generateMentionsLinks(body) {
|
|
// List of mention data to insert in the body.
|
|
// Useful to do the final replace after parsing to avoid using the
|
|
// same tag twice if two different mentions have the same name.
|
|
const mentions = [];
|
|
for (const partner of this.composer.mentionedPartners) {
|
|
const placeholder = `@-mention-partner-${partner.id}`;
|
|
const text = `@${escape(partner.name)}`;
|
|
mentions.push({
|
|
class: 'o_mail_redirect',
|
|
id: partner.id,
|
|
model: 'res.partner',
|
|
placeholder,
|
|
text,
|
|
});
|
|
body = body.replace(text, placeholder);
|
|
}
|
|
for (const channel of this.composer.mentionedChannels) {
|
|
const placeholder = `#-mention-channel-${channel.id}`;
|
|
const text = `#${escape(channel.name)}`;
|
|
mentions.push({
|
|
class: 'o_channel_redirect',
|
|
id: channel.id,
|
|
model: 'mail.channel',
|
|
placeholder,
|
|
text,
|
|
});
|
|
body = body.replace(text, placeholder);
|
|
}
|
|
const baseHREF = url('/web');
|
|
for (const mention of mentions) {
|
|
const href = `href='${baseHREF}#model=${mention.model}&id=${mention.id}'`;
|
|
const attClass = `class='${mention.class}'`;
|
|
const dataOeId = `data-oe-id='${mention.id}'`;
|
|
const dataOeModel = `data-oe-model='${mention.model}'`;
|
|
const target = `target='_blank'`;
|
|
const link = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target}>${mention.text}</a>`;
|
|
body = body.replace(mention.placeholder, link);
|
|
}
|
|
return body;
|
|
},
|
|
_generateMessageBody() {
|
|
const escapedAndCompactContent = escapeAndCompactTextContent(this.composer.textInputContent);
|
|
let body = escapedAndCompactContent.replace(/ /g, ' ').trim();
|
|
// This message will be received from the mail composer as html content
|
|
// subtype but the urls will not be linkified. If the mail composer
|
|
// takes the responsibility to linkify the urls we end up with double
|
|
// linkification a bit everywhere. Ideally we want to keep the content
|
|
// as text internally and only make html enrichment at display time but
|
|
// the current design makes this quite hard to do.
|
|
body = this._generateMentionsLinks(body);
|
|
body = parseAndTransform(body, addLink);
|
|
body = this._generateEmojisOnHtml(body);
|
|
return body;
|
|
},
|
|
/**
|
|
* @private
|
|
* @param {string} content html content
|
|
* @returns {ChannelCommand|undefined} command, if any in the content
|
|
*/
|
|
_getCommandFromText(content) {
|
|
if (content.startsWith('/')) {
|
|
const firstWord = content.substring(1).split(/\s/)[0];
|
|
return this.messaging.commands.find(command => {
|
|
if (command.name !== firstWord) {
|
|
return false;
|
|
}
|
|
if (command.channel_types) {
|
|
return Boolean(this.composer.thread.channel) && command.channel_types.includes(this.composer.thread.channel.channel_type);
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
return undefined;
|
|
},
|
|
/**
|
|
* Gather data for message post.
|
|
*
|
|
* @private
|
|
* @returns {Object}
|
|
*/
|
|
_getMessageData() {
|
|
return {
|
|
attachment_ids: this.composer.attachments.map(attachment => attachment.id),
|
|
body: this._generateMessageBody(),
|
|
message_type: 'comment',
|
|
partner_ids: this.composer.recipients.map(partner => partner.id),
|
|
};
|
|
},
|
|
/**
|
|
* Handles change of this composer. Useful to reset the state of the
|
|
* composer text input.
|
|
*/
|
|
_onChangeComposer() {
|
|
this.update({ hasToRestoreContent: true });
|
|
},
|
|
/**
|
|
* @private
|
|
*/
|
|
_onChangeDetectSuggestionDelimiterPosition() {
|
|
if (!this.composer) {
|
|
return;
|
|
}
|
|
if (this.composer.textInputCursorStart !== this.composer.textInputCursorEnd) {
|
|
// avoid interfering with multi-char selection
|
|
return this.update({ suggestionDelimiterPosition: clear() });
|
|
}
|
|
const candidatePositions = [];
|
|
// keep the current delimiter if it is still valid
|
|
if (
|
|
this.suggestionDelimiterPosition !== undefined &&
|
|
this.suggestionDelimiterPosition < this.composer.textInputCursorStart
|
|
) {
|
|
candidatePositions.push(this.suggestionDelimiterPosition);
|
|
}
|
|
// consider the char before the current cursor position if the
|
|
// current delimiter is no longer valid (or if there is none)
|
|
if (this.composer.textInputCursorStart > 0) {
|
|
candidatePositions.push(this.composer.textInputCursorStart - 1);
|
|
}
|
|
const suggestionDelimiters = ['@', ':', '#', '/'];
|
|
for (const candidatePosition of candidatePositions) {
|
|
if (
|
|
candidatePosition < 0 ||
|
|
candidatePosition >= this.composer.textInputContent.length
|
|
) {
|
|
continue;
|
|
}
|
|
const candidateChar = this.composer.textInputContent[candidatePosition];
|
|
if (candidateChar === '/' && candidatePosition !== 0) {
|
|
continue;
|
|
}
|
|
if (!suggestionDelimiters.includes(candidateChar)) {
|
|
continue;
|
|
}
|
|
const charBeforeCandidate = this.composer.textInputContent[candidatePosition - 1];
|
|
if (charBeforeCandidate && !/\s/.test(charBeforeCandidate)) {
|
|
continue;
|
|
}
|
|
this.update({ suggestionDelimiterPosition: candidatePosition });
|
|
return;
|
|
}
|
|
return this.update({ suggestionDelimiterPosition: clear() });
|
|
},
|
|
/**
|
|
* Updates the suggestion state based on the currently saved composer
|
|
* state (in particular content and cursor position).
|
|
*
|
|
* @private
|
|
*/
|
|
_onChangeUpdateSuggestionList() {
|
|
if (this.messaging.isCurrentUserGuest) {
|
|
return;
|
|
}
|
|
// Update the suggestion list immediately for a reactive UX...
|
|
this._updateSuggestionList();
|
|
// ...and then update it again after the server returned data.
|
|
this._executeOrQueueFunction(async () => {
|
|
if (
|
|
!this.exists() ||
|
|
this.suggestionDelimiterPosition === undefined ||
|
|
this.suggestionSearchTerm === undefined ||
|
|
!this.suggestionModelName
|
|
) {
|
|
return; // ignore obsolete call
|
|
}
|
|
const model = this.messaging.models[this.suggestionModelName];
|
|
const searchTerm = this.suggestionSearchTerm;
|
|
await model.fetchSuggestions(searchTerm, { thread: this.composer.activeThread });
|
|
if (!this.exists()) {
|
|
return;
|
|
}
|
|
this._updateSuggestionList();
|
|
if (
|
|
this.suggestionSearchTerm &&
|
|
this.suggestionSearchTerm === searchTerm &&
|
|
this.suggestionModelName &&
|
|
this.messaging.models[this.suggestionModelName] === model &&
|
|
!this.hasSuggestions
|
|
) {
|
|
this.closeSuggestions();
|
|
}
|
|
});
|
|
},
|
|
/**
|
|
* Updates the current suggestion list. This method should be called
|
|
* whenever the UI has to be refreshed following change in state.
|
|
*
|
|
* This method should ideally be a compute, but its dependencies are
|
|
* currently too complex to express due to accessing plenty of fields
|
|
* from all records of dynamic models.
|
|
*
|
|
* @private
|
|
*/
|
|
_updateSuggestionList() {
|
|
if (
|
|
this.suggestionDelimiterPosition === undefined ||
|
|
this.suggestionSearchTerm === undefined ||
|
|
!this.suggestionModelName
|
|
) {
|
|
return;
|
|
}
|
|
const model = this.messaging.models[this.suggestionModelName];
|
|
const [
|
|
mainSuggestedRecords,
|
|
extraSuggestedRecords = [],
|
|
] = model.searchSuggestions(this.suggestionSearchTerm, { thread: this.composer.activeThread });
|
|
const sortFunction = model.getSuggestionSortFunction(this.suggestionSearchTerm, { thread: this.composer.activeThread });
|
|
mainSuggestedRecords.sort(sortFunction);
|
|
extraSuggestedRecords.sort(sortFunction);
|
|
// arbitrary limit to avoid displaying too many elements at once
|
|
// ideally a load more mechanism should be introduced
|
|
const limit = 8;
|
|
mainSuggestedRecords.length = Math.min(mainSuggestedRecords.length, limit);
|
|
extraSuggestedRecords.length = Math.min(extraSuggestedRecords.length, limit - mainSuggestedRecords.length);
|
|
this.update({
|
|
extraSuggestions: extraSuggestedRecords.map(record => record.suggestable),
|
|
mainSuggestions: mainSuggestedRecords.map(record => record.suggestable),
|
|
});
|
|
if (this.composerSuggestionListView) {
|
|
this.composerSuggestionListView.update({ hasToScrollToActiveSuggestionView: true });
|
|
}
|
|
},
|
|
/**
|
|
* @private
|
|
*/
|
|
_onSuggestionDelimiterPositionChanged() {
|
|
if (this.suggestionDelimiterPosition === undefined) {
|
|
this.update({
|
|
extraSuggestions: clear(),
|
|
mainSuggestions: clear(),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
fields: {
|
|
/**
|
|
* Determines the attachment list that will be used to display the attachments.
|
|
*/
|
|
attachmentList: one('AttachmentList', {
|
|
compute() {
|
|
return (this.composer && this.composer.attachments.length > 0)
|
|
? {}
|
|
: clear();
|
|
},
|
|
inverse: 'composerViewOwner',
|
|
}),
|
|
/**
|
|
* States the ref to the html node of the emojis button.
|
|
*/
|
|
buttonEmojisRef: attr(),
|
|
/**
|
|
* States the chatter which this composer allows editing (if any).
|
|
*/
|
|
chatter: one('Chatter', {
|
|
identifying: true,
|
|
inverse: 'composerView',
|
|
}),
|
|
/**
|
|
* States the OWL component of this composer view.
|
|
*/
|
|
component: attr(),
|
|
/**
|
|
* States the composer state that is displayed by this composer view.
|
|
*/
|
|
composer: one('Composer', {
|
|
compute() {
|
|
if (this.threadView) {
|
|
// When replying to a message, always use the composer from that message's thread
|
|
if (this.threadView && this.threadView.replyingToMessageView) {
|
|
return this.threadView.replyingToMessageView.message.originThread.composer;
|
|
}
|
|
if (this.threadView.thread && this.threadView.thread.composer) {
|
|
return this.threadView.thread.composer;
|
|
}
|
|
}
|
|
if (this.messageViewInEditing && this.messageViewInEditing.composerForEditing) {
|
|
return this.messageViewInEditing.composerForEditing;
|
|
}
|
|
if (this.chatter && this.chatter.thread && this.chatter.thread.composer) {
|
|
return this.chatter.thread.composer;
|
|
}
|
|
return clear();
|
|
},
|
|
inverse: 'composerViews',
|
|
required: true,
|
|
}),
|
|
composerSuggestedRecipientListView: one('ComposerSuggestedRecipientListView', {
|
|
compute() {
|
|
if (this.hasHeader && this.hasFollowers && !this.composer.isLog) {
|
|
return {};
|
|
}
|
|
return clear();
|
|
},
|
|
inverse: 'composerViewOwner',
|
|
}),
|
|
composerSuggestionListView: one('ComposerSuggestionListView', {
|
|
compute() {
|
|
if (this.hasSuggestions) {
|
|
return {};
|
|
}
|
|
return clear();
|
|
},
|
|
inverse: 'composerViewOwner',
|
|
}),
|
|
/**
|
|
* Current partner image URL.
|
|
*/
|
|
currentPartnerAvatar: attr({
|
|
compute() {
|
|
if (this.messaging.currentUser) {
|
|
return url('/web/image', {
|
|
field: 'avatar_128',
|
|
id: this.messaging.currentUser.id,
|
|
model: 'res.users',
|
|
});
|
|
}
|
|
return '/web/static/img/user_menu_avatar.png';
|
|
},
|
|
}),
|
|
/**
|
|
* Determines whether this composer should be focused at next render.
|
|
*/
|
|
doFocus: attr(),
|
|
dropZoneView: one('DropZoneView', {
|
|
compute() {
|
|
if (this.useDragVisibleDropZone.isVisible) {
|
|
return {};
|
|
}
|
|
return clear();
|
|
},
|
|
inverse: 'composerViewOwner',
|
|
}),
|
|
/**
|
|
* Determines the emojis popover that is active on this composer view.
|
|
*/
|
|
emojisPopoverView: one('PopoverView', {
|
|
inverse: 'composerViewOwnerAsEmoji',
|
|
}),
|
|
extraSuggestions: many('ComposerSuggestable'),
|
|
fileUploader: one('FileUploader', {
|
|
default: {},
|
|
inverse: 'composerView',
|
|
readonly: true,
|
|
required: true,
|
|
}),
|
|
hasCurrentPartnerAvatar: attr({
|
|
compute() {
|
|
if (this.messageViewInEditing) {
|
|
return false;
|
|
}
|
|
if (!this.threadView) {
|
|
return clear();
|
|
}
|
|
if (this.threadView.threadViewer.chatWindow) {
|
|
return false;
|
|
}
|
|
if (this.threadView.threadViewer.discuss) {
|
|
return !this.messaging.device.isSmall;
|
|
}
|
|
return clear();
|
|
},
|
|
default: true,
|
|
}),
|
|
hasDiscardButton: attr({
|
|
compute() {
|
|
if (this.messageViewInEditing) {
|
|
return false;
|
|
}
|
|
if (this.messaging.device.isSmall) {
|
|
return false;
|
|
}
|
|
if (!this.threadView) {
|
|
return clear();
|
|
}
|
|
if (this.threadView.threadViewer.discuss) {
|
|
return this.threadView.threadViewer.discuss.activeThread === this.messaging.inbox.thread;
|
|
}
|
|
return clear();
|
|
},
|
|
default: false,
|
|
}),
|
|
hasFollowers: attr({
|
|
compute() {
|
|
if (this.chatter) {
|
|
return true;
|
|
}
|
|
return clear();
|
|
},
|
|
default: false,
|
|
}),
|
|
/**
|
|
* Determines whether composer should display a footer.
|
|
*/
|
|
hasFooter: attr({
|
|
compute() {
|
|
return Boolean(
|
|
this.hasThreadTyping ||
|
|
this.composer.attachments.length > 0 ||
|
|
this.messageViewInEditing ||
|
|
!this.isCompact
|
|
);
|
|
},
|
|
}),
|
|
/**
|
|
* Determine whether the composer should display a header.
|
|
*/
|
|
hasHeader: attr({
|
|
compute() {
|
|
return Boolean(
|
|
(this.hasThreadName && this.composer.thread) ||
|
|
(this.hasFollowers && !this.composer.isLog) ||
|
|
(this.threadView && this.threadView.replyingToMessageView)
|
|
);
|
|
},
|
|
}),
|
|
/**
|
|
* Determines whether there is a mention RPC currently in progress.
|
|
* Useful to queue a new call if there is already one pending.
|
|
*/
|
|
hasMentionRpcInProgress: attr({
|
|
default: false,
|
|
}),
|
|
hasMentionSuggestionsBelowPosition: attr({
|
|
compute() {
|
|
if (this.chatter) {
|
|
return true;
|
|
}
|
|
if (this.messageViewInEditing) {
|
|
return false;
|
|
}
|
|
return clear();
|
|
},
|
|
default: false,
|
|
}),
|
|
hasSendButton: attr({
|
|
compute() {
|
|
if (this.messageViewInEditing) {
|
|
return false;
|
|
}
|
|
if (this.threadView && this.threadView.threadViewer.chatWindow) {
|
|
return this.messaging.device.isSmall;
|
|
}
|
|
return clear();
|
|
},
|
|
default: true,
|
|
}),
|
|
/**
|
|
* States whether there is any result currently found for the current
|
|
* suggestion delimiter and search term, if applicable.
|
|
*/
|
|
hasSuggestions: attr({
|
|
compute() {
|
|
return this.mainSuggestions.length > 0 || this.extraSuggestions.length > 0;
|
|
},
|
|
default: false,
|
|
}),
|
|
hasThreadName: attr({
|
|
compute() {
|
|
if (this.threadView) {
|
|
return this.threadView.hasComposerThreadName;
|
|
}
|
|
return clear();
|
|
},
|
|
default: false,
|
|
}),
|
|
hasThreadTyping: attr({
|
|
compute() {
|
|
if (this.threadView) {
|
|
return this.threadView.hasComposerThreadTyping;
|
|
}
|
|
return clear();
|
|
},
|
|
default: false,
|
|
}),
|
|
/**
|
|
* Determines whether the content of this composer should be restored in
|
|
* this view. Useful to avoid restoring the state when its change was
|
|
* from a user action, in particular to prevent the cursor from jumping
|
|
* to its previous position after the user clicked on the textarea while
|
|
* it didn't have the focus anymore.
|
|
*/
|
|
hasToRestoreContent: attr({
|
|
default: false,
|
|
}),
|
|
isCompact: attr({
|
|
compute() {
|
|
if (this.chatter) {
|
|
return false;
|
|
}
|
|
if (this.messageViewInEditing) {
|
|
return true;
|
|
}
|
|
return clear();
|
|
},
|
|
default: true,
|
|
}),
|
|
isExpandable: attr({
|
|
compute() {
|
|
if (this.chatter) {
|
|
return true;
|
|
}
|
|
return clear();
|
|
},
|
|
default: false,
|
|
}),
|
|
isFocused: attr({
|
|
default: false,
|
|
}),
|
|
/**
|
|
* Determines if we are in the Discuss view.
|
|
*/
|
|
isInDiscuss: attr({
|
|
compute() {
|
|
return Boolean(
|
|
(this.threadView && (this.threadView.threadViewer.discuss || this.threadView.threadViewer.discussPublicView)) ||
|
|
(this.messageViewInEditing && this.messageViewInEditing.isInDiscuss)
|
|
);
|
|
},
|
|
}),
|
|
/**
|
|
* Determines if we are in the ChatWindow view.
|
|
*/
|
|
isInChatWindow: attr({
|
|
compute() {
|
|
return Boolean(
|
|
(this.threadView && this.threadView.threadViewer.chatWindow) ||
|
|
(this.messageViewInEditing && this.messageViewInEditing.isInChatWindow)
|
|
);
|
|
},
|
|
}),
|
|
/**
|
|
* Determines if we are in the Chatter view.
|
|
*/
|
|
isInChatter: attr({
|
|
compute() {
|
|
return Boolean(
|
|
(this.threadView && this.threadView.threadViewer.chatter) ||
|
|
(this.messageViewInEditing && this.messageViewInEditing.isInChatter)
|
|
);
|
|
},
|
|
}),
|
|
/**
|
|
* Last content of textarea from input event. Useful to determine
|
|
* whether the current partner is typing something.
|
|
*/
|
|
textareaLastInputValue: attr({
|
|
default: "",
|
|
}),
|
|
mainSuggestions: many('ComposerSuggestable'),
|
|
/**
|
|
* States the message view on which this composer allows editing (if any).
|
|
*/
|
|
messageViewInEditing: one('MessageView', {
|
|
identifying: true,
|
|
inverse: 'composerViewInEditing',
|
|
}),
|
|
/**
|
|
* Determines the next function to execute after the current mention
|
|
* RPC is done, if any.
|
|
*/
|
|
nextMentionRpcFunction: attr(),
|
|
/**
|
|
* Determines the label on the send button of this composer view.
|
|
*/
|
|
sendButtonText: attr({
|
|
compute() {
|
|
if (
|
|
this.composer &&
|
|
this.composer.isLog &&
|
|
this.composer.activeThread &&
|
|
this.composer.activeThread.model !== 'mail.channel'
|
|
) {
|
|
return this.env._t("Log");
|
|
}
|
|
return this.env._t("Send");
|
|
},
|
|
}),
|
|
/**
|
|
* Keyboard shortcuts from text input to send message.
|
|
* The format is an array of string that can contain 'enter',
|
|
* 'ctrl-enter', and/or 'meta-enter'.
|
|
*/
|
|
sendShortcuts: attr({
|
|
compute() {
|
|
if (this.chatter) {
|
|
return ['ctrl-enter', 'meta-enter'];
|
|
}
|
|
if (this.messageViewInEditing) {
|
|
return ['enter'];
|
|
}
|
|
if (this.threadView) {
|
|
if (!this.messaging.device) {
|
|
return clear();
|
|
}
|
|
// Actually in mobile there is a send button, so we need there 'enter' to allow new
|
|
// line. Hence, we want to use a different shortcut 'ctrl/meta enter' to send for
|
|
// small screen size with a non-mailing channel. Here send will be done on clicking
|
|
// the button or using the 'ctrl/meta enter' shortcut.
|
|
if (
|
|
this.messaging.device.isSmall ||
|
|
(
|
|
this.messaging.discuss.threadView === this.threadView &&
|
|
this.messaging.discuss.activeThread === this.messaging.inbox.thread
|
|
)
|
|
) {
|
|
return ['ctrl-enter', 'meta-enter'];
|
|
}
|
|
return ['enter'];
|
|
}
|
|
return clear();
|
|
},
|
|
default: [],
|
|
}),
|
|
/**
|
|
* States which type of suggestion is currently in progress, if any.
|
|
* The value of this field contains the magic char that corresponds to
|
|
* the suggestion currently in progress, and it must be one of these:
|
|
* canned responses (:), channels (#), commands (/) and partners (@)
|
|
*/
|
|
suggestionDelimiter: attr({
|
|
compute() {
|
|
if (
|
|
!this.composer ||
|
|
this.suggestionDelimiterPosition === undefined ||
|
|
this.suggestionDelimiterPosition >= this.composer.textInputContent.length
|
|
) {
|
|
return clear();
|
|
}
|
|
return this.composer.textInputContent[this.suggestionDelimiterPosition];
|
|
},
|
|
}),
|
|
/**
|
|
* States the position inside textInputContent of the suggestion
|
|
* delimiter currently in consideration. Useful if the delimiter char
|
|
* appears multiple times in the content.
|
|
* Note: the position is 0 based so it's important to compare to
|
|
* `undefined` when checking for the absence of a value.
|
|
*/
|
|
suggestionDelimiterPosition: attr(),
|
|
/**
|
|
* States the target model name of the suggestion currently in progress,
|
|
* if any.
|
|
*/
|
|
suggestionModelName: attr({
|
|
compute() {
|
|
switch (this.suggestionDelimiter) {
|
|
case '@':
|
|
return 'Partner';
|
|
case ':':
|
|
return 'CannedResponse';
|
|
case '/':
|
|
return 'ChannelCommand';
|
|
case '#':
|
|
return 'Thread';
|
|
default:
|
|
return clear();
|
|
}
|
|
},
|
|
}),
|
|
/**
|
|
* States the search term to use for suggestions (if any).
|
|
*/
|
|
suggestionSearchTerm: attr({
|
|
compute() {
|
|
if (
|
|
!this.composer ||
|
|
this.suggestionDelimiterPosition === undefined ||
|
|
this.suggestionDelimiterPosition >= this.composer.textInputCursorStart
|
|
) {
|
|
return clear();
|
|
}
|
|
return this.composer.textInputContent.substring(this.suggestionDelimiterPosition + 1, this.composer.textInputCursorStart);
|
|
},
|
|
}),
|
|
textInput: one('ComposerTextInputView', {
|
|
default: {},
|
|
inverse: 'owner',
|
|
}),
|
|
/**
|
|
* States the thread view on which this composer allows editing (if any).
|
|
*/
|
|
threadView: one('ThreadView', {
|
|
identifying: true,
|
|
inverse: 'composerView',
|
|
}),
|
|
useDragVisibleDropZone: one('UseDragVisibleDropZone', {
|
|
default: {},
|
|
inverse: 'composerViewOwner',
|
|
readonly: true,
|
|
required: true,
|
|
}),
|
|
},
|
|
onChanges: [
|
|
{
|
|
dependencies: ['composer'],
|
|
methodName: '_onChangeComposer',
|
|
},
|
|
{
|
|
dependencies: ['composer.textInputContent', 'composer.textInputCursorEnd', 'composer.textInputCursorStart'],
|
|
methodName: '_onChangeDetectSuggestionDelimiterPosition',
|
|
},
|
|
{
|
|
dependencies: ['suggestionDelimiterPosition', 'suggestionModelName', 'suggestionSearchTerm', 'composer.activeThread'],
|
|
methodName: '_onChangeUpdateSuggestionList',
|
|
},
|
|
{
|
|
dependencies: ['suggestionDelimiterPosition'],
|
|
methodName: '_onSuggestionDelimiterPositionChanged',
|
|
},
|
|
],
|
|
});
|