Merge dev into main

This commit is contained in:
Linus Rath
2026-03-18 16:58:00 +01:00
3 changed files with 98 additions and 56 deletions
+77 -40
View File
@@ -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<HTMLDivElement>(null);
const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [overflowCount, setOverflowCount] = useState(0);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColor = getCurrentColor(email?.keywords);
// S/MIME state
@@ -873,7 +872,7 @@ export function EmailViewer({
const [tnefAttachments, setTnefAttachments] = useState<TnefAttachment[]>([]);
// 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<HTMLElement>('[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<number>();
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<string | null>(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 */}
<button
onClick={() => { onReply?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 10 ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(1) ? "" : "sm:hidden")}
>
<Reply className="w-4 h-4" />
{t('reply')}
@@ -2745,7 +2782,7 @@ export function EmailViewer({
{/* Overflow: reply all */}
<button
onClick={() => { onReplyAll?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 9 ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(2) ? "" : "sm:hidden")}
>
<ReplyAll className="w-4 h-4" />
{t('reply_all')}
@@ -2753,7 +2790,7 @@ export function EmailViewer({
{/* Overflow: forward */}
<button
onClick={() => { onForward?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 8 ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(3) ? "" : "sm:hidden")}
>
<Forward className="w-4 h-4" />
{t('forward')}
@@ -2761,14 +2798,14 @@ export function EmailViewer({
{/* Overflow: archive */}
<button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 7 ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(4) ? "" : "sm:hidden")}
>
<Archive className="w-4 h-4" />
{t('archive')}
</button>
{/* Overflow: move to folder — submenu */}
{moveTree.length > 0 && onMoveToMailbox && (
<div className={cn("relative", overflowCount >= 6 ? "" : "sm:hidden")}
<div className={cn("relative", hiddenPriorities.has(5) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('move')}
onMouseLeave={() => setMoreMenuSub(null)}
>
@@ -2820,7 +2857,7 @@ export function EmailViewer({
)}
{/* Overflow: tag — submenu */}
{colorOptions.length > 0 && (
<div className={cn("relative", overflowCount >= 5 ? "" : "sm:hidden")}
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')}
onMouseLeave={() => setMoreMenuSub(null)}
>
@@ -2868,7 +2905,7 @@ export function EmailViewer({
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 4 ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
>
{isInJunkFolder ? (
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
@@ -2881,7 +2918,7 @@ export function EmailViewer({
{/* Overflow: toggle read */}
<button
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 3 ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(8) ? "" : "sm:hidden")}
>
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
{isUnread ? t('mark_read') : t('mark_unread')}
@@ -2889,7 +2926,7 @@ export function EmailViewer({
{/* Overflow: print */}
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 2 ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(9) ? "" : "sm:hidden")}
>
<Printer className="w-4 h-4" />
{t('print')}
@@ -2897,7 +2934,7 @@ export function EmailViewer({
{/* Overflow: view source */}
<button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 1 ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(10) ? "" : "sm:hidden")}
>
<Code className="w-4 h-4" />
{t('view_source')}
+16 -15
View File
@@ -13,7 +13,7 @@
"asn1js": "^3.0.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
"dompurify": "^3.3.3",
"lucide-react": "^0.575.0",
"next": "^16.1.5",
"next-intl": "^4.5.8",
@@ -4731,9 +4731,9 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -4762,17 +4762,18 @@
"license": "ISC"
},
"node_modules/elliptic": {
"version": "6.5.0",
"resolved": "git+ssh://git@github.com/mahrud/elliptic.git#75637c76678e83c31682fd967c2fa9ff4761b3fc",
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz",
"integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.4.0",
"brorand": "^1.0.1",
"bn.js": "^4.11.9",
"brorand": "^1.1.0",
"hash.js": "^1.0.0",
"hmac-drbg": "^1.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"minimalistic-crypto-utils": "^1.0.0"
"hmac-drbg": "^1.0.1",
"inherits": "^2.0.4",
"minimalistic-assert": "^1.0.1",
"minimalistic-crypto-utils": "^1.0.1"
}
},
"node_modules/enhanced-resolve": {
@@ -8656,9 +8657,9 @@
}
},
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"version": "7.24.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
"integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
"dev": true,
"license": "MIT",
"engines": {
+5 -1
View File
@@ -36,7 +36,7 @@
"asn1js": "^3.0.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
"dompurify": "^3.3.3",
"lucide-react": "^0.575.0",
"next": "^16.1.5",
"next-intl": "^4.5.8",
@@ -74,5 +74,9 @@
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vitest": "^4.0.16"
},
"overrides": {
"elliptic": "^6.6.1",
"undici": "^7.24.0"
}
}