feat: add message editing functionality with UI support (#195)

* feat: add message editing functionality with UI support

* feat: enhance message editing with content length validation and context menu adjustments
This commit is contained in:
Gabriel Jablonski 2026-01-24 23:25:11 -03:00 committed by GitHub
parent 77c90a69ca
commit 0de6001b97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 297 additions and 6 deletions

View File

@ -59,6 +59,22 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
render json: { content: translated_content }
end
def edit_content
new_content = params[:content]
return render json: { error: 'Content is required' }, status: :unprocessable_entity if new_content.blank?
return render json: { error: 'Content exceeds maximum length' }, status: :unprocessable_entity if new_content.length > 150_000
return render json: { error: 'Only outgoing messages can be edited' }, status: :forbidden unless message.outgoing?
original_content = message.content
# Only save previous_content on first edit to preserve the original message
previous_content_to_save = message.is_edited ? message.previous_content : original_content
message.update!(content: new_content, is_edited: true, previous_content: previous_content_to_save)
edit_message_on_channel(new_content, original_content)
@message = message.reload
end
private
def message
@ -70,7 +86,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
def permitted_params
params.permit(:id, :target_language, :status, :external_error)
params.permit(:id, :target_language, :status, :external_error, :content)
end
def already_translated_content_available?
@ -86,6 +102,22 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
Rails.logger.error "Failed to delete message on channel: #{e.message}"
end
def edit_message_on_channel(new_content, original_content)
return unless @conversation.inbox.channel.respond_to?(:edit_message)
return if message.source_id.blank?
@conversation.inbox.channel.edit_message(message, new_content, conversation: @conversation)
rescue StandardError => e
Rails.logger.error "Failed to edit message on channel: #{e.message}"
was_already_edited = message.previous_content != original_content
if was_already_edited
message.update!(content: original_content)
else
message.update!(content: original_content, is_edited: false, previous_content: nil)
end
raise e
end
# API inbox check
def ensure_api_inbox
# Only API inboxes can update messages

View File

@ -92,6 +92,13 @@ class MessageApi extends ApiClient {
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
}
editContent(conversationID, messageId, content) {
return axios.patch(
`${this.url}/${conversationID}/messages/${messageId}/edit_content`,
{ content }
);
}
retry(conversationID, messageId) {
return axios.post(
`${this.url}/${conversationID}/messages/${messageId}/retry`

View File

@ -123,6 +123,7 @@ const props = defineProps({
groupWithNext: { type: Boolean, default: false },
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
inboxSupportsEdit: { type: Boolean, default: false },
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
isEmailInbox: { type: Boolean, default: false },
private: { type: Boolean, default: false },
@ -371,6 +372,12 @@ const contextMenuEnabledOptions = computed(() => {
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
edit:
isOutgoing &&
hasText &&
!isFailedOrProcessing &&
!isMessageDeleted.value &&
props.inboxSupportsEdit,
};
});

View File

@ -14,6 +14,7 @@ import MessageApi from 'dashboard/api/inbox/message.js';
* @property {Number} currentUserId - ID of the current user
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration
* @property {Boolean} inboxSupportsEdit - Whether the inbox supports message editing
* @property {Array} messages - Array of all messages [These are not in camelcase]
*/
const props = defineProps({
@ -33,6 +34,10 @@ const props = defineProps({
type: Object,
default: () => ({ incoming: false, outgoing: false }),
},
inboxSupportsEdit: {
type: Boolean,
default: false,
},
messages: {
type: Array,
default: () => [],
@ -176,6 +181,7 @@ const getInReplyToMessage = parentMessage => {
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, allMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:inbox-supports-edit="inboxSupportsEdit"
:current-user-id="currentUserId"
data-clarity-mask="True"
@retry="emit('retry', message)"

View File

@ -126,6 +126,14 @@ const statusToShow = computed(() => {
return MESSAGE_STATUS.PROGRESS;
});
const isEdited = computed(() => {
return contentAttributes.value?.isEdited === true;
});
const previousContent = computed(() => {
return contentAttributes.value?.previousContent || '';
});
</script>
<template>
@ -133,6 +141,16 @@ const statusToShow = computed(() => {
<div class="inline">
<time class="inline">{{ readableTime }}</time>
</div>
<span
v-if="isEdited"
v-tooltip.top="{
content: previousContent,
delay: { show: 300, hide: 0 },
}"
class="inline-flex items-center gap-0.5 cursor-help"
>
<Icon icon="i-lucide-pencil" class="size-3" />
</span>
<Icon v-if="isPrivate" icon="i-lucide-lock-keyhole" class="size-3" />
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
</div>

View File

@ -4,6 +4,7 @@ import { useMessageContext } from '../../provider.js';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS } from '../../constants';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
content: {
@ -12,7 +13,7 @@ const props = defineProps({
},
});
const { variant } = useMessageContext();
const { variant, contentAttributes, shouldGroupWithNext } = useMessageContext();
const formattedContent = computed(() => {
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
@ -21,8 +22,40 @@ const formattedContent = computed(() => {
return new MessageFormatter(props.content).formattedMessage;
});
// Show edited indicator inline when meta is hidden (grouped messages)
const isEdited = computed(() => {
return contentAttributes.value?.isEdited === true;
});
const previousContent = computed(() => {
return contentAttributes.value?.previousContent || '';
});
const shouldShowEditedIndicator = computed(() => {
return isEdited.value && shouldGroupWithNext.value;
});
const iconColorClass = computed(() => {
return variant.value === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11';
});
</script>
<template>
<span v-dompurify-html="formattedContent" class="prose prose-bubble" />
<span class="inline">
<span v-dompurify-html="formattedContent" class="prose prose-bubble" />
<span
v-if="shouldShowEditedIndicator"
v-tooltip.top="{
content: previousContent,
delay: { show: 300, hide: 0 },
}"
:class="iconColorClass"
class="inline-flex items-center ml-1 align-middle cursor-help"
>
<Icon icon="i-lucide-pencil" class="size-3" />
</span>
</span>
</template>

View File

@ -262,6 +262,10 @@ export default {
return { incoming, outgoing };
},
inboxSupportsEdit() {
// Currently only Baileys WhatsApp channel supports message editing
return this.isAWhatsAppBaileysChannel;
},
inboxProviderConnection() {
return this.currentInbox.provider_connection?.connection;
},
@ -557,6 +561,7 @@ export default {
:first-unread-id="unReadMessages[0]?.id"
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:inbox-supports-edit="inboxSupportsEdit"
:messages="getMessages"
@retry="handleMessageRetry"
>

View File

@ -274,6 +274,16 @@
"MESSAGE": "You cannot undo this action",
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"EDIT": {
"LABEL": "Edit",
"TITLE": "Edit message",
"PLACEHOLDER": "Enter message content",
"SAVE": "Save",
"CANCEL": "Cancel",
"SUCCESS": "Message edited successfully",
"ERROR": "Failed to edit message",
"EMPTY_CONTENT": "Message content cannot be empty"
}
},
"SIDEBAR": {

View File

@ -273,6 +273,16 @@
"MESSAGE": "Você não pode desfazer essa ação",
"DELETE": "Excluir",
"CANCEL": "Cancelar"
},
"EDIT": {
"LABEL": "Editar",
"TITLE": "Editar mensagem",
"PLACEHOLDER": "Digite o conteúdo da mensagem",
"SAVE": "Salvar",
"CANCEL": "Cancelar",
"SUCCESS": "Mensagem editada com sucesso",
"ERROR": "Falha ao editar mensagem",
"EMPTY_CONTENT": "O conteúdo da mensagem não pode estar vazio"
}
},
"SIDEBAR": {

View File

@ -14,6 +14,8 @@ import {
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
import { useTrack } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
components: {
@ -21,6 +23,7 @@ export default {
MenuItem,
ContextMenu,
NextButton,
Editor,
},
props: {
message: {
@ -47,15 +50,20 @@ export default {
emits: ['open', 'close', 'replyTo'],
setup() {
const { getPlainText } = useMessageFormatter();
const { isEditorHotKeyEnabled } = useUISettings();
return {
getPlainText,
isEditorHotKeyEnabled,
};
},
data() {
return {
isCannedResponseModalOpen: false,
showDeleteModal: false,
showEditModal: false,
editedContent: '',
isEditingMessage: false,
};
},
computed: {
@ -83,6 +91,17 @@ export default {
},
},
methods: {
handleEnterKey(e) {
if (this.isEditorHotKeyEnabled('enter')) {
e.preventDefault();
this.confirmEdit();
}
},
handleCmdEnterKey() {
if (this.isEditorHotKeyEnabled('cmd_enter')) {
this.confirmEdit();
}
},
async copyLinkToMessage() {
const fullConversationURL =
window.chatwootConfig.hostURL +
@ -152,6 +171,40 @@ export default {
closeDeleteModal() {
this.showDeleteModal = false;
},
openEditModal() {
this.handleClose();
this.editedContent = this.messageContent;
this.showEditModal = true;
},
closeEditModal() {
this.showEditModal = false;
this.editedContent = '';
},
async confirmEdit() {
const trimmedContent = this.editedContent.trim();
if (!trimmedContent) {
useAlert(this.$t('CONVERSATION.CONTEXT_MENU.EDIT.EMPTY_CONTENT'));
return;
}
if (trimmedContent === (this.messageContent || '').trim()) {
this.closeEditModal();
return;
}
this.isEditingMessage = true;
try {
await this.$store.dispatch('editMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
content: trimmedContent,
});
useAlert(this.$t('CONVERSATION.CONTEXT_MENU.EDIT.SUCCESS'));
this.closeEditModal();
} catch (error) {
useAlert(this.$t('CONVERSATION.CONTEXT_MENU.EDIT.ERROR'));
} finally {
this.isEditingMessage = false;
}
},
},
};
</script>
@ -181,6 +234,52 @@ export default {
:confirm-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.DELETE')"
:reject-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.CANCEL')"
/>
<!-- Edit Message Modal -->
<woot-modal
v-if="showEditModal"
v-model:show="showEditModal"
:on-close="closeEditModal"
>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('CONVERSATION.CONTEXT_MENU.EDIT.TITLE')"
/>
<form
class="flex flex-col w-full"
@submit.prevent="confirmEdit"
@keydown.enter.exact="handleEnterKey"
@keydown.meta.enter="handleCmdEnterKey"
@keydown.ctrl.enter="handleCmdEnterKey"
>
<Editor
v-model="editedContent"
:label="$t('CONVERSATION.CONTEXT_MENU.EDIT.PLACEHOLDER')"
:placeholder="$t('CONVERSATION.CONTEXT_MENU.EDIT.PLACEHOLDER')"
:max-length="4096"
:show-character-count="false"
:enable-canned-responses="false"
focus-on-mount
/>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="$t('CONVERSATION.CONTEXT_MENU.EDIT.CANCEL')"
@click.prevent="closeEditModal"
/>
<NextButton
type="submit"
:label="$t('CONVERSATION.CONTEXT_MENU.EDIT.SAVE')"
:disabled="!editedContent.trim()"
:is-loading="isEditingMessage"
icon="i-lucide-corner-down-left"
trailing-icon
/>
</div>
</form>
</div>
</woot-modal>
<NextButton
v-if="!hideButton"
ghost
@ -243,6 +342,15 @@ export default {
variant="icon"
@click.stop="showCannedResponseModal"
/>
<MenuItem
v-if="enabledOptions['edit']"
:option="{
icon: 'edit',
label: $t('CONVERSATION.CONTEXT_MENU.EDIT.LABEL'),
}"
variant="icon"
@click.stop="openEditModal"
/>
<hr v-if="enabledOptions['delete']" />
<MenuItem
v-if="enabledOptions['delete']"

View File

@ -333,6 +333,23 @@ const actions = {
}
},
editMessage: async function editMessage(
{ commit },
{ conversationId, messageId, content }
) {
try {
const { data } = await MessageApi.editContent(
conversationId,
messageId,
content
);
commit(types.ADD_MESSAGE, data);
return data;
} catch (error) {
throw new Error(error);
}
},
deleteConversation: async ({ commit, dispatch }, conversationId) => {
try {
await ConversationApi.delete(conversationId);

View File

@ -152,6 +152,13 @@ class Channel::Whatsapp < ApplicationRecord
provider_service.delete_message(recipient_id, message)
end
def edit_message(message, new_content, conversation:)
return unless provider_service.respond_to?(:edit_message)
recipient_id = conversation.contact.identifier || conversation.contact.phone_number
provider_service.edit_message(recipient_id, message, new_content)
end
delegate :setup_channel_provider, to: :provider_service
delegate :send_message, to: :provider_service
delegate :send_template, to: :provider_service

View File

@ -79,7 +79,11 @@ module Whatsapp::BaileysHandlers::MessagesUpdate
@raw_message = @raw_message.dig(:update, :message, :editedMessage)
content = message_content
return @message.update!(content: content, is_edited: true, previous_content: @message.content) if content
if content
# Preserve original previous_content if message was already edited
previous_content_to_save = @message.is_edited ? @message.previous_content : @message.content
return @message.update!(content: content, is_edited: true, previous_content: previous_content_to_save)
end
Rails.logger.warn 'No valid message content found in the edit event'
end

View File

@ -262,6 +262,28 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
true
end
def edit_message(recipient_id, message, new_content)
@recipient_id = recipient_id
response = HTTParty.patch(
"#{provider_url}/connections/#{whatsapp_channel.phone_number}/messages",
headers: api_headers,
body: {
jid: remote_jid,
key: {
id: message.source_id,
remoteJid: remote_jid,
fromMe: message.message_type == 'outgoing'
},
messageContent: { text: new_content }
}.to_json
)
raise ProviderUnavailableError unless process_response(response)
true
end
private
def provider_url
@ -384,5 +406,6 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
:unread_message,
:received_messages,
:on_whatsapp,
:delete_message
:delete_message,
:edit_message
end

View File

@ -266,10 +266,12 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
@message = find_message_by_source_id(@raw_message[:messageId])
return unless @message
# Preserve original previous_content if message was already edited
previous_content_to_save = @message.is_edited ? @message.previous_content : @message.content
@message.update!(
content: message_content,
is_edited: true,
previous_content: @message.content
previous_content: previous_content_to_save
)
end
end

View File

@ -0,0 +1 @@
json.partial! 'api/v1/models/message', message: @message

View File

@ -125,6 +125,7 @@ Rails.application.routes.draw do
member do
post :translate
post :retry
patch :edit_content
end
resources :attachments, only: [:update]
end