diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 89ed732a..632df8bb 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useRef, useCallback } from "react"; +import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; import ReactDOM from "react-dom"; import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; @@ -817,7 +817,6 @@ export function EmailViewer({ const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const debugMode = useSettingsStore((state) => state.debugMode); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); @@ -854,7 +853,7 @@ export function EmailViewer({ const tagMenuRef = useRef(null); const moveMenuRef = useRef(null); const toolbarRef = useRef(null); - const [overflowCount, setOverflowCount] = useState(0); + const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); const currentColor = getCurrentColor(email?.keywords); // S/MIME state @@ -873,7 +872,7 @@ export function EmailViewer({ const [tnefAttachments, setTnefAttachments] = useState([]); // Ensure S/MIME key records are loaded from IndexedDB - useEffect(() => { + useLayoutEffect(() => { smimeStore.load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -947,10 +946,17 @@ export function EmailViewer({ useEffect(() => { const el = toolbarRef.current; if (!el) return; + + let rafId: number | null = null; + const calculate = () => { + rafId = null; const items = Array.from(el.querySelectorAll('[data-overflow-item]')); - if (items.length === 0) return; - // Sort descending by priority so highest number (least important) is first + if (items.length === 0) { + setHiddenPriorities(prev => prev.size === 0 ? prev : new Set()); + return; + } + // Sort descending by priority so highest number (least important) is hidden first items.sort((a, b) => Number(b.dataset.overflowPriority || 0) - Number(a.dataset.overflowPriority || 0) ); @@ -965,7 +971,7 @@ export function EmailViewer({ rightGroup.style.flexShrink = '0'; el.style.overflow = 'hidden'; // Iteratively hide items until content fits - let count = 0; + const hidden = new Set(); const isOverflowing = () => leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap > containerWidth + 1; for (const item of items) { @@ -973,18 +979,54 @@ export function EmailViewer({ // Skip items already hidden by CSS (e.g., on mobile) if (item.offsetWidth === 0) continue; item.style.display = 'none'; - count++; + hidden.add(Number(item.dataset.overflowPriority)); } // Restore layout leftGroup.style.flexShrink = ''; rightGroup.style.flexShrink = ''; el.style.overflow = ''; - setOverflowCount(prev => prev === count ? prev : count); + setHiddenPriorities(prev => { + if (prev.size === hidden.size && [...hidden].every(p => prev.has(p))) return prev; + return hidden; + }); }; - const observer = new ResizeObserver(calculate); - observer.observe(el); - return () => observer.disconnect(); - }, [toolbarPosition]); + + const scheduleCalculate = () => { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(calculate); + }; + + // Recalculate on container resize + const resizeObserver = new ResizeObserver(scheduleCalculate); + resizeObserver.observe(el); + + // Recalculate when children change (conditional items, label visibility) + const mutationObserver = new MutationObserver(scheduleCalculate); + mutationObserver.observe(el, { childList: true, subtree: true }); + + // Initial synchronous calculation to avoid flash + calculate(); + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [ + toolbarPosition, + email?.id, + showToolbarLabels, + isLoading, + moveTree.length, + colorOptions.length, + currentColor, + isInJunkFolder, + isTablet, + tabletListVisible, + onBack, + onMarkAsSpam, + onUndoSpam, + ]); // Contact sidebar state const [contactSidebarEmail, setContactSidebarEmail] = useState(null); @@ -1081,21 +1123,19 @@ export function EmailViewer({ if (!email || !client) return; const smimeDebug = (...args: unknown[]) => { - if (debugMode) { + if (useSettingsStore.getState().debugMode) { console.debug(...args); } }; const smimeWarn = (...args: unknown[]) => { - if (debugMode) { + if (useSettingsStore.getState().debugMode) { console.warn(...args); } }; const smimeError = (...args: unknown[]) => { - if (debugMode) { - console.error(...args); - } + console.error(...args); }; const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type']; @@ -1336,7 +1376,7 @@ export function EmailViewer({ candidates: candidateSummaries, }); - if (debugMode && typeof window !== 'undefined') { + if (useSettingsStore.getState().debugMode && typeof window !== 'undefined') { const debugPayload = { emailId: email!.id, detection, @@ -1616,7 +1656,6 @@ export function EmailViewer({ }, [ email, client, - debugMode, prepareSmimeUnlock, smimeStore.autoImportSignerCerts, smimeStore.keyRecords, @@ -1636,18 +1675,16 @@ export function EmailViewer({ debug.group('TNEF Processing'); debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size); - // Only process if the email has no usable HTML body + // Check if the email already has a usable HTML body const hasHtmlBody = !!( email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]?.value?.trim() ); if (hasHtmlBody) { - debug.log('TNEF: Email already has HTML body, skipping TNEF extraction'); - debug.log(' HTML partId:', email.htmlBody?.[0]?.partId, 'body length:', email.bodyValues?.[email.htmlBody![0].partId]?.value?.length); - debug.groupEnd(); - return; + debug.log('TNEF: Email already has HTML body, will extract attachments only'); + } else { + debug.log('TNEF: Email has no HTML body, proceeding with full TNEF extraction'); } - debug.log('TNEF: Email has no HTML body, proceeding with TNEF extraction'); let cancelled = false; @@ -1682,10 +1719,10 @@ export function EmailViewer({ debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length); - if (parsed.htmlBody) { + if (parsed.htmlBody && !hasHtmlBody) { setTnefHtml(parsed.htmlBody); } - if (parsed.body) { + if (parsed.body && !hasHtmlBody) { setTnefText(parsed.body); } if (parsed.attachments.length > 0) { @@ -1790,8 +1827,8 @@ export function EmailViewer({ } const jmapAttachments = (email?.attachments ?? []) - // Hide winmail.dat when we have successfully extracted TNEF content - .filter(att => !(tnefHtml || tnefText) || !isTnefAttachment(att.name, att.type)) + // Hide winmail.dat when we have successfully extracted TNEF content or attachments + .filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type)) .map((attachment, index) => ({ id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, name: attachment.name || null, @@ -2737,7 +2774,7 @@ export function EmailViewer({ {/* Overflow: reply */} {/* Overflow: move to folder — submenu */} {moveTree.length > 0 && onMoveToMailbox && ( -
= 6 ? "" : "sm:hidden")} +
setMoreMenuSub('move')} onMouseLeave={() => setMoreMenuSub(null)} > @@ -2820,7 +2857,7 @@ export function EmailViewer({ )} {/* Overflow: tag — submenu */} {colorOptions.length > 0 && ( -
= 5 ? "" : "sm:hidden")} +
setMoreMenuSub('tag')} onMouseLeave={() => setMoreMenuSub(null)} > @@ -2868,7 +2905,7 @@ export function EmailViewer({ {(onMarkAsSpam || onUndoSpam) && (