diff --git a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue index 562db8d8..9c17f797 100644 --- a/frontend/apps/main/src/features/conversation/message/MessageBubble.vue +++ b/frontend/apps/main/src/features/conversation/message/MessageBubble.vue @@ -77,7 +77,7 @@ :html="sanitizedContent" :allowedSchemas="['cid', 'https', 'http', 'mailto']" :allowed-css-properties="extendedCssProperties" - class="mb-1 native-html whitespace-pre-wrap break-words" + class="mb-1 native-html break-words" :class="{ 'mb-3': message.attachments.length > 0 }" /> @@ -205,6 +205,7 @@ import BubbleAttachmentPreview from '@main/features/conversation/message/attachm import MessageEnvelope from './MessageEnvelope.vue' import CSATResponseDisplay from './CSATResponseDisplay.vue' import api from '@main/api' +import { containsQuoteMarkers } from '@shared-ui/utils/quotedContent.js' const extendedCssProperties = [...allowedCssProperties, 'transform', 'transform-origin'] @@ -305,7 +306,7 @@ const retryMessage = (msg) => { const showQuotedText = ref(false) const hasQuotedContent = computed( - () => !isOutgoing.value && sanitizedContent.value.includes('
!isOutgoing.value && containsQuoteMarkers(sanitizedContent.value) ) const toggleQuote = () => { showQuotedText.value = !showQuotedText.value diff --git a/frontend/apps/main/src/utils/quotedContent.test.js b/frontend/apps/main/src/utils/quotedContent.test.js new file mode 100644 index 00000000..42984fe9 --- /dev/null +++ b/frontend/apps/main/src/utils/quotedContent.test.js @@ -0,0 +1,63 @@ +import { describe, test, expect } from 'vitest' +import { containsQuoteMarkers } from '@shared-ui/utils/quotedContent.js' + +describe('containsQuoteMarkers', () => { + test('returns false for null', () => { + expect(containsQuoteMarkers(null)).toBe(false) + }) + + test('returns false for undefined', () => { + expect(containsQuoteMarkers(undefined)).toBe(false) + }) + + test('returns false for empty string', () => { + expect(containsQuoteMarkers('')).toBe(false) + }) + + test('returns false for plain HTML with no quote', () => { + expect(containsQuoteMarkers('Hello world
thanks')).toBe(false) + }) + + test('detects(Gmail/Apple/Thunderbird)', () => { + expect(containsQuoteMarkers('previous')).toBe(true) + }) + + test('detectswith attributes', () => { + expect(containsQuoteMarkers('x')) + .toBe(true) + }) + + test('detects Outlook Web appendonsend marker', () => { + expect(containsQuoteMarkers('')).toBe(true) + }) + + test('detects Outlook divRplyFwdMsg marker', () => { + expect(containsQuoteMarkers('From:')).toBe(true) + }) + + test('detects Outlook for Mac OLK_SRC_BODY_SECTION marker', () => { + expect(containsQuoteMarkers('body')).toBe(true) + }) + + test('detects legacy Outlook OutlookMessageHeader class', () => { + expect(containsQuoteMarkers('x')).toBe(true) + }) + + test('detects real Hotmail reply payload', () => { + const html = `Quoted reply!+ +
+From: Libredesk + Sent: 17 May 2026 18:12+Original message body` + expect(containsQuoteMarkers(html)).toBe(true) + }) + + test('does not match marker word in plain prose', () => { + expect(containsQuoteMarkers('I love the appendonsend feature in Outlook')).toBe(false) + }) + + test('does not match if id uses single quotes (edge case - Outlook always emits double)', () => { + expect(containsQuoteMarkers(``)).toBe(false) + }) +}) diff --git a/frontend/apps/widget/src/components/ChatMessages.vue b/frontend/apps/widget/src/components/ChatMessages.vue index 4c80e1f7..b8b249b4 100644 --- a/frontend/apps/widget/src/components/ChatMessages.vue +++ b/frontend/apps/widget/src/components/ChatMessages.vue @@ -160,6 +160,7 @@ import MessageAttachment from './MessageAttachment.vue' import CSATMessageBubble from './CSATMessageBubble.vue' import { TypingIndicator } from '@shared-ui/components/TypingIndicator' import { Spinner } from '@shared-ui/components/ui/spinner' +import { containsQuoteMarkers } from '@shared-ui/utils/quotedContent.js' const extendedCssProperties = [...allowedCssProperties, 'transform', 'transform-origin'] @@ -187,9 +188,7 @@ const getMessageTime = (timestamp) => { return useRelativeTime(new Date(timestamp)).value } -const hasQuotedContent = (content) => { - return content && content.includes('containsQuoteMarkers(content) const isQuotedTextVisible = (messageUuid) => { return quotedTextState.value[messageUuid] || false diff --git a/frontend/shared-ui/assets/styles/main.scss b/frontend/shared-ui/assets/styles/main.scss index b0aa1364..e59622c9 100644 --- a/frontend/shared-ui/assets/styles/main.scss +++ b/frontend/shared-ui/assets/styles/main.scss @@ -296,7 +296,21 @@ } .hide-quoted-text { - blockquote { + // Selectors below use attribute-suffix matching because vue-letter + // prefixes every id/class with a random "msg_XYZ_" at render time, so + // raw "#appendonsend" would never match. + // Outlook/Hotmail emit a marker with the quoted history as trailing + // siblings (no wrapping element), so we hide the marker AND everything + // after it. Outlook always top-posts, so trailing siblings are quote. + blockquote, + [id$="_divRplyFwdMsg"], + [id$="_divRplyFwdMsg"] ~ *, + [id$="_appendonsend"], + [id$="_appendonsend"] ~ *, + [id$="_OLK_SRC_BODY_SECTION"], + [id$="_OLK_SRC_BODY_SECTION"] ~ *, + [class*="_OutlookMessageHeader"], + [class*="_OutlookMessageHeader"] ~ * { @apply hidden; } } diff --git a/frontend/shared-ui/utils/quotedContent.js b/frontend/shared-ui/utils/quotedContent.js new file mode 100644 index 00000000..9ca51c93 --- /dev/null +++ b/frontend/shared-ui/utils/quotedContent.js @@ -0,0 +1,19 @@ +// Runs against the RAW HTML stored on the message - before vue-letter +// renders. vue-letter prefixes every id/class with a random "msg_XYZ_" at +// render time, so any CSS that needs to match the rendered DOM must use +// attribute-suffix selectors (see .hide-quoted-text in main.scss). +// +// Scope:covers Gmail/Apple/Thunderbird. The id/class markers +// below cover the Microsoft variants (Outlook desktop/web/mac, Hotmail). +export const QUOTE_MARKERS = [ + '{ + if (!html) return false + return QUOTE_MARKERS.some((marker) => html.includes(marker)) +}