This commit is contained in:
Linus Rath
2026-07-16 18:08:40 +02:00
93 changed files with 4818 additions and 249 deletions
@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render } from '@testing-library/react';
import { FaviconBadge } from '@/components/favicon-badge';
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { Mailbox } from '@/lib/jmap/types';
vi.mock('@/hooks/use-favicon-badge', () => ({
useFaviconBadge: vi.fn(),
}));
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
return {
name: patch.id,
sortOrder: 0,
totalEmails: 0,
unreadEmails: 0,
totalThreads: 0,
unreadThreads: 0,
isSubscribed: true,
myRights: {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
...patch,
} as Mailbox;
}
const initialMailboxes = useEmailStore.getState().mailboxes;
beforeEach(() => {
useEmailStore.setState({ mailboxes: initialMailboxes });
useSettingsStore.setState({ faviconUnreadBadge: true });
});
afterEach(() => {
useEmailStore.setState({ mailboxes: initialMailboxes });
useSettingsStore.setState({ faviconUnreadBadge: true });
vi.clearAllMocks();
});
describe('FaviconBadge', () => {
it('badges the unread count of the primary inbox', () => {
useEmailStore.setState({
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
});
const { container } = render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
expect(container.firstChild).toBeNull(); // renders no markup
});
it('disables the badge when the setting is off', () => {
useSettingsStore.setState({ faviconUnreadBadge: false });
useEmailStore.setState({
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
});
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
});
it('ignores a shared inbox, even when it sorts first', () => {
// Shared and group inboxes ship in the same `mailboxes` array. A plain
// `role === 'inbox'` lookup would badge somebody else's inbox on a
// delegated setup, so the store's canonical `!isShared` filter is required.
useEmailStore.setState({
mailboxes: [
mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
],
});
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
});
it('badges zero when there is no inbox yet', () => {
useEmailStore.setState({ mailboxes: [] });
render(<FaviconBadge />);
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
});
});
@@ -148,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
vi.mock('@/lib/reply-identity', () => ({
resolveReplyFrom: () => null,
findComposeIdentityId: () => null,
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
@@ -227,7 +230,7 @@ describe('RecipientChipInput drag and drop', () => {
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 });
});
it('keeps a display name with a comma in a single chip (array model)', async () => {
@@ -241,7 +244,7 @@ describe('RecipientChipInput drag and drop', () => {
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 });
});
it('onDragEnd clears the opacity class on the chip', async () => {
@@ -340,4 +343,121 @@ describe('RecipientChipInput drag and drop', () => {
const ccLabel = await screen.findByText('cc_label');
expect(ccLabel).toBeInTheDocument();
});
// ─── Reordering within / across fields (#593) ─────────────────────────────────
// jsdom ignores `clientX` in fireEvent's init for drag events (it's a
// read-only MouseEvent getter) and gives every element a zero-size rect at
// (0,0). So we dispatch events with `clientX` forced via defineProperty; with
// the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE.
const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' };
const chipByText = async (text: string) =>
(await screen.findByText(text)).closest('[draggable]') as HTMLElement;
/** Dispatch a drag event with a real clientX (fireEvent init drops it). */
const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => {
const e = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(e, 'clientX', { value: clientX });
Object.defineProperty(e, 'dataTransfer', { value: dt });
act(() => { fireEvent(el, e); });
};
const BEFORE = -100;
const AFTER = 100;
/** Ordered chip labels of the field-container that holds `anchorText`. */
const orderIn = (anchorText: string) => {
const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
const c = containers.find(el =>
Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText))
) as HTMLElement;
return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? '');
};
/** All draggable chips (across fields) whose label contains `text`. */
const draggableChipsWith = (text: string) =>
Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text));
it('reorders a chip to the end of the same field (drop after the last chip)', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com');
const carol = await chipByText('carol@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0
fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3
fireDnd('drop', carol, dt, AFTER);
expect(orderIn('bob@example.com')).toEqual([
'bob@example.com', 'carol@example.com', 'alice@example.com',
]);
});
it('reorders a chip to the front of the same field (drop before the first chip)', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('carol@example.com');
const carol = await chipByText('carol@example.com');
const alice = await chipByText('alice@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2
fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0
fireDnd('drop', alice, dt, BEFORE);
expect(orderIn('alice@example.com')).toEqual([
'carol@example.com', 'alice@example.com', 'bob@example.com',
]);
});
it('dropping a chip onto its own position leaves the order unchanged', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('bob@example.com');
const bob = await chipByText('bob@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1
fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op)
fireDnd('drop', bob, dt, BEFORE);
expect(orderIn('bob@example.com')).toEqual([
'alice@example.com', 'bob@example.com', 'carol@example.com',
]);
});
it('moves a chip into another field at the drop position (cross-field reorder)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: 'alice@example.com, ', cc: 'x@example.com, y@example.com, ' }} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com'); // To
const y = await chipByText('y@example.com'); // Cc
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt });
fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc
fireDnd('drop', y, dt, BEFORE);
// alice lands between x and y; To no longer holds it (count only real chips,
// not the leftover jsdom drag-preview element)
expect(orderIn('x@example.com')).toEqual([
'x@example.com', 'alice@example.com', 'y@example.com',
]);
expect(draggableChipsWith('alice@example.com')).toHaveLength(1);
});
it('shows a drop caret only while a chip is dragged over the field', async () => {
render(<EmailComposer initialData={THREE} />);
await screen.findByText('alice@example.com');
const alice = await chipByText('alice@example.com');
const bob = await chipByText('bob@example.com');
const dt = new MockDataTransfer();
fireEvent.dragStart(alice, { dataTransfer: dt });
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
fireDnd('dragover', bob, dt, BEFORE);
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull();
fireEvent.dragEnd(alice);
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
});
});
@@ -147,7 +147,10 @@ vi.mock('@/lib/email-sanitization', () => ({
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
vi.mock('@/lib/reply-identity', () => ({
resolveReplyFrom: () => null,
findComposeIdentityId: () => null,
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
+107 -19
View File
@@ -11,7 +11,7 @@ import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
@@ -823,7 +823,7 @@ export function EmailComposer({
}, [composerClient, plainTextMode, mode]);
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
? `<div>${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
@@ -936,7 +936,10 @@ export function EmailComposer({
}
}, [plainTextMode]);
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
// Move a chip from one recipient field to another. `toIndex`, when given,
// inserts at that position in the destination (drag-and-drop reordering,
// #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button).
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => {
if (fromField === toField) return;
const setters = { to: setTo, cc: setCc, bcc: setBcc };
const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : '';
@@ -946,7 +949,13 @@ export function EmailComposer({
const idx = prev.findIndex(r => sameRecipient(r, recipient));
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
});
setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]);
setters[toField](prev => {
if (prev.some(r => sameRecipient(r, recipient))) return prev;
const at = toIndex == null ? prev.length : Math.max(0, Math.min(toIndex, prev.length));
const next = [...prev];
next.splice(at, 0, recipient);
return next;
});
if (toField === 'cc') setShowCc(true);
if (toField === 'bcc') setShowBcc(true);
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
@@ -2041,7 +2050,7 @@ export function EmailComposer({
};
return (
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
<div ref={composerRootRef} data-testid="email-composer" className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
@@ -2099,6 +2108,7 @@ export function EmailComposer({
disabled={!canSend || isSending}
title={getSendTooltip()}
size="sm"
data-testid="composer-send"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 me-1.5" />
@@ -2133,6 +2143,7 @@ export function EmailComposer({
</div>
) : identities.length > 1 ? (
<select
data-testid="composer-from"
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
@@ -2164,7 +2175,7 @@ export function EmailComposer({
})}
</select>
) : (
<span className="text-sm text-foreground flex-1 truncate">
<span data-testid="composer-from" className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
@@ -2227,7 +2238,7 @@ export function EmailComposer({
</div>
{/* To field */}
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<div data-testid="composer-to" className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<RecipientChipInput
chips={to}
@@ -2372,6 +2383,7 @@ export function EmailComposer({
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
ref={subjectInputRef}
data-testid="composer-subject"
type="text"
placeholder={t('subject_placeholder')}
value={subject}
@@ -2594,6 +2606,7 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
data-testid="composer-send"
className="rounded-e-none border-e border-primary-foreground/20"
>
<Send className="w-4 h-4 me-2" />
@@ -2632,6 +2645,7 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
data-testid="composer-send"
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 me-2" />
@@ -2895,7 +2909,7 @@ function RecipientChipInput({
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => void;
}) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
@@ -2904,6 +2918,9 @@ function RecipientChipInput({
const [editValue, setEditValue] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
// Gap (0..chips.length) a dragged chip would drop into; drives the insertion
// caret and positional drop for reordering (#593). null when not dragging.
const [dropIndex, setDropIndex] = useState<number | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
// Focus edit input when editing starts
@@ -3060,27 +3077,81 @@ function RecipientChipInput({
onAutoBlur(e, field);
};
const isChipDrag = (e: React.DragEvent) =>
e.dataTransfer.types.includes('application/x-recipient-chip');
// Dragging over empty container space (past the last chip / over the input)
// targets the end of the list.
const handleContainerDragOver = (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
if (!isChipDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
setDropIndex(chips.length);
};
const handleContainerDragLeave = (e: React.DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
setDropIndex(null);
}
};
// Dragging over a chip picks the gap before or after it based on which half
// the pointer is in (mirrored for RTL). stopPropagation keeps the container
// handler from overriding this finer target.
const handleChipDragOver = (e: React.DragEvent, index: number) => {
if (!isChipDrag(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const rtl = typeof window !== 'undefined' &&
getComputedStyle(e.currentTarget as Element).direction === 'rtl';
const past = rtl
? e.clientX < rect.left + rect.width / 2
: e.clientX > rect.left + rect.width / 2;
setIsDragOver(true);
setDropIndex(past ? index + 1 : index);
};
// Insert the dragged chip at `target`. Same-field is a local reorder;
// cross-field routes through onMoveChip with the destination index (#593).
const performDrop = (e: React.DragEvent, target: number) => {
e.preventDefault();
setIsDragOver(false);
setDropIndex(null);
setDraggingIndex(null);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
let payload: { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc'; fromIndex?: number };
try {
payload = JSON.parse(raw);
} catch {
return;
}
const { recipient, fromField, fromIndex } = payload;
const to = Math.max(0, Math.min(target, chips.length));
if (fromField === field) {
const from = typeof fromIndex === 'number'
? fromIndex
: chips.findIndex(c => c.email === recipient.email && (c.name ?? '') === (recipient.name ?? ''));
if (from < 0 || from >= chips.length) return;
// Removing the source before `to` shifts the target left by one.
const insertAt = to > from ? to - 1 : to;
if (insertAt === from) return; // dropped onto its own position
const next = [...chips];
const [moved] = next.splice(from, 1);
next.splice(insertAt, 0, moved);
onChipsChange(next);
} else {
onMoveChip(recipient, fromField, field, to);
}
};
const handleContainerDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField === field) return;
onMoveChip(recipient, fromField, field);
performDrop(e, dropIndex ?? chips.length);
};
return (
@@ -3100,20 +3171,29 @@ function RecipientChipInput({
const isEditing = editingChip?.index === i;
const chipDisplay = formatChipDisplay(chip);
return (
<React.Fragment key={`${chip.email}-${i}`}>
{dropIndex === i && (
<span
aria-hidden
data-testid="recipient-drop-caret"
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
/>
)}
<span
key={`${chip.email}-${i}`}
draggable={!isEditing}
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field, fromIndex: i }));
// Show the address while dragging, matching the email-list drag preview.
const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
requestAnimationFrame(() => dragPreview.remove());
setDraggingIndex(i);
}}
onDragEnd={() => setDraggingIndex(null)}
onDragEnd={() => { setDraggingIndex(null); setDropIndex(null); }}
onDragOver={(e) => handleChipDragOver(e, i)}
onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
isEditing
@@ -3179,8 +3259,16 @@ function RecipientChipInput({
)}
</button>
</span>
</React.Fragment>
);
})}
{dropIndex === chips.length && chips.length > 0 && (
<span
aria-hidden
data-testid="recipient-drop-caret"
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
/>
)}
{!editingChip && (
<input
ref={inputRef}
+1
View File
@@ -1175,6 +1175,7 @@ export function EmailViewer({
id: email.id,
contentType,
bodyStructure: email.bodyStructure,
bodyValues: email.bodyValues,
attachments: email.attachments,
blobId: email.blobId,
from: email.from,
+6 -3
View File
@@ -10,6 +10,10 @@ import { buildSignatureBlock } from "@/components/email/signature-block";
// HTML, so parseHTML can recognise it on the way back in.
export const QUOTED_HTML_MARKER = "data-quoted-html";
// Reusable style for the quote bar when quoting email text (like in a reply).
const QUOTE_BAR_STYLE =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
/**
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
* quoted/forwarded original email. The HTML is stored in the `html` attribute
@@ -68,8 +72,7 @@ export const QuotedHtml = TiptapNode.create({
const dom = document.createElement("div");
dom.setAttribute(QUOTED_HTML_MARKER, "");
dom.className = "quoted-html-island";
dom.style.cssText =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
dom.style.cssText = QUOTE_BAR_STYLE;
// CRITICAL: render the quoted email inside a Shadow Root. The app's
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
@@ -192,5 +195,5 @@ export function serializeEditorContent(editor: Editor): string {
* must be what serializeEditorContent emits too (round-trip consistency).
*/
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
}
+65
View File
@@ -41,6 +41,7 @@ import {
Heading1,
Heading2,
Table as TableIcon,
Baseline,
Trash2,
Rows3,
Columns3,
@@ -143,6 +144,14 @@ function ToolbarSeparator() {
const TABLE_PICKER_ROWS = 6;
const TABLE_PICKER_COLS = 8;
// Preset text colours (2 x 8). Inline `style="color: …"` survives email
// round-trips; the TextStyle/Color extensions are already registered to
// preserve pasted colours - this palette just adds a UI to set them.
const TEXT_COLORS = [
"#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
];
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
return (
@@ -336,6 +345,19 @@ export function RichTextEditor({
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [colorMenuOpen, setColorMenuOpen] = useState(false);
const colorWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!colorMenuOpen) return;
const handler = (e: MouseEvent) => {
if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
setColorMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [colorMenuOpen]);
useEffect(() => {
if (!tableMenuOpen) return;
@@ -386,6 +408,49 @@ export function RichTextEditor({
>
<Strikethrough className="w-4 h-4" />
</ToolbarButton>
<div ref={colorWrapperRef} className="relative">
<ToolbarButton
active={!!editor.getAttributes("textStyle").color}
onClick={() => setColorMenuOpen((v) => !v)}
title="Text color"
>
{/* The icon itself previews the active colour - no layout shift. */}
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
</ToolbarButton>
{colorMenuOpen && (
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
{TEXT_COLORS.map((color) => (
<button
key={color}
type="button"
title={color}
onClick={() => {
editor.chain().focus().setColor(color).run();
setColorMenuOpen(false);
}}
className={cn(
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
)}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="h-px bg-border my-1.5" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
onClick={() => {
editor.chain().focus().unsetColor().run();
setColorMenuOpen(false);
}}
>
<RemoveFormatting className="w-4 h-4" /> Remove color
</button>
</div>
)}
</div>
<ToolbarSeparator />
+29 -3
View File
@@ -6,6 +6,23 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
// so parseHTML can recognise it on the way back in (initial content, drafts).
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
/**
* Force every link in the rendered signature to open in a new tab.
*
* Applied to the NodeView's DOM only, never to `attrs.html` — that attribute is
* what serializeEditorContent emits into the sent message, and the recipient's
* copy should stay exactly as the user wrote it. Without this the composer's
* signature is a set of live, target-less anchors in the main document (the
* message body gets a sandboxed iframe; this does not), so one stray click
* navigates the whole app away and takes the unsent draft with it.
*/
function forceLinksToNewTab(root: HTMLElement): void {
root.querySelectorAll("a[href]").forEach((a) => {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
});
}
/**
* SignatureBlock — an atomic, NON-editable block node that carries the
* *verbatim* HTML of the user's identity signature in its `html` attribute.
@@ -61,6 +78,7 @@ export const SignatureBlock = TiptapNode.create({
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
dom.className = "signature-block-island";
// CRITICAL: render the signature inside a Shadow Root. The app's global
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
// would otherwise cascade INTO the signature and destroy its layout -
@@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({
const inner = document.createElement("div");
// Read-only: a signature is inserted/removed as a unit, not edited inline.
inner.contentEditable = "false";
inner.innerHTML = node.attrs.html || "";
// Track what we were given, not what's in the DOM: forceLinksToNewTab
// rewrites the markup, so inner.innerHTML no longer round-trips against
// attrs.html and comparing the two would rewrite on every transaction.
let appliedHtml = node.attrs.html || "";
inner.innerHTML = appliedHtml;
forceLinksToNewTab(inner);
shadow.appendChild(inner);
return {
@@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({
stopEvent: () => false,
update: (updatedNode) => {
if (updatedNode.type.name !== "signatureBlock") return false;
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
inner.innerHTML = updatedNode.attrs.html || "";
const nextHtml = updatedNode.attrs.html || "";
if (nextHtml !== appliedHtml) {
appliedHtml = nextHtml;
inner.innerHTML = nextHtml;
forceLinksToNewTab(inner);
}
return true;
},
+9 -4
View File
@@ -13,7 +13,12 @@ declare module "@tiptap/core" {
/**
* Adds a `dir` attribute to block nodes so the composer can mark individual
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). The
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing).
*
* The default is `"auto"`: each block detects its own direction from its first
* strong character, so a paragraph typed in English renders LTR and one typed
* in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins
* an explicit `ltr`/`rtl` when you want to override the auto-detection, and the
* attribute round-trips to HTML so the direction is preserved in the sent mail.
*/
export const TextDirection = Extension.create({
@@ -29,10 +34,10 @@ export const TextDirection = Extension.create({
types: this.options.types,
attributes: {
dir: {
default: null,
parseHTML: (element) => element.getAttribute("dir") || null,
default: "auto",
parseHTML: (element) => element.getAttribute("dir") || "auto",
renderHTML: (attributes) =>
attributes.dir ? { dir: attributes.dir } : {},
attributes.dir ? { dir: attributes.dir } : { dir: "auto" },
},
},
},
+6 -2
View File
@@ -166,6 +166,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
ref={ref}
{...dragHandlers}
{...longPressHandlers}
data-testid="email-list-item"
data-email-id={email.id}
data-subject={email.subject || ''}
data-unread={isUnread ? 'true' : 'false'}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
resolvedColorTag ? resolvedColorTag : (
@@ -660,7 +664,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onToggle={toggleThreadSelection}
selectLabel={tBatch('select')}
/>
{!isMobile && !isFocusedMailLayout && (
{!isMobile && (
<button
data-expand-toggle
onClick={(e) => {
@@ -892,7 +896,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
</div>
{isExpanded && !isMobile && !isFocusedMailLayout && (
{isExpanded && !isMobile && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
/**
* Badges the browser-tab favicon with the inbox unread count, so new mail is
* visible without focusing the tab. See issue #560.
*
* Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
* default.
*
* Mounted in the root layout rather than on the mail route: the badge belongs
* to the tab, not to a page. Mounting it on the mail page unmounted it — and so
* cleared the badge, and flickered the icon — on every hop to /settings,
* /calendar or /contacts.
*
* Renders nothing.
*/
export function FaviconBadge() {
// The store's canonical inbox selector. `role === 'inbox'` alone is not
// enough: shared and group inboxes ship in the same `mailboxes` array, so on
// a delegated setup the first match can be somebody else's inbox.
const inboxUnread = useEmailStore(
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
);
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
useFaviconBadge(inboxUnread, enabled);
return null;
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes
@@ -305,7 +305,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
<div
dangerouslySetInnerHTML={{
__html: sanitizeSignatureHtml(formData.htmlSignature)
__html: sanitizeSignatureHtmlForDisplay(formData.htmlSignature)
}}
/>
</div>
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { useSettingsStore } from '@/stores/settings-store';
function useSyncIdentities() {
@@ -207,15 +208,16 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const handleSetPrimary = useCallback((identity: Identity) => {
setPreferredPrimary(identity.id);
// Persist to the synced settings (keyed by username, matching how
// loadIdentities reads it back) so the choice survives a new browser /
// cleared site data and reaches other devices (#507).
const username = useAuthStore.getState().username || '';
if (username) {
// Persist the choice per account in the synced settings store so it
// survives clearing site data, follows the user across devices, and shows
// up in exported settings (issue #507). JMAP identity ids are account-
// scoped, so the default is keyed by the active account.
const activeAccountId = useAccountStore.getState().activeAccountId;
if (activeAccountId) {
const current = useSettingsStore.getState().preferredIdentityIds;
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
...current,
[username]: identity.id,
[activeAccountId]: identity.id,
});
}
// Re-sort: move the preferred identity to the front
+6
View File
@@ -151,6 +151,8 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
data-testid="account-switcher"
data-active-account-id={activeAccountId ?? undefined}
className={cn(
"flex items-center gap-2 rounded-md transition-colors",
variant === "rail"
@@ -213,6 +215,9 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
>
<button
onClick={() => handleSwitch(account.id)}
data-testid="account-option"
data-account-id={account.id}
data-account-email={account.email || account.username}
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
@@ -274,6 +279,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
<div className="border-t border-border">
<button
onClick={handleAddAccount}
data-testid="add-account"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
+26 -2
View File
@@ -220,8 +220,13 @@ function SidebarRowCounts({
) : null;
return (
<span className="ms-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
<span
className="ms-2 flex-shrink-0 flex items-baseline gap-1"
title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
data-testid="folder-counts"
data-unread={unreadCount}
data-total={totalCount}
>
{unreadNode}
{unreadCount > 0 && totalCount > 0 && (
<span className="text-xs text-muted-foreground/60">/</span>
@@ -249,6 +254,10 @@ interface SidebarRowProps {
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
/** Stable identifiers for integration tests (not user-visible). */
testRole?: string | null;
testName?: string;
testMailboxId?: string;
}
function SidebarRow({
@@ -269,6 +278,9 @@ function SidebarRow({
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
testRole,
testName,
testMailboxId,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -277,6 +289,10 @@ function SidebarRow({
<div
{...(dropHandlers || {})}
onContextMenu={onContextMenu}
data-testid="folder-row"
data-folder-role={testRole ?? undefined}
data-folder-name={testName ?? undefined}
data-mailbox-id={testMailboxId ?? undefined}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -477,6 +493,9 @@ function MailboxTreeItem({
<SidebarRow
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
label={label}
testRole={node.role}
testName={node.name}
testMailboxId={node.id}
depth={node.depth}
isSelected={isSelected}
isVirtual={isVirtualNode}
@@ -1047,6 +1066,9 @@ export function Sidebar({
key={unifiedId}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons, count.role)} />}
label={t(`unified_${count.role}`)}
testRole={count.role}
testName={`unified-${count.role}`}
testMailboxId={unifiedId}
depth={0}
isSelected={isSelected}
unread={count.unreadEmails}
@@ -1068,6 +1090,8 @@ export function Sidebar({
key={id}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
label={label}
testName={id}
testMailboxId={id}
depth={0}
isSelected={isSelected}
unread={unread}
+8 -1
View File
@@ -118,7 +118,7 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
@@ -231,6 +231,13 @@ export function LayoutSettings() {
/>
</SettingItem>
<SettingItem label={t('favicon_unread_badge.label')} description={t('favicon_unread_badge.description')}>
<ToggleSwitch
checked={faviconUnreadBadge}
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
/>
</SettingItem>
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
<SettingItem
label={t('unified_mailbox.label')}