Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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: [] }),
|
||||
}));
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user