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:
parent
77c90a69ca
commit
0de6001b97
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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']"
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/message', message: @message
|
||||
@ -125,6 +125,7 @@ Rails.application.routes.draw do
|
||||
member do
|
||||
post :translate
|
||||
post :retry
|
||||
patch :edit_content
|
||||
end
|
||||
resources :attachments, only: [:update]
|
||||
end
|
||||
|
||||
Loading…
Reference in New Issue
Block a user