diff --git a/.dockerignore b/.dockerignore index 771c8d7d..509fe74a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,8 @@ scripts/ TODO.md *.md !README.md +# Sibling projects / test harness - not part of the webmail image +examples/ +integration/ +e2e/ +**/node_modules diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index a8af157f..34d9f94a 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { getLocale, getTranslations } from "next-intl/server"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; +import { FaviconBadge } from "@/components/favicon-badge"; import { configManager } from "@/lib/admin/config-manager"; import { matchDomainBranding, @@ -132,6 +133,7 @@ export default async function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > + {children} diff --git a/components/__tests__/favicon-badge.test.tsx b/components/__tests__/favicon-badge.test.tsx new file mode 100644 index 00000000..5524c01c --- /dev/null +++ b/components/__tests__/favicon-badge.test.tsx @@ -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 & { 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(); + + 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(); + + 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(); + + expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true); + }); + + it('badges zero when there is no inbox yet', () => { + useEmailStore.setState({ mailboxes: [] }); + + render(); + + expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true); + }); +}); diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx index 461b4452..f3138b38 100644 --- a/components/email/__tests__/recipient-chip-drag.test.tsx +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); }); diff --git a/components/email/__tests__/recipient-paste.test.tsx b/components/email/__tests__/recipient-paste.test.tsx index 1a3ccef6..a8d4d15c 100644 --- a/components/email/__tests__/recipient-paste.test.tsx +++ b/components/email/__tests__/recipient-paste.test.tsx @@ -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: [] }), })); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 4e022d29..ae937fc5 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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 - ? `
${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}
` + ? `
${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}
` : signatureIdentity?.textSignature ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` : ''; @@ -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 ( -
+
@@ -2133,6 +2143,7 @@ export function EmailComposer({
) : identities.length > 1 ? ( ) : ( - + {subAddressTag ? ( {generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)} @@ -2227,7 +2238,7 @@ export function EmailComposer({
{/* To field */} -
+
{t('to')}: {t('subject_label')} handleSend()} disabled={!canSend || isSending} title={getSendTooltip()} + data-testid="composer-send" className="rounded-e-none border-e border-primary-foreground/20" > @@ -2632,6 +2645,7 @@ export function EmailComposer({ onClick={() => handleSend()} disabled={!canSend || isSending} title={getSendTooltip()} + data-testid="composer-send" className="hidden md:inline-flex" > @@ -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(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(null); const editInputRef = useRef(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 ( + + {dropIndex === i && ( + + )} { 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({ )} + ); })} + {dropIndex === chips.length && chips.length > 0 && ( + + )} {!editingChip && ( ${sanitizedInnerHtml}
`; + return `
${sanitizedInnerHtml}
`; } diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 28da13a6..f59704d4 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -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(null); + const [colorMenuOpen, setColorMenuOpen] = useState(false); + const colorWrapperRef = useRef(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({ > +
+ setColorMenuOpen((v) => !v)} + title="Text color" + > + {/* The icon itself previews the active colour - no layout shift. */} + + + {colorMenuOpen && ( +
+
+ {TEXT_COLORS.map((color) => ( +
+
+ +
+ )} +
diff --git a/components/email/signature-block.ts b/components/email/signature-block.ts index 231b9f6a..9b0ee3ef 100644 --- a/components/email/signature-block.ts +++ b/components/email/signature-block.ts @@ -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; }, diff --git a/components/email/text-direction.ts b/components/email/text-direction.ts index 7edf34b5..6665c373 100644 --- a/components/email/text-direction.ts +++ b/components/email/text-direction.ts @@ -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" }, }, }, }, diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 8e44f2a2..cb8868b9 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -166,6 +166,10 @@ const SingleEmailItem = React.forwardRef( 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 - {!isMobile && !isFocusedMailLayout && ( + {!isMobile && (