diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js
index e9f4620b2c9..ec77a3a6dd1 100644
--- a/addons/mail/static/src/components/composer_text_input/composer_text_input.js
+++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js
@@ -144,6 +144,10 @@ export class ComposerTextInput extends Component {
if (!this.composer) {
return;
}
+ if (this.composer.doFocus) {
+ this.focus();
+ this.composer.update({ doFocus: false });
+ }
if (this.composer.isLastStateChangeProgrammatic) {
this._textareaRef.el.value = this.composer.textInputContent;
if (this.composer.hasFocus) {
@@ -306,6 +310,10 @@ export class ComposerTextInput extends Component {
break;
case 'ArrowUp':
case 'PageUp':
+ if (ev.key === 'ArrowUp' && !this.composer.hasSuggestions && !this.composer.textInputContent && this.composer.thread) {
+ this.composer.thread.startEditingLastMessageFromCurrentUser();
+ break;
+ }
if (this.composer.hasSuggestions) {
this.composer.setPreviousSuggestionActive();
this.composer.update({ hasToScrollToActiveSuggestion: true });
@@ -313,6 +321,10 @@ export class ComposerTextInput extends Component {
break;
case 'ArrowDown':
case 'PageDown':
+ if (ev.key === 'ArrowDown' && !this.composer.hasSuggestions && !this.composer.textInputContent && this.composer.thread) {
+ this.composer.thread.startEditingLastMessageFromCurrentUser();
+ break;
+ }
if (this.composer.hasSuggestions) {
this.composer.setNextSuggestionActive();
this.composer.update({ hasToScrollToActiveSuggestion: true });
diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js
index 655e067866c..aa6c83577f7 100644
--- a/addons/mail/static/src/components/message/message.js
+++ b/addons/mail/static/src/components/message/message.js
@@ -422,6 +422,9 @@ export class Message extends Component {
this._prettyBodyRef.el.innerHTML = this.message.prettyBody;
this._lastPrettyBody = this.message.prettyBody;
}
+ if (!this._prettyBodyRef.el) {
+ this._lastPrettyBody = undefined;
+ }
// Remove all readmore before if any before reinsert them with _insertReadMoreLess.
// This is needed because _insertReadMoreLess is working with direct DOM mutations
// which are not sync with Owl.
diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml
index 2a4be3bb5bf..a5d69f53a72 100644
--- a/addons/mail/static/src/components/message/message.xml
+++ b/addons/mail/static/src/components/message/message.xml
@@ -7,6 +7,7 @@
'o_Message_active': isActive,
'o-clicked': state.isClicked,
'o-discussion': message and (message.is_discussion or message.is_notification),
+ 'o-editing': message.isEditing,
'o-has-message-selection': threadView and threadView.threadViewer and threadView.threadViewer.selectedMessage,
'o-highlighted': message and message.isHighlighted,
'o-mobile': messaging and messaging.device.isMobile,
@@ -117,7 +118,22 @@
Subject:
-
+
+
+
+
+
+
diff --git a/addons/mail/static/src/components/message_action_list/message_action_list.xml b/addons/mail/static/src/components/message_action_list/message_action_list.xml
index 6180a98bb1e..8b6eb648c05 100644
--- a/addons/mail/static/src/components/message_action_list/message_action_list.xml
+++ b/addons/mail/static/src/components/message_action_list/message_action_list.xml
@@ -14,6 +14,7 @@
}" title="Mark as Todo" t-on-click="actionList.onClickToggleStar"/>
+
diff --git a/addons/mail/static/src/models/attachment/attachment.js b/addons/mail/static/src/models/attachment/attachment.js
index f1261870197..e952b43af07 100644
--- a/addons/mail/static/src/models/attachment/attachment.js
+++ b/addons/mail/static/src/models/attachment/attachment.js
@@ -63,7 +63,6 @@ function factory(dependencies) {
if ('name' in data) {
data2.name = data.name;
}
-
// relation
if ('res_id' in data && 'res_model' in data) {
data2.originThread = insert({
@@ -71,7 +70,9 @@ function factory(dependencies) {
model: data.res_model,
});
}
-
+ if ('originThread' in data) {
+ data2.originThread = data.originThread;
+ }
return data2;
}
@@ -378,7 +379,7 @@ function factory(dependencies) {
attachmentViewer: many2many('mail.attachment_viewer', {
inverse: 'attachments',
}),
- checkSum: attr(),
+ checksum: attr(),
composers: many2many('mail.composer', {
inverse: 'attachments',
}),
@@ -408,6 +409,7 @@ function factory(dependencies) {
isImage: attr({
compute: '_computeIsImage',
}),
+ is_main: attr(),
isLinkedToComposer: attr({
compute: '_computeIsLinkedToComposer',
}),
diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js
index 2b90537d4f1..8df3949513f 100644
--- a/addons/mail/static/src/models/composer/composer.js
+++ b/addons/mail/static/src/models/composer/composer.js
@@ -33,6 +33,14 @@ function factory(dependencies) {
return res;
}
+ /**
+ * @override
+ */
+ _created() {
+ this.onClickCancelLink = this.onClickCancelLink.bind(this);
+ this.onClickSaveLink = this.onClickSaveLink.bind(this);
+ }
+
/**
* @override
*/
@@ -59,6 +67,12 @@ function factory(dependencies) {
* currently used as a Discuss Inbox reply composer.
*/
discard() {
+ if (this.messageInEditing) {
+ this.messageInEditing.update({ isEditing: false });
+ if (this.messageInEditing.originThread && this.messageInEditing.originThread.composer) {
+ this.messageInEditing.originThread.composer.update({ doFocus: true });
+ }
+ }
if (this.discussAsReplying) {
this.discussAsReplying.clearReplyingToMessage();
}
@@ -153,8 +167,8 @@ function factory(dependencies) {
*/
_computeRecipients() {
const recipients = [...this.mentionedPartners];
- if (this.thread && !this.isLog) {
- for (const recipient of this.thread.suggestedRecipientInfoList) {
+ if (this.activeThread && !this.isLog) {
+ for (const recipient of this.activeThread.suggestedRecipientInfoList) {
if (recipient.partner && recipient.isSelected) {
recipients.push(recipient.partner);
}
@@ -163,6 +177,39 @@ function factory(dependencies) {
return replace(recipients);
}
+ /**
+ * Handles click on the cancel link.
+ *
+ * @param {MouseEvent} ev
+ */
+ onClickCancelLink(ev) {
+ ev.preventDefault();
+ this.discard();
+ }
+
+ /**
+ * Handles click on the save link.
+ *
+ * @param {MouseEvent} ev
+ */
+ onClickSaveLink(ev) {
+ ev.preventDefault();
+ if (!this.canPostMessage) {
+ if (this.hasUploadingAttachment) {
+ this.env.services['notification'].notify({
+ message: this.env._t("Please wait while the file is uploading."),
+ type: 'warning',
+ });
+ }
+ return;
+ }
+ if (this.messageInEditing && this.messageInEditing.isEditing) {
+ this.updateMessage();
+ return;
+ }
+ this.postMessage();
+ }
+
/**
* Open the full composer modal.
*/
@@ -173,9 +220,9 @@ function factory(dependencies) {
default_attachment_ids: attachmentIds,
default_body: escapeAndCompactTextContent(this.textInputContent),
default_is_log: this.isLog,
- default_model: this.thread.model,
+ default_model: this.activeThread.model,
default_partner_ids: this.recipients.map(partner => partner.id),
- default_res_id: this.thread.id,
+ default_res_id: this.activeThread.id,
mail_post_autofollow: true,
};
@@ -193,7 +240,7 @@ function factory(dependencies) {
return;
}
this._reset();
- this.thread.loadNewMessages();
+ this.activeThread.loadNewMessages();
},
};
await this.env.bus.trigger('do-action', { action, options });
@@ -203,10 +250,10 @@ function factory(dependencies) {
* Post a message in provided composer's thread based on current composer fields values.
*/
async postMessage() {
- if (this.thread.model === 'mail.channel') {
+ if (this.activeThread.model === 'mail.channel') {
const command = this._getCommandFromText(this.textInputContent);
if (command) {
- await command.execute({ channel: this.thread, body: this.textInputContent });
+ await command.execute({ channel: this.activeThread, body: this.textInputContent });
if (this.exists()) {
this._reset();
}
@@ -214,7 +261,7 @@ function factory(dependencies) {
}
}
if (this.messaging.currentPartner) {
- this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true });
+ this.activeThread.unregisterCurrentPartnerIsTyping({ immediateNotify: true });
}
const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent);
let body = escapedAndCompactContent.replace(/ /g, ' ').trim();
@@ -235,12 +282,12 @@ function factory(dependencies) {
};
const params = {
'post_data': postData,
- 'thread_id': this.thread.id,
- 'thread_model': this.thread.model,
+ 'thread_id': this.activeThread.id,
+ 'thread_model': this.activeThread.model,
};
try {
this.update({ isPostingMessage: true });
- if (this.thread.model === 'mail.channel') {
+ if (this.activeThread.model === 'mail.channel') {
Object.assign(postData, {
subtype_xmlid: 'mail.mt_comment',
});
@@ -275,13 +322,47 @@ function factory(dependencies) {
}
}
+ /**
+ * Update a posted message when the message is ready.
+ */
+ async updateMessage() {
+ if (this.messageInEditing.originThread) {
+ if (this.messaging.currentPartner) {
+ this.messageInEditing.originThread.unregisterCurrentPartnerIsTyping({ immediateNotify: true });
+ }
+ }
+ const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent);
+ let body = escapedAndCompactContent.replace(/ /g, ' ').trim();
+ body = this._generateMentionsLinks(body);
+ body = parseAndTransform(body, addLink);
+ body = this._generateEmojisOnHtml(body);
+ let data = {
+ body: body,
+ attachment_ids: this.messageInEditing.attachments.concat(this.attachments).map(attachment => attachment.id),
+ };
+ try {
+ this.update({ isPostingMessage: true });
+ await this.messageInEditing.updateContent(data);
+ if (this.messageInEditing.originThread.model !== 'mail.channel') {
+ this.messageInEditing.originThread.refreshFollowers();
+ this.messageInEditing.originThread.fetchAndUpdateSuggestedRecipients();
+ }
+ this._reset();
+ } finally {
+ this.update({ isPostingMessage: false });
+ this.messageInEditing.update({ isEditing: false });
+ if (this.messageInEditing.originThread && this.messageInEditing.originThread.composer) {
+ this.messageInEditing.originThread.composer.update({ doFocus: true });
+ }
+ }
+ }
/**
* 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.thread) {
+ if (!this.activeThread) {
return;
}
if (!this.messaging.currentPartner) {
@@ -293,10 +374,10 @@ function factory(dependencies) {
) {
return;
}
- if (this.thread.typingMembers.includes(this.messaging.currentPartner)) {
- this.thread.refreshCurrentPartnerIsTyping();
+ if (this.activeThread.typingMembers.includes(this.messaging.currentPartner)) {
+ this.activeThread.refreshCurrentPartnerIsTyping();
} else {
- this.thread.registerCurrentPartnerIsTyping();
+ this.activeThread.registerCurrentPartnerIsTyping();
}
}
@@ -385,6 +466,20 @@ function factory(dependencies) {
return link(firstRecord);
}
+ /**
+ * @private
+ * @returns {mail.thread}
+ */
+ _computeActiveThread() {
+ if (this.messageInEditing && this.messageInEditing.originThread) {
+ return replace(this.messageInEditing.originThread);
+ }
+ if (this.thread) {
+ return replace(this.thread);
+ }
+ return clear();
+ }
+
/**
* @private
* @returns {boolean}
@@ -648,7 +743,7 @@ function factory(dependencies) {
return false;
}
if (command.channel_types) {
- return command.channel_types.includes(this.thread.channel_type);
+ return command.channel_types.includes(this.activeThread.channel_type);
}
return true;
});
@@ -723,7 +818,7 @@ function factory(dependencies) {
}
const Model = this.messaging.models[this.suggestionModelName];
const searchTerm = this.suggestionSearchTerm;
- await this.async(() => Model.fetchSuggestions(searchTerm, { thread: this.thread }));
+ await this.async(() => Model.fetchSuggestions(searchTerm, { thread: this.activeThread }));
this._updateSuggestionList();
if (
this.suggestionSearchTerm &&
@@ -774,8 +869,8 @@ function factory(dependencies) {
const [
mainSuggestedRecords,
extraSuggestedRecords = [],
- ] = Model.searchSuggestions(this.suggestionSearchTerm, { thread: this.thread });
- const sortFunction = Model.getSuggestionSortFunction(this.suggestionSearchTerm, { thread: this.thread });
+ ] = Model.searchSuggestions(this.suggestionSearchTerm, { thread: this.activeThread });
+ const sortFunction = Model.getSuggestionSortFunction(this.suggestionSearchTerm, { thread: this.activeThread });
mainSuggestedRecords.sort(sortFunction);
extraSuggestedRecords.sort(sortFunction);
// arbitrary limit to avoid displaying too many elements at once
@@ -801,6 +896,9 @@ function factory(dependencies) {
activeSuggestedRecord: many2one('mail.model', {
compute: '_computeActiveSuggestedRecord',
}),
+ activeThread: many2one('mail.thread', {
+ compute: '_computeActiveThread',
+ }),
attachments: many2many('mail.attachment', {
inverse: 'composers',
}),
@@ -826,6 +924,10 @@ function factory(dependencies) {
discussAsReplying: one2one('mail.discuss', {
inverse: 'replyingToMessageOriginThreadComposer',
}),
+ /**
+ * Determines whether this composer should be focused at next render.
+ */
+ doFocus: attr(),
/**
* Determines the extra records that are currently suggested.
* Allows to have different model types of mentions through a dynamic
@@ -898,7 +1000,7 @@ function factory(dependencies) {
/**
* Determines the extra `mail.partner` (on top of existing followers)
* that will receive the message being composed by `this`, and that will
- * also be added as follower of `this.thread`.
+ * also be added as follower of `this.activeThread`.
*/
recipients: many2many('mail.partner', {
compute: '_computeRecipients',
@@ -947,7 +1049,9 @@ function factory(dependencies) {
}),
thread: one2one('mail.thread', {
inverse: 'composer',
- required: true,
+ }),
+ messageInEditing: one2one('mail.message', {
+ inverse: 'composerInEditing',
}),
};
Composer.onChanges = [
diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js
index bbed43fbb25..34aeaa89b9a 100644
--- a/addons/mail/static/src/models/message/message.js
+++ b/addons/mail/static/src/models/message/message.js
@@ -2,7 +2,7 @@
import { registerNewModel } from '@mail/model/model_core';
import { attr, many2many, many2one, one2many, one2one } from '@mail/model/model_field';
-import { clear, create, insert, insertAndReplace, replace, unlinkAll } from '@mail/model/model_field_command';
+import { clear, create, insert, insertAndReplace, link, replace, unlinkAll } from '@mail/model/model_field_command';
import emojis from '@mail/js/emojis';
import { addLink, htmlToTextContentInline, parseAndTransform, timeFromNow } from '@mail/js/utils';
@@ -276,6 +276,28 @@ function factory(dependencies) {
this.messaging.discuss.replyToMessage(this);
}
+ startEditing() {
+ const parser = new DOMParser();
+ const htmlDoc = parser.parseFromString(this.body.replaceAll('
', '\n').replaceAll('', '\n'), "text/html");
+ const textInputContent = htmlDoc.body.textContent;
+ const composerData = {
+ doFocus: true,
+ isLastStateChangeProgrammatic: true,
+ textInputCursorStart: textInputContent.length,
+ textInputCursorEnd: textInputContent.length,
+ textInputSelectionDirection: 'none',
+ textInputContent,
+ };
+ if (this.composerInEditing) {
+ this.composerInEditing.update(composerData);
+ } else {
+ this.messaging.models['mail.composer'].create({
+ messageInEditing: link(this),
+ ...composerData,
+ });
+ }
+ this.update({ isEditing: true });
+ }
/**
* Toggle the starred status of the provided message.
*/
@@ -293,11 +315,12 @@ function factory(dependencies) {
* @param {Object} param0
* @param {string} param0.body the new body of the message
*/
- async updateContent({ body }) {
+ async updateContent({ body, attachment_ids }) {
const messageData = await this.env.services.rpc({
route: '/mail/message/update_content',
params: {
body,
+ attachment_ids,
message_id: this.id,
},
});
@@ -569,6 +592,13 @@ function factory(dependencies) {
canStarBeToggled: attr({
compute: '_computeCanStarBeToggled',
}),
+ /**
+ * The composer in editing mode of a message.
+ */
+ composerInEditing: one2one('mail.composer', {
+ inverse: 'messageInEditing',
+ isCausal: true,
+ }),
/**
* Determines the date of the message as a moment object.
*/
@@ -617,6 +647,12 @@ function factory(dependencies) {
compute: '_computeIsBodyEqualSubtypeDescription',
default: false,
}),
+ /*
+ * Determines whether the current user is currently editing this message.
+ */
+ isEditing: attr({
+ default: false,
+ }),
/**
* Determine whether the message has to be considered empty or not.
*
diff --git a/addons/mail/static/src/models/message_action_list/message_action_list.js b/addons/mail/static/src/models/message_action_list/message_action_list.js
index dea2d07feb7..72056f26ab6 100644
--- a/addons/mail/static/src/models/message_action_list/message_action_list.js
+++ b/addons/mail/static/src/models/message_action_list/message_action_list.js
@@ -16,6 +16,7 @@ function factory(dependencies) {
this.onClick = this.onClick.bind(this);
this.onClickConfirmDelete = this.onClickConfirmDelete.bind(this);
this.onClickDelete = this.onClickDelete.bind(this);
+ this.onClickEdit = this.onClickEdit.bind(this);
this.onClickMarkAsRead = this.onClickMarkAsRead.bind(this);
this.onReactionPopoverOpened = this.onReactionPopoverOpened.bind(this);
this.onReactionPopoverClosed = this.onReactionPopoverClosed.bind(this);
@@ -38,7 +39,10 @@ function factory(dependencies) {
* @param {MouseEvent} ev
*/
onClickConfirmDelete(ev) {
- this.message.updateContent({ body: '' });
+ this.message.updateContent({
+ body: '',
+ attachment_ids: [],
+ });
}
/**
@@ -49,6 +53,14 @@ function factory(dependencies) {
this.update({ showDeleteConfirm: true });
}
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ onClickEdit(ev) {
+ this.message.startEditing();
+ }
+
/**
* @private
* @param {MouseEvent} ev
diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js
index f2e63ddac85..f58e2c94b94 100644
--- a/addons/mail/static/src/models/thread/thread.js
+++ b/addons/mail/static/src/models/thread/thread.js
@@ -702,9 +702,7 @@ function factory(dependencies) {
orderBy: [{ name: 'id', asc: false }],
}, { shadow: true }));
this.update({
- originThreadAttachments: insertAndReplace(attachmentsData.map(data =>
- this.messaging.models['mail.attachment'].convertData(data)
- )),
+ originThreadAttachments: insertAndReplace(attachmentsData),
});
this.update({ areAttachmentsLoaded: true });
}
@@ -1189,6 +1187,20 @@ function factory(dependencies) {
});
}
+ /**
+ * Starts editing the last message of this thread from the current user.
+ */
+ startEditingLastMessageFromCurrentUser() {
+ for (const threadView of this.threadViews) {
+ const messages = threadView.threadCache.orderedMessages;
+ messages.reverse();
+ const message = messages.find(message => message.isCurrentUserOrGuestAuthor && message.canBeDeleted);
+ if (message) {
+ message.startEditing();
+ }
+ }
+ }
+
/**
* Unfollow current partner from this thread.
*/