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.
This commit is contained in:
Pranav
2025-03-12 19:31:28 -07:00
committed by GitHub
parent ae694da6c1
commit cc4924db55
4 changed files with 43 additions and 77 deletions

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import BaseBubble from 'next/message/bubbles/Base.vue'; import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from './FormattedContent.vue'; import FormattedContent from './FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
@@ -9,6 +9,26 @@ import { useMessageContext } from '../../provider.js';
const { content, attachments, contentAttributes, messageType } = const { content, attachments, contentAttributes, messageType } =
useMessageContext(); useMessageContext();
const hasTranslations = computed(() => {
const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const renderOriginal = ref(false);
const renderContent = computed(() => {
if (renderOriginal.value) {
return content.value;
}
if (hasTranslations.value) {
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
}
return content.value;
});
const isTemplate = computed(() => { const isTemplate = computed(() => {
return messageType.value === MESSAGE_TYPES.TEMPLATE; return messageType.value === MESSAGE_TYPES.TEMPLATE;
}); });
@@ -16,6 +36,16 @@ const isTemplate = computed(() => {
const isEmpty = computed(() => { const isEmpty = computed(() => {
return !content.value && !attachments.value?.length; return !content.value && !attachments.value?.length;
}); });
const viewToggleKey = computed(() => {
return renderOriginal.value
? 'CONVERSATION.VIEW_TRANSLATED'
: 'CONVERSATION.VIEW_ORIGINAL';
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
</script> </script>
<template> <template>
@@ -24,7 +54,16 @@ const isEmpty = computed(() => {
<span v-if="isEmpty" class="text-n-slate-11"> <span v-if="isEmpty" class="text-n-slate-11">
{{ $t('CONVERSATION.NO_CONTENT') }} {{ $t('CONVERSATION.NO_CONTENT') }}
</span> </span>
<FormattedContent v-if="content" :content="content" /> <FormattedContent v-if="renderContent" :content="renderContent" />
<span class="-mt-3">
<span
v-if="hasTranslations"
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
@click="handleSeeOriginal"
>
{{ $t(viewToggleKey) }}
</span>
</span>
<AttachmentChips :attachments="attachments" class="gap-2" /> <AttachmentChips :attachments="attachments" class="gap-2" />
<template v-if="isTemplate"> <template v-if="isTemplate">
<div <div

View File

@@ -1,61 +0,0 @@
<script>
export default {
props: {
contentAttributes: {
type: Object,
default: () => ({}),
},
content: {
type: String,
default: '',
},
},
emits: ['close'],
computed: {
translationsAvailable() {
return !!Object.keys(this.translations).length;
},
translations() {
return this.contentAttributes.translations || {};
},
},
methods: {
onClose() {
this.$emit('close');
},
},
};
</script>
<template>
<woot-modal
modal-type="right-aligned"
class="text-left"
show
:on-close="onClose"
>
<div class="content">
<p>
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
</p>
<p v-dompurify-html="content" class="mb-0" />
<br />
<hr />
<div v-if="translationsAvailable">
<p>
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
</p>
<div v-for="(translation, language) in translations" :key="language">
<p>
<strong>{{ language }}:</strong>
</p>
<p v-dompurify-html="translation" />
<br />
</div>
</div>
<p v-else>
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
</p>
</div>
</woot-modal>
</template>

View File

@@ -12,6 +12,8 @@
"NO_INBOX_2": " to get started", "NO_INBOX_2": " to get started",
"NO_INBOX_AGENT": "Uh Oh! Looks like you are not part of any inbox. Please contact your administrator", "NO_INBOX_AGENT": "Uh Oh! Looks like you are not part of any inbox. Please contact your administrator",
"SEARCH_MESSAGES": "Search for messages in conversations", "SEARCH_MESSAGES": "Search for messages in conversations",
"VIEW_ORIGINAL": "View original",
"VIEW_TRANSLATED": "View translated",
"EMPTY_STATE": { "EMPTY_STATE": {
"CMD_BAR": "to open command menu", "CMD_BAR": "to open command menu",
"KEYBOARD_SHORTCUTS": "to view keyboard shortcuts" "KEYBOARD_SHORTCUTS": "to view keyboard shortcuts"

View File

@@ -11,14 +11,12 @@ import {
ACCOUNT_EVENTS, ACCOUNT_EVENTS,
CONVERSATION_EVENTS, CONVERSATION_EVENTS,
} from '../../../helper/AnalyticsHelper/events'; } from '../../../helper/AnalyticsHelper/events';
import TranslateModal from 'dashboard/components/widgets/conversation/bubble/TranslateModal.vue';
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';
export default { export default {
components: { components: {
AddCannedModal, AddCannedModal,
TranslateModal,
MenuItem, MenuItem,
ContextMenu, ContextMenu,
}, },
@@ -54,7 +52,6 @@ export default {
data() { data() {
return { return {
isCannedResponseModalOpen: false, isCannedResponseModalOpen: false,
showTranslateModal: false,
showDeleteModal: false, showDeleteModal: false,
}; };
}, },
@@ -125,15 +122,11 @@ export default {
}); });
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE); useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
this.handleClose(); this.handleClose();
this.showTranslateModal = true;
}, },
handleReplyTo() { handleReplyTo() {
this.$emit('replyTo', this.message); this.$emit('replyTo', this.message);
this.handleClose(); this.handleClose();
}, },
onCloseTranslateModal() {
this.showTranslateModal = false;
},
openDeleteModal() { openDeleteModal() {
this.handleClose(); this.handleClose();
this.showDeleteModal = true; this.showDeleteModal = true;
@@ -170,13 +163,6 @@ export default {
:on-close="hideCannedResponseModal" :on-close="hideCannedResponseModal"
/> />
</woot-modal> </woot-modal>
<!-- Translate Content -->
<TranslateModal
v-if="showTranslateModal"
:content="messageContent"
:content-attributes="contentAttributes"
@close="onCloseTranslateModal"
/>
<!-- Confirm Deletion --> <!-- Confirm Deletion -->
<woot-delete-modal <woot-delete-modal
v-if="showDeleteModal" v-if="showDeleteModal"