Files
chatwoot/app/javascript/dashboard/modules/conversations/components/MessageContextMenu.vue
Pranav cc4924db55 feat: Update translated content inline (#11074)
Previously, viewing translations required opening a modal, which was a
frustrating experience. This update improves the UX by displaying
translations inline.

https://www.loom.com/share/c69f4316248946208f2e56e2429063a2

A sample message with translated content (which has an option to see the
original):
 
<img width="313" alt="Screenshot 2025-03-12 at 6 57 49 PM"
src="https://github.com/user-attachments/assets/52434086-b57f-40e8-87b8-314c3519df4b"
/>

Right now, the translation is done based on the account locale. Some of
the future considerations.

- Add personal preference for the language (For eg: an agent can use the
software in spanish even though the company default is english), then
support translations based on personal preference.
- Add support for LLM translations (OpenAI integration)
- Add translations if Captain is enabled
- Add auto translation if the feature is turned on.
2025-03-12 19:31:28 -07:00

283 lines
7.4 KiB
Vue

<script>
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
import {
ACCOUNT_EVENTS,
CONVERSATION_EVENTS,
} from '../../../helper/AnalyticsHelper/events';
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
import { useTrack } from 'dashboard/composables';
export default {
components: {
AddCannedModal,
MenuItem,
ContextMenu,
},
props: {
message: {
type: Object,
required: true,
},
isOpen: {
type: Boolean,
default: false,
},
enabledOptions: {
type: Object,
default: () => ({}),
},
contextMenuPosition: {
type: Object,
default: () => ({}),
},
hideButton: {
type: Boolean,
default: false,
},
},
emits: ['open', 'close', 'replyTo'],
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
},
data() {
return {
isCannedResponseModalOpen: false,
showDeleteModal: false,
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
currentAccountId: 'getCurrentAccountId',
}),
plainTextContent() {
return this.getPlainText(this.messageContent);
},
conversationId() {
return this.message.conversation_id ?? this.message.conversationId;
},
messageId() {
return this.message.id;
},
messageContent() {
return this.message.content;
},
contentAttributes() {
return useSnakeCase(
this.message.content_attributes ?? this.message.contentAttributes
);
},
},
methods: {
async copyLinkToMessage() {
const fullConversationURL =
window.chatwootConfig.hostURL +
frontendURL(
conversationUrl({
id: this.conversationId,
accountId: this.currentAccountId,
})
);
await copyTextToClipboard(
`${fullConversationURL}?messageId=${this.messageId}`
);
useAlert(this.$t('CONVERSATION.CONTEXT_MENU.LINK_COPIED'));
this.handleClose();
},
async handleCopy() {
await copyTextToClipboard(this.plainTextContent);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
this.handleClose();
},
showCannedResponseModal() {
useTrack(ACCOUNT_EVENTS.ADDED_TO_CANNED_RESPONSE);
this.isCannedResponseModalOpen = true;
},
hideCannedResponseModal() {
this.isCannedResponseModalOpen = false;
this.handleClose();
},
handleOpen(e) {
this.$emit('open', e);
},
handleClose(e) {
this.$emit('close', e);
},
handleTranslate() {
const { locale } = this.getAccount(this.currentAccountId);
this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage: locale || 'en',
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
this.handleClose();
},
handleReplyTo() {
this.$emit('replyTo', this.message);
this.handleClose();
},
openDeleteModal() {
this.handleClose();
this.showDeleteModal = true;
},
async confirmDeletion() {
try {
await this.$store.dispatch('deleteMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
});
useAlert(this.$t('CONVERSATION.SUCCESS_DELETE_MESSAGE'));
this.handleClose();
} catch (error) {
useAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
}
},
closeDeleteModal() {
this.showDeleteModal = false;
},
},
};
</script>
<template>
<div class="context-menu">
<!-- Add To Canned Responses -->
<woot-modal
v-if="isCannedResponseModalOpen && enabledOptions['cannedResponse']"
v-model:show="isCannedResponseModalOpen"
:on-close="hideCannedResponseModal"
>
<AddCannedModal
:response-content="plainTextContent"
:on-close="hideCannedResponseModal"
/>
</woot-modal>
<!-- Confirm Deletion -->
<woot-delete-modal
v-if="showDeleteModal"
v-model:show="showDeleteModal"
class="context-menu--delete-modal"
:on-close="closeDeleteModal"
:on-confirm="confirmDeletion"
:title="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.TITLE')"
:message="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.MESSAGE')"
:confirm-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.DELETE')"
:reject-text="$t('CONVERSATION.CONTEXT_MENU.DELETE_CONFIRMATION.CANCEL')"
/>
<woot-button
v-if="!hideButton"
icon="more-vertical"
color-scheme="secondary"
variant="clear"
size="small"
class="invisible group-hover/context-menu:visible"
@click="handleOpen"
/>
<ContextMenu
v-if="isOpen && !isCannedResponseModalOpen"
:x="contextMenuPosition.x"
:y="contextMenuPosition.y"
@close="handleClose"
>
<div class="menu-container">
<MenuItem
v-if="enabledOptions['replyTo']"
:option="{
icon: 'arrow-reply',
label: $t('CONVERSATION.CONTEXT_MENU.REPLY_TO'),
}"
variant="icon"
@click.stop="handleReplyTo"
/>
<MenuItem
v-if="enabledOptions['copy']"
:option="{
icon: 'clipboard',
label: $t('CONVERSATION.CONTEXT_MENU.COPY'),
}"
variant="icon"
@click.stop="handleCopy"
/>
<MenuItem
v-if="enabledOptions['copy']"
:option="{
icon: 'translate',
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
}"
variant="icon"
@click.stop="handleTranslate"
/>
<hr />
<MenuItem
:option="{
icon: 'link',
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
}"
variant="icon"
@click.stop="copyLinkToMessage"
/>
<MenuItem
v-if="enabledOptions['cannedResponse']"
:option="{
icon: 'comment-add',
label: $t('CONVERSATION.CONTEXT_MENU.CREATE_A_CANNED_RESPONSE'),
}"
variant="icon"
@click.stop="showCannedResponseModal"
/>
<hr v-if="enabledOptions['delete']" />
<MenuItem
v-if="enabledOptions['delete']"
:option="{
icon: 'delete',
label: $t('CONVERSATION.CONTEXT_MENU.DELETE'),
}"
variant="icon"
@click.stop="openDeleteModal"
/>
</div>
</ContextMenu>
</div>
</template>
<style lang="scss" scoped>
.menu-container {
@apply p-1 bg-white dark:bg-slate-900 shadow-xl rounded-md;
hr:first-child {
@apply hidden;
}
hr {
@apply m-1 border-b border-solid border-n-strong;
}
}
.context-menu--delete-modal {
::v-deep {
.modal-container {
@apply max-w-[30rem];
h2 {
@apply font-medium text-base;
}
}
.modal-footer {
@apply pt-4 pb-8 px-8;
}
}
}
</style>