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 }
|
render json: { content: translated_content }
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def message
|
def message
|
||||||
@ -70,7 +86,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:id, :target_language, :status, :external_error)
|
params.permit(:id, :target_language, :status, :external_error, :content)
|
||||||
end
|
end
|
||||||
|
|
||||||
def already_translated_content_available?
|
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}"
|
Rails.logger.error "Failed to delete message on channel: #{e.message}"
|
||||||
end
|
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
|
# API inbox check
|
||||||
def ensure_api_inbox
|
def ensure_api_inbox
|
||||||
# Only API inboxes can update messages
|
# Only API inboxes can update messages
|
||||||
|
|||||||
@ -92,6 +92,13 @@ class MessageApi extends ApiClient {
|
|||||||
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
|
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) {
|
retry(conversationID, messageId) {
|
||||||
return axios.post(
|
return axios.post(
|
||||||
`${this.url}/${conversationID}/messages/${messageId}/retry`
|
`${this.url}/${conversationID}/messages/${messageId}/retry`
|
||||||
|
|||||||
@ -123,6 +123,7 @@ const props = defineProps({
|
|||||||
groupWithNext: { type: Boolean, default: false },
|
groupWithNext: { type: Boolean, default: false },
|
||||||
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
|
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||||
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
||||||
|
inboxSupportsEdit: { type: Boolean, default: false },
|
||||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||||
isEmailInbox: { type: Boolean, default: false },
|
isEmailInbox: { type: Boolean, default: false },
|
||||||
private: { type: Boolean, default: false },
|
private: { type: Boolean, default: false },
|
||||||
@ -371,6 +372,12 @@ const contextMenuEnabledOptions = computed(() => {
|
|||||||
!props.private &&
|
!props.private &&
|
||||||
props.inboxSupportsReplyTo.outgoing &&
|
props.inboxSupportsReplyTo.outgoing &&
|
||||||
!isFailedOrProcessing,
|
!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 {Number} currentUserId - ID of the current user
|
||||||
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
|
* @property {Boolean} isAnEmailChannel - Whether this is an email channel
|
||||||
* @property {Object} inboxSupportsReplyTo - Inbox reply support configuration
|
* @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]
|
* @property {Array} messages - Array of all messages [These are not in camelcase]
|
||||||
*/
|
*/
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -33,6 +34,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({ incoming: false, outgoing: false }),
|
default: () => ({ incoming: false, outgoing: false }),
|
||||||
},
|
},
|
||||||
|
inboxSupportsEdit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
messages: {
|
messages: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@ -176,6 +181,7 @@ const getInReplyToMessage = parentMessage => {
|
|||||||
:in-reply-to="getInReplyToMessage(message)"
|
:in-reply-to="getInReplyToMessage(message)"
|
||||||
:group-with-next="shouldGroupWithNext(index, allMessages)"
|
:group-with-next="shouldGroupWithNext(index, allMessages)"
|
||||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||||
|
:inbox-supports-edit="inboxSupportsEdit"
|
||||||
:current-user-id="currentUserId"
|
:current-user-id="currentUserId"
|
||||||
data-clarity-mask="True"
|
data-clarity-mask="True"
|
||||||
@retry="emit('retry', message)"
|
@retry="emit('retry', message)"
|
||||||
|
|||||||
@ -126,6 +126,14 @@ const statusToShow = computed(() => {
|
|||||||
|
|
||||||
return MESSAGE_STATUS.PROGRESS;
|
return MESSAGE_STATUS.PROGRESS;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isEdited = computed(() => {
|
||||||
|
return contentAttributes.value?.isEdited === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousContent = computed(() => {
|
||||||
|
return contentAttributes.value?.previousContent || '';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -133,6 +141,16 @@ const statusToShow = computed(() => {
|
|||||||
<div class="inline">
|
<div class="inline">
|
||||||
<time class="inline">{{ readableTime }}</time>
|
<time class="inline">{{ readableTime }}</time>
|
||||||
</div>
|
</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" />
|
<Icon v-if="isPrivate" icon="i-lucide-lock-keyhole" class="size-3" />
|
||||||
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
|
<MessageStatus v-if="showStatusIndicator" :status="statusToShow" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useMessageContext } from '../../provider.js';
|
|||||||
|
|
||||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||||
import { MESSAGE_VARIANTS } from '../../constants';
|
import { MESSAGE_VARIANTS } from '../../constants';
|
||||||
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
content: {
|
content: {
|
||||||
@ -12,7 +13,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { variant } = useMessageContext();
|
const { variant, contentAttributes, shouldGroupWithNext } = useMessageContext();
|
||||||
|
|
||||||
const formattedContent = computed(() => {
|
const formattedContent = computed(() => {
|
||||||
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
|
if (variant.value === MESSAGE_VARIANTS.ACTIVITY) {
|
||||||
@ -21,8 +22,40 @@ const formattedContent = computed(() => {
|
|||||||
|
|
||||||
return new MessageFormatter(props.content).formattedMessage;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
</template>
|
||||||
|
|||||||
@ -262,6 +262,10 @@ export default {
|
|||||||
|
|
||||||
return { incoming, outgoing };
|
return { incoming, outgoing };
|
||||||
},
|
},
|
||||||
|
inboxSupportsEdit() {
|
||||||
|
// Currently only Baileys WhatsApp channel supports message editing
|
||||||
|
return this.isAWhatsAppBaileysChannel;
|
||||||
|
},
|
||||||
inboxProviderConnection() {
|
inboxProviderConnection() {
|
||||||
return this.currentInbox.provider_connection?.connection;
|
return this.currentInbox.provider_connection?.connection;
|
||||||
},
|
},
|
||||||
@ -557,6 +561,7 @@ export default {
|
|||||||
:first-unread-id="unReadMessages[0]?.id"
|
:first-unread-id="unReadMessages[0]?.id"
|
||||||
:is-an-email-channel="isAnEmailChannel"
|
:is-an-email-channel="isAnEmailChannel"
|
||||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||||
|
:inbox-supports-edit="inboxSupportsEdit"
|
||||||
:messages="getMessages"
|
:messages="getMessages"
|
||||||
@retry="handleMessageRetry"
|
@retry="handleMessageRetry"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -274,6 +274,16 @@
|
|||||||
"MESSAGE": "You cannot undo this action",
|
"MESSAGE": "You cannot undo this action",
|
||||||
"DELETE": "Delete",
|
"DELETE": "Delete",
|
||||||
"CANCEL": "Cancel"
|
"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": {
|
"SIDEBAR": {
|
||||||
|
|||||||
@ -273,6 +273,16 @@
|
|||||||
"MESSAGE": "Você não pode desfazer essa ação",
|
"MESSAGE": "Você não pode desfazer essa ação",
|
||||||
"DELETE": "Excluir",
|
"DELETE": "Excluir",
|
||||||
"CANCEL": "Cancelar"
|
"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": {
|
"SIDEBAR": {
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
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 {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -21,6 +23,7 @@ export default {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
NextButton,
|
NextButton,
|
||||||
|
Editor,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
message: {
|
message: {
|
||||||
@ -47,15 +50,20 @@ export default {
|
|||||||
emits: ['open', 'close', 'replyTo'],
|
emits: ['open', 'close', 'replyTo'],
|
||||||
setup() {
|
setup() {
|
||||||
const { getPlainText } = useMessageFormatter();
|
const { getPlainText } = useMessageFormatter();
|
||||||
|
const { isEditorHotKeyEnabled } = useUISettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getPlainText,
|
getPlainText,
|
||||||
|
isEditorHotKeyEnabled,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isCannedResponseModalOpen: false,
|
isCannedResponseModalOpen: false,
|
||||||
showDeleteModal: false,
|
showDeleteModal: false,
|
||||||
|
showEditModal: false,
|
||||||
|
editedContent: '',
|
||||||
|
isEditingMessage: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -83,6 +91,17 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
handleEnterKey(e) {
|
||||||
|
if (this.isEditorHotKeyEnabled('enter')) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.confirmEdit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleCmdEnterKey() {
|
||||||
|
if (this.isEditorHotKeyEnabled('cmd_enter')) {
|
||||||
|
this.confirmEdit();
|
||||||
|
}
|
||||||
|
},
|
||||||
async copyLinkToMessage() {
|
async copyLinkToMessage() {
|
||||||
const fullConversationURL =
|
const fullConversationURL =
|
||||||
window.chatwootConfig.hostURL +
|
window.chatwootConfig.hostURL +
|
||||||
@ -152,6 +171,40 @@ export default {
|
|||||||
closeDeleteModal() {
|
closeDeleteModal() {
|
||||||
this.showDeleteModal = false;
|
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>
|
</script>
|
||||||
@ -181,6 +234,52 @@ export default {
|
|||||||
:confirm-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.DELETE')"
|
:confirm-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.DELETE')"
|
||||||
:reject-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.CANCEL')"
|
: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
|
<NextButton
|
||||||
v-if="!hideButton"
|
v-if="!hideButton"
|
||||||
ghost
|
ghost
|
||||||
@ -243,6 +342,15 @@ export default {
|
|||||||
variant="icon"
|
variant="icon"
|
||||||
@click.stop="showCannedResponseModal"
|
@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']" />
|
<hr v-if="enabledOptions['delete']" />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
v-if="enabledOptions['delete']"
|
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) => {
|
deleteConversation: async ({ commit, dispatch }, conversationId) => {
|
||||||
try {
|
try {
|
||||||
await ConversationApi.delete(conversationId);
|
await ConversationApi.delete(conversationId);
|
||||||
|
|||||||
@ -152,6 +152,13 @@ class Channel::Whatsapp < ApplicationRecord
|
|||||||
provider_service.delete_message(recipient_id, message)
|
provider_service.delete_message(recipient_id, message)
|
||||||
end
|
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 :setup_channel_provider, to: :provider_service
|
||||||
delegate :send_message, to: :provider_service
|
delegate :send_message, to: :provider_service
|
||||||
delegate :send_template, 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)
|
@raw_message = @raw_message.dig(:update, :message, :editedMessage)
|
||||||
content = message_content
|
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'
|
Rails.logger.warn 'No valid message content found in the edit event'
|
||||||
end
|
end
|
||||||
|
|||||||
@ -262,6 +262,28 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
|
|||||||
true
|
true
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def provider_url
|
def provider_url
|
||||||
@ -384,5 +406,6 @@ class Whatsapp::Providers::WhatsappBaileysService < Whatsapp::Providers::BaseSer
|
|||||||
:unread_message,
|
:unread_message,
|
||||||
:received_messages,
|
:received_messages,
|
||||||
:on_whatsapp,
|
:on_whatsapp,
|
||||||
:delete_message
|
:delete_message,
|
||||||
|
:edit_message
|
||||||
end
|
end
|
||||||
|
|||||||
@ -266,10 +266,12 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
|
|||||||
@message = find_message_by_source_id(@raw_message[:messageId])
|
@message = find_message_by_source_id(@raw_message[:messageId])
|
||||||
return unless @message
|
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!(
|
@message.update!(
|
||||||
content: message_content,
|
content: message_content,
|
||||||
is_edited: true,
|
is_edited: true,
|
||||||
previous_content: @message.content
|
previous_content: previous_content_to_save
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/message', message: @message
|
||||||
@ -125,6 +125,7 @@ Rails.application.routes.draw do
|
|||||||
member do
|
member do
|
||||||
post :translate
|
post :translate
|
||||||
post :retry
|
post :retry
|
||||||
|
patch :edit_content
|
||||||
end
|
end
|
||||||
resources :attachments, only: [:update]
|
resources :attachments, only: [:update]
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user