diff --git a/components/email/ComposeEmail.tsx b/components/email/ComposeEmail.tsx index 23309c23..c127fe52 100644 --- a/components/email/ComposeEmail.tsx +++ b/components/email/ComposeEmail.tsx @@ -15,6 +15,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import RichTextEditor from '@/components/ui/rich-text-editor'; +import { detectTextDirection } from '@/lib/utils/text-direction'; // Import from the centralized utils import { @@ -346,12 +348,13 @@ export default function ComposeEmail(props: ComposeEmailProps) { {/* Message Body */} -
]*>[\s\S]*?<\/blockquote>/gi, + '[Quoted text hidden]'); + } + return htmlToDisplay; + }, [htmlToDisplay, showQuotedText]); - // Simple content rendering - if (content.isHtml && content.html) { - // Use HTML content - htmlContent = content.html; - } else if (content.text) { - // Format text content with line breaks - htmlContent = content.text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
'); - } else { - // No content available - htmlContent = 'No content available'; - } + // Sanitize HTML content before rendering + const sanitizedHTML = useMemo(() => { + return DOMPurify.sanitize(processedHTML); + }, [processedHTML]); return ( -+{/* Debug output if enabled */} {debug && (); diff --git a/components/ui/rich-text-editor.tsx b/components/ui/rich-text-editor.tsx new file mode 100644 index 00000000..6b2c755a --- /dev/null +++ b/components/ui/rich-text-editor.tsx @@ -0,0 +1,171 @@ +'use client'; + +import React, { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react'; +import { detectTextDirection } from '@/lib/utils/text-direction'; +import DOMPurify from 'isomorphic-dompurify'; + +interface RichTextEditorProps { + /** Initial HTML content */ + initialContent?: string; + + /** Callback when content changes */ + onChange?: (html: string) => void; + + /** Additional CSS class names */ + className?: string; + + /** Editor placeholder text */ + placeholder?: string; + + /** Whether the editor is read-only */ + readOnly?: boolean; + + /** Minimum height of the editor */ + minHeight?: string; + + /** Initial text direction */ + initialDirection?: 'ltr' | 'rtl'; +} + +/** + * Unified rich text editor component with proper RTL support + * Handles email composition with appropriate text direction detection + */ +const RichTextEditor = forwardRef-)} @@ -66,10 +121,30 @@ const EmailContentDisplay: React.FCContent Type: {content.isHtml ? 'HTML' : 'Text'}
-Direction: {content.direction}
-HTML Length: {content.html?.length || 0}
-Text Length: {content.text?.length || 0}
+Content Type: {safeContent.isHtml ? 'HTML' : 'Text'}
+Direction: {safeContent.direction}
+HTML Length: {safeContent.html?.length || 0}
+Text Length: {safeContent.text?.length || 0}
= ({ width: 100%; } + .email-content-display[dir="rtl"] { + text-align: right; + } + .email-content-inner img { max-width: 100%; height: auto; } + + .email-content-inner blockquote { + margin: 10px 0; + padding-left: 15px; + border-left: 2px solid #ddd; + color: #666; + background-color: #f9f9f9; + border-radius: 4px; + } + + .email-content-display[dir="rtl"] .email-content-inner blockquote { + padding-left: 0; + padding-right: 15px; + border-left: none; + border-right: 2px solid #ddd; + } `} (({ + initialContent = '', + onChange, + className = '', + placeholder = 'Write your message...', + readOnly = false, + minHeight = '200px', + initialDirection +}, ref) => { + const internalEditorRef = useRef (null); + const [direction, setDirection] = useState<'ltr' | 'rtl'>( + initialDirection || detectTextDirection(initialContent) + ); + + // Forward the ref to parent components + useImperativeHandle(ref, () => internalEditorRef.current as HTMLDivElement); + + // Initialize editor with clean content + useEffect(() => { + if (internalEditorRef.current) { + // Clean the initial content + const cleanContent = DOMPurify.sanitize(initialContent); + internalEditorRef.current.innerHTML = cleanContent; + + // Set initial direction + internalEditorRef.current.setAttribute('dir', direction); + + // Focus editor if not read-only + if (!readOnly) { + setTimeout(() => { + internalEditorRef.current?.focus(); + }, 100); + } + } + }, [initialContent, direction, readOnly]); + + // Handle content changes and detect direction changes + const handleInput = (e: React.FormEvent ) => { + if (onChange && e.currentTarget.innerHTML !== initialContent) { + onChange(e.currentTarget.innerHTML); + } + + // Re-detect direction on significant content changes + // Only do this when the content length has changed significantly + const newContent = e.currentTarget.innerText; + if (newContent.length > 5 && newContent.length % 10 === 0) { + const newDirection = detectTextDirection(newContent); + if (newDirection !== direction) { + setDirection(newDirection); + e.currentTarget.setAttribute('dir', newDirection); + } + } + }; + + // Toggle direction manually + const toggleDirection = () => { + const newDirection = direction === 'ltr' ? 'rtl' : 'ltr'; + setDirection(newDirection); + if (internalEditorRef.current) { + internalEditorRef.current.setAttribute('dir', newDirection); + } + }; + + return ( + + {!readOnly && ( ++ ); +}); + +RichTextEditor.displayName = 'RichTextEditor'; + +export default RichTextEditor; \ No newline at end of file diff --git a/hooks/use-email-fetch.ts b/hooks/use-email-fetch.ts index 214a0c79..5e9c96e0 100644 --- a/hooks/use-email-fetch.ts +++ b/hooks/use-email-fetch.ts @@ -1,6 +1,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useToast } from './use-toast'; import { EmailMessage, EmailContent } from '@/types/email'; +import { detectTextDirection } from '@/lib/utils/text-direction'; +import { sanitizeHtml } from '@/lib/utils/email-utils'; interface EmailFetchState { email: EmailMessage | null; @@ -73,6 +75,50 @@ export function useEmailFetch({ onEmailLoaded, onError }: UseEmailFetchProps = { const data = await response.json(); // Create a valid email message object with required fields + const processContent = (data: any): EmailContent => { + // Determine the text content - using all possible paths + let textContent = ''; + if (typeof data.content === 'string') { + textContent = data.content; + } else if (data.content?.text) { + textContent = data.content.text; + } else if (data.text) { + textContent = data.text; + } else if (data.plainText) { + textContent = data.plainText; + } + + // Determine the HTML content - using all possible paths + let htmlContent = undefined; + if (data.content?.html) { + htmlContent = data.content.html; + } else if (data.html) { + htmlContent = data.html; + } else if (typeof data.content === 'string' && data.content.includes('<')) { + // If the content string appears to be HTML + htmlContent = data.content; + // We should still keep the text version, will be extracted if needed + } + + // Clean HTML content if present + if (htmlContent) { + htmlContent = sanitizeHtml(htmlContent); + } + + // Determine if content is HTML + const isHtml = !!htmlContent; + + // Detect text direction + const direction = data.content?.direction || detectTextDirection(textContent); + + return { + text: textContent, + html: htmlContent, + isHtml, + direction + }; + }; + const transformedEmail: EmailMessage = { id: data.id || emailId, subject: data.subject || '', @@ -82,14 +128,7 @@ export function useEmailFetch({ onEmailLoaded, onError }: UseEmailFetchProps = { bcc: data.bcc, date: data.date || new Date().toISOString(), flags: Array.isArray(data.flags) ? data.flags : [], - content: { - text: typeof data.content === 'string' ? data.content : - data.content?.text || data.text || '', - html: data.content?.html || data.html || undefined, - isHtml: !!(data.content?.html || data.html || - (typeof data.content === 'string' && data.content.includes('<'))), - direction: data.content?.direction || 'ltr' - }, + content: processContent(data), attachments: data.attachments }; diff --git a/lib/utils/email-utils.ts b/lib/utils/email-utils.ts index bb98f63e..69db2e3e 100644 --- a/lib/utils/email-utils.ts +++ b/lib/utils/email-utils.ts @@ -17,6 +17,7 @@ import { } from '@/types/email'; import { adaptLegacyEmail } from '@/lib/utils/email-adapters'; import { decodeInfomaniakEmail, adaptMimeEmail, isMimeFormat } from './email-mime-decoder'; +import { detectTextDirection } from '@/lib/utils/text-direction'; // Reset any existing hooks to start clean DOMPurify.removeAllHooks(); @@ -38,15 +39,6 @@ DOMPurify.setConfig({ ALLOWED_ATTR: ['style', 'class', 'id', 'dir'] }); -/** - * Detect if text contains RTL characters - */ -export function detectTextDirection(text: string): 'ltr' | 'rtl' { - // Pattern for RTL characters (Arabic, Hebrew, etc.) - const rtlLangPattern = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/; - return rtlLangPattern.test(text) ? 'rtl' : 'ltr'; -} - /** * Format email addresses for display * Can handle both array of EmailAddress objects or a string @@ -224,10 +216,18 @@ export function renderEmailContent(content: EmailContent): string { return `+ ++ )} + + + + +${formattedText}`; } +// Add interface for email formatting functions +interface FormattedEmail { + to: string; + cc?: string; + subject: string; + content: EmailContent; +} + /** * Format email for reply */ -export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all' = 'reply') { +export function formatReplyEmail(originalEmail: EmailMessage | LegacyEmailMessage | null, type: 'reply' | 'reply-all' = 'reply'): FormattedEmail { if (!originalEmail) { return { to: '', @@ -244,7 +244,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all' // Format the recipients const to = Array.isArray(originalEmail.from) - ? originalEmail.from.map(addr => { + ? originalEmail.from.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.address ? addr.address : ''; }).filter(Boolean).join(', ') @@ -256,7 +256,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all' let cc = ''; if (type === 'reply-all') { const toRecipients = Array.isArray(originalEmail.to) - ? originalEmail.to.map(addr => { + ? originalEmail.to.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.address ? addr.address : ''; }).filter(Boolean) @@ -265,7 +265,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all' : []; const ccRecipients = Array.isArray(originalEmail.cc) - ? originalEmail.cc.map(addr => { + ? originalEmail.cc.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.address ? addr.address : ''; }).filter(Boolean) @@ -286,7 +286,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all' const dateStr = originalDate.toLocaleString(); const fromStr = Array.isArray(originalEmail.from) - ? originalEmail.from.map(addr => { + ? originalEmail.from.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.name ? `${addr.name} <${addr.address}>` : addr.address; }).join(', ') @@ -295,7 +295,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all' : 'Unknown Sender'; const toStr = Array.isArray(originalEmail.to) - ? originalEmail.to.map(addr => { + ? originalEmail.to.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.name ? `${addr.name} <${addr.address}>` : addr.address; }).join(', ') @@ -303,24 +303,40 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all' ? originalEmail.to : ''; - // Create HTML content + // Extract original content + const originalTextContent = + originalEmail.content?.text || + (typeof originalEmail.content === 'string' ? originalEmail.content : ''); + + const originalHtmlContent = + originalEmail.content?.html || + originalEmail.html || + (typeof originalEmail.content === 'string' && originalEmail.content.includes('<') + ? originalEmail.content + : ''); + + // Get the direction from the original email + const originalDirection = + originalEmail.content?.direction || + (originalTextContent ? detectTextDirection(originalTextContent) : 'ltr'); + + // Create HTML content that preserves the directionality const htmlContent = `
-+`; // Create plain text content - const plainText = originalEmail.content?.text || ''; const textContent = ` On ${dateStr}, ${fromStr} wrote: -> ${plainText.split('\n').join('\n> ')} +> ${originalTextContent.split('\n').join('\n> ')} `; return { @@ -331,7 +347,7 @@ On ${dateStr}, ${fromStr} wrote: text: textContent, html: htmlContent, isHtml: true, - direction: 'ltr' as const + direction: 'ltr' as const // Reply is LTR, but original content keeps its direction in the blockquote } }; } @@ -339,7 +355,7 @@ On ${dateStr}, ${fromStr} wrote: /** * Format email for forwarding */ -export function formatForwardedEmail(originalEmail: any) { +export function formatForwardedEmail(originalEmail: EmailMessage | LegacyEmailMessage | null): FormattedEmail { if (!originalEmail) { return { to: '', @@ -360,7 +376,7 @@ export function formatForwardedEmail(originalEmail: any) { // Format from, to, cc for the header const fromStr = Array.isArray(originalEmail.from) - ? originalEmail.from.map(addr => { + ? originalEmail.from.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.name ? `${addr.name} <${addr.address}>` : addr.address; }).join(', ') @@ -369,7 +385,7 @@ export function formatForwardedEmail(originalEmail: any) { : 'Unknown Sender'; const toStr = Array.isArray(originalEmail.to) - ? originalEmail.to.map(addr => { + ? originalEmail.to.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.name ? `${addr.name} <${addr.address}>` : addr.address; }).join(', ') @@ -378,7 +394,7 @@ export function formatForwardedEmail(originalEmail: any) { : ''; const ccStr = Array.isArray(originalEmail.cc) - ? originalEmail.cc.map(addr => { + ? originalEmail.cc.map((addr: any) => { if (typeof addr === 'string') return addr; return addr.name ? `${addr.name} <${addr.address}>` : addr.address; }).join(', ') @@ -388,19 +404,36 @@ export function formatForwardedEmail(originalEmail: any) { const dateStr = originalEmail.date ? new Date(originalEmail.date).toLocaleString() : 'Unknown Date'; - // Create HTML content + // Extract original content + const originalTextContent = + originalEmail.content?.text || + (typeof originalEmail.content === 'string' ? originalEmail.content : ''); + + const originalHtmlContent = + originalEmail.content?.html || + originalEmail.html || + (typeof originalEmail.content === 'string' && originalEmail.content.includes('<') + ? originalEmail.content + : ''); + + // Get the direction from the original email + const originalDirection = + originalEmail.content?.direction || + (originalTextContent ? detectTextDirection(originalTextContent) : 'ltr'); + + // Create HTML content that preserves the directionality const htmlContent = `On ${dateStr}, ${fromStr} wrote:
- ${originalEmail.content?.html || originalEmail.content?.text || ''} + ${originalHtmlContent || originalTextContent.replace(/\n/g, '
')}
-+---------- Forwarded message ---------
From: ${fromStr}
Date: ${dateStr}
Subject: ${originalEmail.subject || ''}
To: ${toStr}
${ccStr ? `Cc: ${ccStr}
` : ''} -- ${originalEmail.content?.html || originalEmail.content?.text || ''} +`; @@ -415,7 +448,7 @@ Subject: ${originalEmail.subject || ''} To: ${toStr} ${ccStr ? `Cc: ${ccStr}\n` : ''} -${originalEmail.content?.text || ''} +${originalTextContent} `; return { @@ -425,7 +458,7 @@ ${originalEmail.content?.text || ''} text: textContent, html: htmlContent, isHtml: true, - direction: 'ltr' as const + direction: 'ltr' as const // Forward is LTR, but original content keeps its direction } }; } diff --git a/lib/utils/text-direction.ts b/lib/utils/text-direction.ts new file mode 100644 index 00000000..ede73ccb --- /dev/null +++ b/lib/utils/text-direction.ts @@ -0,0 +1,64 @@ +/** + * Text Direction Utilities + * + * Centralized utilities for handling text direction (RTL/LTR) + * to ensure consistent behavior across the application. + */ + +/** + * Detects if text contains RTL characters and should be displayed right-to-left + * Uses a comprehensive regex pattern that covers Arabic, Hebrew, and other RTL scripts + * + * @param text Text to analyze for direction + * @returns 'rtl' if RTL characters are detected, otherwise 'ltr' + */ +export function detectTextDirection(text: string | undefined | null): 'ltr' | 'rtl' { + if (!text) return 'ltr'; + + // Comprehensive pattern for RTL languages: + // - Arabic (0600-06FF, FB50-FDFF, FE70-FEFF) + // - Hebrew (0590-05FF, FB1D-FB4F) + // - RTL marks and controls (200F, 202B, 202E) + const rtlPattern = /[\u0591-\u07FF\u200F\u202B\u202E\uFB1D-\uFDFD\uFE70-\uFEFC]/; + + return rtlPattern.test(text) ? 'rtl' : 'ltr'; +} + +/** + * Adds appropriate direction attribute to HTML content based on content analysis + * + * @param htmlContent HTML content to analyze and enhance with direction + * @param textContent Plain text version for direction analysis (optional) + * @returns HTML with appropriate direction attribute + */ +export function applyTextDirection(htmlContent: string, textContent?: string): string { + if (!htmlContent) return ''; + + // If text content is provided, use it for direction detection + // Otherwise extract text from HTML for direction detection + const textForAnalysis = textContent || + htmlContent.replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); + + const direction = detectTextDirection(textForAnalysis); + + // If the HTML already has a dir attribute, don't override it + if (htmlContent.includes('dir="rtl"') || htmlContent.includes('dir="ltr"')) { + return htmlContent; + } + + // Check if we already have an email-content wrapper + if (htmlContent.startsWith('+ ${originalHtmlContent || originalTextContent.replace(/\n/g, '
')}${htmlContent}`; +} \ No newline at end of file diff --git a/types/email.ts b/types/email.ts index f4ea9273..26a7d734 100644 --- a/types/email.ts +++ b/types/email.ts @@ -19,10 +19,24 @@ export interface EmailAttachment { contentId?: string; } +/** + * Standard email content structure used throughout the application + * Ensures consistent handling of HTML/text content and text direction + */ export interface EmailContent { + /** Plain text version of the content (always required) */ text: string; + + /** HTML version of the content (optional) */ html?: string; + + /** Whether the primary display format should be HTML */ isHtml: boolean; + + /** + * Text direction - 'rtl' for right-to-left languages (Arabic, Hebrew, etc.) + * or 'ltr' for left-to-right languages (default) + */ direction: 'ltr' | 'rtl'; }