Merge branch 'develop' into feat/ui-lib
This commit is contained in:
@@ -10,6 +10,10 @@ import { useI18n } from 'vue-i18n';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
|
||||
const props = defineProps({
|
||||
hideMeta: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
useMessageContext();
|
||||
const { t } = useI18n();
|
||||
@@ -64,6 +68,13 @@ const scrollToMessage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowMeta = computed(
|
||||
() =>
|
||||
!props.hideMeta &&
|
||||
!shouldGroupWithNext.value &&
|
||||
variant.value !== MESSAGE_VARIANTS.ACTIVITY
|
||||
);
|
||||
|
||||
const replyToPreview = computed(() => {
|
||||
if (!inReplyTo) return '';
|
||||
|
||||
@@ -93,16 +104,16 @@ const replyToPreview = computed(() => {
|
||||
>
|
||||
<div
|
||||
v-if="inReplyTo"
|
||||
class="bg-n-alpha-black1 rounded-lg p-2 -mx-1 mb-2 cursor-pointer"
|
||||
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<span class="line-clamp-2 break-all">
|
||||
<span class="break-all line-clamp-2">
|
||||
{{ replyToPreview }}
|
||||
</span>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
v-if="!shouldGroupWithNext && variant !== MESSAGE_VARIANTS.ACTIVITY"
|
||||
v-if="shouldShowMeta"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef, ref, onMounted } from 'vue';
|
||||
import { Letter } from 'vue-letter';
|
||||
import { sanitizeTextForRender } from '@chatwoot/utils';
|
||||
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 { EmailQuoteExtractor } from 'dashboard/helper/emailQuoteExtractor.js';
|
||||
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import EmailMeta from './EmailMeta.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
@@ -37,15 +38,22 @@ const { hasTranslations, translationContent } =
|
||||
const originalEmailText = computed(() => {
|
||||
const text =
|
||||
contentAttributes?.value?.email?.textContent?.full ?? content.value;
|
||||
return text?.replace(/\n/g, '<br>');
|
||||
return sanitizeTextForRender(text);
|
||||
});
|
||||
|
||||
const originalEmailHtml = computed(
|
||||
() =>
|
||||
contentAttributes?.value?.email?.htmlContent?.full ??
|
||||
contentAttributes?.value?.email?.htmlContent?.full ||
|
||||
originalEmailText.value
|
||||
);
|
||||
|
||||
const hasEmailContent = computed(() => {
|
||||
return (
|
||||
contentAttributes?.value?.email?.textContent?.full ||
|
||||
contentAttributes?.value?.email?.htmlContent?.full
|
||||
);
|
||||
});
|
||||
|
||||
const messageContent = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
@@ -136,7 +144,7 @@ const handleSeeOriginal = () => {
|
||||
</button>
|
||||
</div>
|
||||
<FormattedContent
|
||||
v-if="isOutgoing && content"
|
||||
v-if="isOutgoing && content && !hasEmailContent"
|
||||
class="text-n-slate-12"
|
||||
:content="messageContent"
|
||||
/>
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// Quote detection strategies
|
||||
const QUOTE_INDICATORS = [
|
||||
'.gmail_quote_container',
|
||||
'.gmail_quote',
|
||||
'.OutlookQuote',
|
||||
'.email-quote',
|
||||
'.quoted-text',
|
||||
'.quote',
|
||||
'[class*="quote"]',
|
||||
'[class*="Quote"]',
|
||||
];
|
||||
|
||||
// Regex patterns for quote identification
|
||||
const QUOTE_PATTERNS = [
|
||||
/On .* wrote:/i,
|
||||
/-----Original Message-----/i,
|
||||
/Sent: /i,
|
||||
/From: /i,
|
||||
];
|
||||
|
||||
export class EmailQuoteExtractor {
|
||||
/**
|
||||
* Remove quotes from email HTML and return cleaned HTML
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {string} HTML content with quotes removed
|
||||
*/
|
||||
static extractQuotes(htmlContent) {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
|
||||
// Remove elements matching class selectors
|
||||
QUOTE_INDICATORS.forEach(selector => {
|
||||
tempDiv.querySelectorAll(selector).forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// Remove text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
textNodeQuotes.forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HTML content contains any quotes
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {boolean} True if quotes are detected, false otherwise
|
||||
*/
|
||||
static hasQuotes(htmlContent) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const selector of QUOTE_INDICATORS) {
|
||||
if (tempDiv.querySelector(selector)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
return textNodeQuotes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find text nodes that match quote patterns
|
||||
* @param {Element} rootElement - Root element to search
|
||||
* @returns {Element[]} Array of parent block elements containing quote-like text
|
||||
*/
|
||||
static findTextNodeQuotes(rootElement) {
|
||||
const quoteBlocks = [];
|
||||
const treeWalker = document.createTreeWalker(
|
||||
rootElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
for (
|
||||
let currentNode = treeWalker.nextNode();
|
||||
currentNode !== null;
|
||||
currentNode = treeWalker.nextNode()
|
||||
) {
|
||||
const isQuoteLike = QUOTE_PATTERNS.some(pattern =>
|
||||
pattern.test(currentNode.textContent)
|
||||
);
|
||||
|
||||
if (isQuoteLike) {
|
||||
const parentBlock = this.findParentBlock(currentNode);
|
||||
if (parentBlock && !quoteBlocks.includes(parentBlock)) {
|
||||
quoteBlocks.push(parentBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return quoteBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest block-level parent element by recursively traversing up the DOM tree.
|
||||
* This method searches for common block-level elements like DIV, P, BLOCKQUOTE, and SECTION
|
||||
* that contain the text node. It's used to identify and remove entire block-level elements
|
||||
* that contain quote-like text, rather than just removing the text node itself. This ensures
|
||||
* proper structural removal of quoted content while maintaining HTML integrity.
|
||||
* @param {Node} node - Starting node to find parent
|
||||
* @returns {Element|null} Block-level parent element
|
||||
*/
|
||||
static findParentBlock(node) {
|
||||
const blockElements = ['DIV', 'P', 'BLOCKQUOTE', 'SECTION'];
|
||||
let current = node.parentElement;
|
||||
|
||||
while (current) {
|
||||
if (blockElements.includes(current.tagName)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
|
||||
const { contentAttributes } = useMessageContext();
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
|
||||
const status = computed(() => data.value?.status);
|
||||
const direction = computed(() => data.value?.call_direction);
|
||||
|
||||
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
||||
useVoiceCallStatus(status, direction);
|
||||
|
||||
const containerRingClass = computed(() => {
|
||||
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div
|
||||
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
:class="containerRingClass"
|
||||
>
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bubbleIconBg"
|
||||
>
|
||||
<span class="text-xl" :class="bubbleIconName" />
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
|
||||
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
Reference in New Issue
Block a user