"use client"; import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { formatDate, formatFileSize, cn } from "@/lib/utils"; import { ArrowLeft, ChevronDown, ChevronUp, Reply, ReplyAll, Forward, Paperclip, Star, Download, Loader2, FileText, FileImage, FileVideo, FileAudio, FileArchive, File, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; interface ThreadConversationViewProps { thread: ThreadGroup; emails: Email[]; isLoading?: boolean; onBack: () => void; onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; onForward?: (email: Email) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; onMarkAsRead?: (emailId: string, read: boolean) => void; } // Helper function to get file icon based on mime type or extension const getFileIcon = (name?: string, type?: string) => { const ext = name?.split('.').pop()?.toLowerCase(); const mimeType = type?.toLowerCase(); if (mimeType?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) { return FileImage; } if (mimeType?.startsWith('video/') || ['mp4', 'avi', 'mov', 'wmv'].includes(ext || '')) { return FileVideo; } if (mimeType?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'flac'].includes(ext || '')) { return FileAudio; } if (mimeType === 'application/pdf' || ext === 'pdf') { return FileText; } if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '')) { return FileArchive; } return File; }; export function ThreadConversationView({ thread, emails, isLoading = false, onBack, onReply, onReplyAll, onForward, onDownloadAttachment, onMarkAsRead, }: ThreadConversationViewProps) { const t = useTranslations(); const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); // Track which emails are expanded (most recent by default) const [expandedIds, setExpandedIds] = useState>(new Set()); const [allowExternalContent, setAllowExternalContent] = useState>(new Set()); // Auto-expand most recent email AND all unread emails when thread opens useEffect(() => { if (emails.length > 0) { const idsToExpand = new Set(); // Always expand most recent idsToExpand.add(emails[0].id); // Also expand all unread emails emails.forEach(email => { if (!email.keywords?.$seen) { idsToExpand.add(email.id); } }); setExpandedIds(idsToExpand); } }, [emails]); const toggleExpanded = (emailId: string) => { setExpandedIds(prev => { const next = new Set(prev); if (next.has(emailId)) { next.delete(emailId); } else { next.add(emailId); } return next; }); }; const toggleAllowExternal = (emailId: string) => { setAllowExternalContent(prev => { const next = new Set(prev); next.add(emailId); return next; }); }; if (isLoading) { return (

{t("threads.loading")}

); } return (
{/* Header */}

{thread.latestEmail.subject || t("email_viewer.no_subject")}

{t("threads.messages_other", { count: emails.length })}

{/* Email Cards */}
{emails.map((email, index) => ( toggleExpanded(email.id)} onAllowExternal={() => toggleAllowExternal(email.id)} onReply={onReply ? () => onReply(email) : undefined} onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined} onForward={onForward ? () => onForward(email) : undefined} onDownloadAttachment={onDownloadAttachment} onMarkAsRead={onMarkAsRead} /> ))}
); } // Individual email card component interface EmailCardProps { email: Email; isExpanded: boolean; isLatest: boolean; allowExternal: boolean; onToggleExpanded: () => void; onAllowExternal: () => void; onReply?: () => void; onReplyAll?: () => void; onForward?: () => void; onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; onMarkAsRead?: (emailId: string, read: boolean) => void; } function EmailCard({ email, isExpanded, isLatest: _isLatest, allowExternal, onToggleExpanded, onAllowExternal, onReply, onReplyAll, onForward, onDownloadAttachment, onMarkAsRead, }: EmailCardProps) { const t = useTranslations(); const sender = email.from?.[0]; const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const [hasBlockedContent, setHasBlockedContent] = useState(false); // Mark as read when email is expanded useEffect(() => { // Only trigger if expanded, email is unread, and we have a handler if (!isExpanded || !onMarkAsRead || email.keywords?.$seen) { return; } const markAsReadDelay = useSettingsStore.getState().markAsReadDelay; // Never auto-mark if (markAsReadDelay === -1) { return; } // Instant mark if (markAsReadDelay === 0) { onMarkAsRead(email.id, true); return; } // Delayed mark const timeout = setTimeout(() => { onMarkAsRead(email.id, true); }, markAsReadDelay); return () => clearTimeout(timeout); }, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]); // Sanitize and prepare email HTML content const emailContent = useMemo(() => { if (!email) return { html: "", isHtml: false }; if (email.bodyValues) { let useHtmlVersion = false; let htmlContent = ''; if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { htmlContent = email.bodyValues[email.htmlBody[0].partId].value; const tempDiv = document.createElement('div'); tempDiv.innerHTML = htmlContent; const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote'); const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2; const hasBrTags = tempDiv.querySelectorAll('br').length > 0; useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags); } if (useHtmlVersion && htmlContent) { let blockedExternalContent = false; const sanitizeConfig = { ADD_TAGS: ['style'], ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'], FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base'], FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'], }; if (!allowExternal) { DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (node.tagName === 'IMG') { const src = node.getAttribute('src'); if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) { node.setAttribute('data-blocked-src', src); node.removeAttribute('src'); node.setAttribute('alt', '[Image blocked]'); blockedExternalContent = true; } } if (node.hasAttribute('style')) { const style = node.getAttribute('style'); if (style && /url\s*\(/i.test(style)) { const cleanStyle = style.replace(/url\s*\([^)]*\)/gi, 'none'); node.setAttribute('style', cleanStyle); blockedExternalContent = true; } } }); } const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig); DOMPurify.removeHook('afterSanitizeAttributes'); if (blockedExternalContent) { setHasBlockedContent(true); } return { html: sanitized, isHtml: true }; } // Plain text fallback if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) { const text = email.bodyValues[email.textBody[0].partId].value; const htmlEscaped = text .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
') .replace(/(https?:\/\/[^\s<]+)/g, '$1'); return { html: htmlEscaped, isHtml: false }; } } // Fallback to preview if (email.preview) { return { html: email.preview.replace(/\n/g, '
'), isHtml: false }; } return { html: "", isHtml: false }; }, [email, allowExternal]); return (
{/* Card Header - Always visible */} {/* Expanded Content */} {isExpanded && (
{/* External content warning */} {hasBlockedContent && !allowExternal && (
{t("email_viewer.external_content_warning")}
)} {/* Email Body */}
{/* Attachments */} {email.attachments && email.attachments.length > 0 && (
{email.attachments.map((attachment, idx) => { const Icon = getFileIcon(attachment.name, attachment.type); return ( ); })}
)} {/* Action Buttons */}
{onReply && ( )} {onReplyAll && ( )} {onForward && ( )}
)}
); }