Files
chatwoot/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue
Sivin Varghese 72509f9e38 chore: Improve translation service with HTML and plain text support (#11305)
# Pull Request Template

## Description

This PR changes to translation to properly handle different content
types during translation.

### Changes

1. **Email translation with HTML support**
   - Properly detects and preserves HTML content from emails
   - Sets `mime_type` to 'text/html' when HTML content is present

2. **Email translation with plain text support**
   - Falls back to email text content when HTML is not available
- Sets `mime_type` to 'text/plain' when HTML is not available and
content type includes 'text/plain'

3. **Plain message with plain text support (Non email channels)**
   - Sets `mime_type` to 'text/plain' for non-email channels
- Fixes an issue where Markdown formatting was being lost due to
incorrect `mime_type`
   
**Note**: Translation for very long emails is not currently supported.

Fixes
https://linear.app/chatwoot/issue/CW-4244/translate-button-doesnt-work-in-email-channels

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Loom video**

https://www.loom.com/share/8f8428ed2cfe415ea5cb6c547c070f00?sid=eab9fa11-05f8-4838-9181-334bee1023c4


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-04-16 17:59:06 +05:30

216 lines
6.5 KiB
Vue

<script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { Letter } from 'vue-letter';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
import BaseBubble from 'next/message/bubbles/Base.vue';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
import { useMessageContext } from '../../provider.js';
import { MESSAGE_TYPES } from 'next/message/constants.js';
import { useTranslations } from 'dashboard/composables/useTranslations';
const { content, contentAttributes, attachments, messageType } =
useMessageContext();
const isExpandable = ref(false);
const isExpanded = ref(false);
const showQuotedMessage = ref(false);
const renderOriginal = ref(false);
const contentContainer = useTemplateRef('contentContainer');
onMounted(() => {
isExpandable.value = contentContainer.value?.scrollHeight > 400;
});
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isIncoming = computed(() => !isOutgoing.value);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
const originalEmailText = computed(() => {
const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>');
});
const originalEmailHtml = computed(
() =>
contentAttributes?.value?.email?.htmlContent?.full ??
originalEmailText.value
);
const messageContent = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original content
return content.value;
});
const textToShow = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original text
return originalEmailText.value;
});
const fullHTML = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
return translationContent.value;
}
// Otherwise show original HTML
return originalEmailHtml.value;
});
const unquotedHTML = computed(() =>
EmailQuoteExtractor.extractQuotes(fullHTML.value)
);
const hasQuotedMessage = computed(() =>
EmailQuoteExtractor.hasQuotes(fullHTML.value)
);
// Ensure unique keys for <Letter> when toggling between original and translated views.
// This forces Vue to re-render the component and update content correctly.
const translationKeySuffix = computed(() => {
if (renderOriginal.value) return 'original';
if (hasTranslations.value) return 'translated';
return 'original';
});
const handleSeeOriginal = () => {
renderOriginal.value = !renderOriginal.value;
};
</script>
<template>
<BaseBubble
class="w-full"
:class="{
'bg-n-slate-4': isIncoming,
'bg-n-solid-blue': isOutgoing,
}"
data-bubble-name="email"
>
<EmailMeta
class="p-3"
:class="{
'border-b border-n-strong': isIncoming,
'border-b border-n-slate-8/20': isOutgoing,
}"
/>
<section ref="contentContainer" class="p-3">
<div
:class="{
'max-h-[400px] overflow-hidden relative': !isExpanded && isExpandable,
'overflow-y-scroll relative': isExpanded,
}"
>
<div
v-if="isExpandable && !isExpanded"
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent"
>
<button
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
@click="isExpanded = true"
>
<Icon icon="i-lucide-maximize-2" />
{{ $t('EMAIL_HEADER.EXPAND') }}
</button>
</div>
<FormattedContent
v-if="isOutgoing && content"
class="text-n-slate-12"
:content="messageContent"
/>
<template v-else>
<Letter
v-if="showQuotedMessage"
:key="`letter-quoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:html="fullHTML"
:text="textToShow"
/>
<Letter
v-else
:key="`letter-unquoted-${translationKeySuffix}`"
class-name="prose prose-bubble !max-w-none letter-render"
:html="unquotedHTML"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:text="textToShow"
/>
</template>
<button
v-if="hasQuotedMessage"
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
@click="showQuotedMessage = !showQuotedMessage"
>
<template v-if="showQuotedMessage">
{{ $t('CHAT_LIST.HIDE_QUOTED_TEXT') }}
</template>
<template v-else>
{{ $t('CHAT_LIST.SHOW_QUOTED_TEXT') }}
</template>
<Icon
:icon="
showQuotedMessage
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
/>
</button>
</div>
</section>
<TranslationToggle
v-if="hasTranslations"
class="py-2 px-3"
:showing-original="renderOriginal"
@toggle="handleSeeOriginal"
/>
<section
v-if="Array.isArray(attachments) && attachments.length"
class="px-4 pb-4 space-y-2"
>
<AttachmentChips :attachments="attachments" class="gap-1" />
</section>
</BaseBubble>
</template>
<style lang="scss">
// Tailwind resets break the rendering of google drive link in Gmail messages
// This fixes it using https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors
.letter-render [class*='gmail_drive_chip'] {
box-sizing: initial;
@apply bg-n-slate-4 border-n-slate-6 rounded-md !important;
a {
@apply text-n-slate-12 !important;
img {
display: inline-block;
}
}
}
</style>