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 */} -
setEmailContent(e.currentTarget.innerHTML)} + initialContent={emailContent} + initialDirection={detectTextDirection(emailContent)} + onChange={setEmailContent} + className="min-h-[320px] border rounded-md bg-white text-gray-800 flex-1" + placeholder="Write your message here..." /> {/* Attachments */} diff --git a/components/email/EmailContentDisplay.tsx b/components/email/EmailContentDisplay.tsx index 1a506738..af326c6c 100644 --- a/components/email/EmailContentDisplay.tsx +++ b/components/email/EmailContentDisplay.tsx @@ -1,7 +1,9 @@ 'use client'; -import React from 'react'; +import React, { useMemo } from 'react'; import { EmailContent } from '@/types/email'; +import { detectTextDirection } from '@/lib/utils/text-direction'; +import DOMPurify from 'isomorphic-dompurify'; interface EmailContentDisplayProps { content: EmailContent | null | undefined; @@ -13,7 +15,7 @@ interface EmailContentDisplayProps { /** * Unified component for displaying email content in a consistent way - * This handles both HTML and plain text content with proper styling + * This handles both HTML and plain text content with proper styling and RTL support */ const EmailContentDisplay: React.FC = ({ content, @@ -22,42 +24,95 @@ const EmailContentDisplay: React.FC = ({ type = 'auto', debug = false }) => { - if (!content) { - return
No content available
; - } + // Create a safe content object with fallback values for missing properties + const safeContent = useMemo(() => { + if (!content) { + return { + text: '', + html: undefined, + isHtml: false, + direction: 'ltr' as const + }; + } + return { + text: content.text || '', + html: content.html, + isHtml: content.isHtml, + // If direction is missing, detect it from the text content + direction: content.direction || detectTextDirection(content.text) + }; + }, [content]); + + // Determine what content to display based on type preference and available content + const htmlToDisplay = useMemo(() => { + // If no content is available, show a placeholder + if (!safeContent.text && !safeContent.html) { + return '
No content available
'; + } + + // If type is explicitly set to text, or we don't have HTML and auto mode + if (type === 'text' || (type === 'auto' && !safeContent.isHtml)) { + // Format plain text with line breaks for HTML display + if (safeContent.text) { + const formattedText = safeContent.text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + + return formattedText; + } + } + + // Otherwise use HTML content if available + if (safeContent.isHtml && safeContent.html) { + return safeContent.html; + } + + // Fallback to text content if there's no HTML + if (safeContent.text) { + const formattedText = safeContent.text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + + return formattedText; + } + + return '
No content available
'; + }, [safeContent, type]); - let htmlContent = ''; + // Handle quoted text display + const processedHTML = useMemo(() => { + if (!showQuotedText) { + // This is simplified - a more robust approach would parse and handle + // quoted sections more intelligently + return htmlToDisplay.replace(/]*>[\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 && (
-

Content 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}

)} @@ -66,10 +121,30 @@ const EmailContentDisplay: React.FC = ({ 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; + } `}
); 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(({ + 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 ``; } +// 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 = `

-