# Conflicts:
#	app/api/dev-jmap/[...path]/route.ts
This commit is contained in:
Linus Rath
2026-07-30 19:17:43 +02:00
71 changed files with 3682 additions and 1551 deletions
@@ -1,154 +0,0 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EmailListItem } from '../email-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import type { Email } from '@/lib/jmap/types';
// Mock the drag hook
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
// Mock identity badge
vi.mock('../email-identity-badge', () => ({
EmailIdentityBadge: () => null,
}));
// Mock auth store
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({ identities: [] }),
}));
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
describe('EmailListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show tag badge when email has no label keyword', () => {
const email = makeEmail({ keywords: { $seen: true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
// No keyword label should appear
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
});
});
it('shows tag badge with label when email has $label: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows tag badge for legacy $color: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('shows a gray fallback badge when keyword id is not in settings', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
render(<EmailListItem email={email} />);
// Unknown tags fall back to the raw id as label with a gray dot
// (see email-list-item.tsx: keywordDefs fallback).
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
});
it('shows custom keyword label', () => {
useSettingsStore.setState({
emailKeywords: [
...DEFAULT_KEYWORDS,
{ id: 'work', label: 'Work', color: 'teal' },
],
});
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('updates badge when keyword definition changes', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
// Update label name
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
rerender(<EmailListItem email={email} />);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
it('renders subject even without tag', () => {
const email = makeEmail({ subject: 'Hello World' });
render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders inline preview text in focused mail layout', () => {
useSettingsStore.setState({
showPreview: true,
mailLayout: 'focus',
});
const email = makeEmail({ preview: 'Inline preview content' });
const { container } = render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
expect(container.querySelector('p')).toBeNull();
});
});
describe('EmailListItem shift-range checkbox', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
});
it('shift-clicking the checkbox extends the selection from the anchor', () => {
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
// selection mode active (so the checkbox renders), anchor on e1
useEmailStore.setState({
emails: [e1, e2, e3],
selectedEmailIds: new Set(['e1']),
lastSelectedEmailId: 'e1',
selectedMailbox: 'inbox',
});
render(<EmailListItem email={e3} />);
// the checkbox is the first button in the row (shown in selection mode)
const checkbox = screen.getAllByRole('button')[0];
act(() => {
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
});
const sel = useEmailStore.getState().selectedEmailIds;
expect(sel.has('e1')).toBe(true);
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
expect(sel.has('e3')).toBe(true);
});
});
@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TagBadge } from '../tag-badge';
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
];
describe('TagBadge', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
it('names the tag by its full path', () => {
render(<TagBadge tagId="work/clients" variant="badge" />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
});
it('names a tag it has no definition for by its id', () => {
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
});
it('offers removal only when asked to', () => {
const onRemove = vi.fn();
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
expect(onRemove).toHaveBeenCalledOnce();
});
it('leaves the dot alone, having nowhere to put the control', () => {
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByLabelText('Work')).toBeInTheDocument();
});
});
@@ -0,0 +1,117 @@
import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TagPicker } from '../tag-picker';
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
{ id: 'work/clients/acme', label: 'Acme', color: 'red' },
{ id: 'personal', label: 'Personal', color: 'purple' },
];
/** Ten tags is the point at which the filter box appears. */
const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({
id: `tag-${i}`,
label: i === 0 ? 'Invoices' : `Tag ${i}`,
color: 'blue',
}));
describe('TagPicker', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
it('names a nested tag by its own label, not the whole path', () => {
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
// The tree conveys the hierarchy, so a child needs only its own name.
expect(screen.getByText('Clients')).toBeInTheDocument();
expect(screen.getByText('Acme')).toBeInTheDocument();
expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument();
});
it('indents each level below its parent', () => {
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
const acme = screen.getByText('Acme');
// Two levels down: two nested indent wrappers between it and the list.
const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4');
expect(indents).not.toBeNull();
expect(container.querySelectorAll('.ps-4').length).toBe(2);
});
it('marks the applied tags and reports toggles by id', () => {
const onToggle = vi.fn();
render(<TagPicker selectedIds={['work/clients']} onToggle={onToggle} />);
const row = screen.getByText('Clients').closest('button')!;
expect(row).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false');
fireEvent.click(row);
expect(onToggle).toHaveBeenCalledWith('work/clients');
});
it('lists a tag it has no definition for, so it can be taken off', () => {
const onToggle = vi.fn();
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
const row = screen.getByText('from-elsewhere').closest('button')!;
expect(row).toHaveAttribute('aria-checked', 'true');
fireEvent.click(row);
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
// Nothing but the message says it exists, so deselecting is the last of it.
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
});
it('counts undefined tags towards the filter box, and matches them', () => {
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
expect(within(container).getByText('stray-3')).toBeInTheDocument();
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
});
it('hides the filter box until the list is long enough to need one', () => {
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument();
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0);
});
it('flattens to matches while filtering, and says so when there are none', () => {
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } });
expect(within(container).getByText('Invoices')).toBeInTheDocument();
expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } });
expect(within(container).getByText('tag_no_matches')).toBeInTheDocument();
});
it('matches the full path, so a child is reachable by its parent name', () => {
useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] });
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } });
// Filtered rows are flat, so they carry the whole path.
expect(within(container).getByText('Work/Clients')).toBeInTheDocument();
});
it('lists tags flat when nesting is off', () => {
useSettingsStore.setState({ nestedTags: false });
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
expect(container.querySelectorAll('.ps-4').length).toBe(0);
expect(screen.getByText('Clients')).toBeInTheDocument();
});
});
@@ -0,0 +1,290 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ThreadListItem } from '../thread-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import { groupEmailsByThread } from '@/lib/thread-utils';
import type { Email } from '@/lib/jmap/types';
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({ identities: [] }),
}));
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
/**
* A one-message thread, built through the real grouping so the fixture cannot
* drift from what the list actually feeds this component. `ThreadListItem`
* delegates to `SingleEmailItem` at that size, which is what draws every
* single-message row in the app.
*/
function renderRow(email: Email) {
const [thread] = groupEmailsByThread([email]);
return render(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
}
describe('ThreadListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show a tag badge when the email has no label keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true } }));
expect(screen.getByText('Test Subject')).toBeInTheDocument();
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
});
});
it('shows a tag badge for a $label: keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows a tag badge for the legacy $color: keyword', () => {
renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } }));
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('falls back to the raw id when the tag is not in settings', () => {
// A keyword created by another client, or one whose definition was deleted.
renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }));
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
});
it('shows a custom tag label', () => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }],
});
renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } }));
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('follows a renamed tag definition', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = renderRow(email);
expect(screen.getByText('Red')).toBeInTheDocument();
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
const [thread] = groupEmailsByThread([email]);
rerender(
<ThreadListItem
thread={thread}
isExpanded={false}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
});
describe('ThreadListItem multi-message thread', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
function renderThread(emails: Email[], expanded = false) {
const [thread] = groupEmailsByThread(emails);
return render(
<ThreadListItem
thread={thread}
isExpanded={expanded}
expandedEmails={expanded ? emails : undefined}
onToggleExpand={() => {}}
onEmailSelect={() => {}}
/>,
);
}
it('carries the tags of every message, not just the first', () => {
// A collapsed row stands in for the whole thread, so a tag applied only to
// a later message still has to surface.
renderThread([
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
]);
expect(screen.getByText('Red')).toBeInTheDocument();
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('names a tag shared by several messages once', () => {
renderThread([
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }),
]);
expect(screen.getAllByText('Red')).toHaveLength(1);
});
it('shows each message its own tags once the thread is expanded', () => {
renderThread(
[
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
],
true,
);
// Once on the header and once on the message that carries it.
expect(screen.getAllByText('Red').length).toBeGreaterThan(1);
});
});
describe('ThreadListItem row content', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('renders the subject without a tag', () => {
renderRow(makeEmail({ subject: 'Hello World' }));
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders preview text inline in the focused layout', () => {
useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' });
const { container } = renderRow(makeEmail({ preview: 'Inline preview content' }));
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
// Focused rows are one line: the preview shares the subject's element
// rather than getting a paragraph of its own.
expect(container.querySelector('p')).toBeNull();
});
});
describe('ThreadListItem shift-range checkbox', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
});
});
it('shift-clicking the checkbox extends the selection from the anchor', () => {
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
// Selection mode active so the checkbox renders, with the anchor on e1.
useEmailStore.setState({
emails: [e1, e2, e3],
selectedEmailIds: new Set(['e1']),
lastSelectedEmailId: 'e1',
selectedMailbox: 'inbox',
});
renderRow(e3);
const checkbox = screen.getAllByRole('button')[0];
act(() => {
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
});
const selected = useEmailStore.getState().selectedEmailIds;
expect(selected.has('e1')).toBe(true);
expect(selected.has('e2')).toBe(true); // the row in between got filled in
expect(selected.has('e3')).toBe(true);
});
});
describe('ThreadListItem row tint', () => {
const rowClasses = (container: HTMLElement) =>
container.querySelector('[data-email-id="email-1"]')!.className.split(' ');
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
mailLayout: 'split',
tintListRowsByTag: true,
});
useEmailStore.setState({
selectedEmailIds: new Set(['email-1']),
selectedMailbox: 'inbox',
});
});
it('keeps a checked row tinted, and says so to either theme', () => {
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
const classes = rowClasses(container);
expect(classes).toContain('bg-red-50');
expect(classes).toContain('dark:bg-red-950/30');
expect(classes).not.toContain('bg-accent/40');
expect(classes).toContain('ring-primary/20');
});
it('washes a checked row that has no tint to keep', () => {
const { container } = renderRow(makeEmail({ keywords: { $seen: true } }));
const classes = rowClasses(container);
expect(classes).toContain('bg-accent/40');
expect(classes).toContain('ring-primary/20');
});
it('leaves the tint alone when the setting is off', () => {
useSettingsStore.setState({ tintListRowsByTag: false });
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
const classes = rowClasses(container);
expect(classes).not.toContain('bg-red-50');
expect(classes).toContain('bg-accent/40');
});
});
+23 -61
View File
@@ -23,8 +23,6 @@ import {
Archive,
FolderInput,
Tag,
X,
Check,
Inbox,
Send,
File,
@@ -34,10 +32,12 @@ import {
EditIcon,
CalendarClock,
XCircle,
Paperclip,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker";
interface Position {
x: number;
@@ -59,12 +59,13 @@ interface EmailContextMenuProps {
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onForwardAsAttachment?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onTogglePinned?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onSetTag?: (tagId: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
@@ -99,20 +100,6 @@ const getMailboxIcon = (role?: string) => {
}
};
// Get all active label/color tag IDs from email keywords
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return tags;
};
export function EmailContextMenu({
email,
position,
@@ -127,12 +114,13 @@ export function EmailContextMenu({
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMoveToMailbox,
onMarkAsSpam,
onUndoSpam,
@@ -149,13 +137,12 @@ export function EmailContextMenu({
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const _tColor = useTranslations("email_viewer.color_tag");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const tEmailViewer = useTranslations("email_viewer");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true;
const currentColors = getCurrentColors(email.keywords);
const currentTagIds = getEmailTagIds(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
@@ -164,13 +151,6 @@ export function EmailContextMenu({
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({
name: kw.label,
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
}));
// Build mailbox tree for move-to submenu with proper hierarchy
const moveTargetIds = new Set(
mailboxes
@@ -277,6 +257,12 @@ export function EmailContextMenu({
onClick={() => handleAction(onForward!)}
disabled={!onForward}
/>
<ContextMenuItem
icon={Paperclip}
label={tEmailViewer("forward_as_attachment")}
onClick={() => handleAction(onForwardAsAttachment!)}
disabled={!onForwardAsAttachment || !email.blobId}
/>
<ContextMenuSeparator />
</>
)}
@@ -370,37 +356,13 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
/>
</>
)}
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
<div className="w-56 max-w-[18rem]">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => onSetTag?.(tagId)}
/>
</div>
</ContextMenuSubMenu>
)}
+3 -3
View File
@@ -15,7 +15,7 @@ interface EmailHoverActionsProps {
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onSetTag?: (tagId: string | null) => void;
onMarkAsSpam?: () => void;
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
// the spam quick-action flips to "not spam".
@@ -76,7 +76,7 @@ export function EmailHoverActions({
onMarkAsRead,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMarkAsSpam,
isInJunk = false,
onUndoSpam,
@@ -112,7 +112,7 @@ export function EmailHoverActions({
onArchive?.();
break;
case "tag":
onSetColorTag?.(null);
onSetTag?.(null);
break;
case "spam":
if (isInJunk) onUndoSpam?.();
-351
View File
@@ -1,351 +0,0 @@
"use client";
import { useTranslations } from "next-intl";
import { useCallback } from "react";
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps {
email: Email;
selected?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
}
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
// In aggregate role-views the selected mailbox is virtual → fall back to the
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isMobile = useUIStore((state) => state.isMobile);
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
const colorTagIds = getEmailColorTags(email.keywords);
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
const { dragHandlers, isDragging } = useEmailDrag({
email,
sourceMailboxId: selectedMailbox,
});
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
email
);
}, [onContextMenu, email]),
isMobile
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
// Shift-click extends the selection from the anchor to here, like
// shift-clicking the row (the checkbox stops propagation, so the
// row's shift handler never runs — replicate it here).
selectRangeEmails(email.id);
} else {
toggleEmailSelection(email.id);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
{...dragHandlers}
{...longPressHandlers}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
? "bg-selection"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !selected && !colorTag && "bg-warning/10",
// Add visual feedback for checked state
isChecked && "ring-2 ring-primary/20 bg-selection/60",
// Drag state visual feedback
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
// Long press visual feedback
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email.id);
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email.id);
} else {
if (selectedEmailIds.size > 0) clearSelection();
onClick?.();
}
}}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onDoubleClick) return;
e.preventDefault();
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && (
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{/* Unread indicator */}
{isUnread && (
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
{/* Avatar */}
{density !== 'extra-compact' && (
<SelectableAvatar
name={sender?.name}
email={sender?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={() => toggleEmailSelection(email.id)}
selectLabel={tBatch('select')}
/>
)}
{/* Content */}
<div className="flex-1 min-w-0">
{isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={cn(
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)}>
{email.subject || t('no_subject')}
</span>
{inlinePreview && (
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
) : (
<>
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{trimmedPreview || t('no_preview_available')}
</p>
)}
</>
)}
</div>
</div>
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
</div>
);
}
+38 -22
View File
@@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
import { useVirtualizer } from "@tanstack/react-virtual";
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { SearchChips } from "@/components/search/search-chips";
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
@@ -33,12 +34,13 @@ interface EmailListProps {
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
onForwardAsAttachment?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onToggleStar?: (email: Email) => void;
onTogglePinned?: (email: Email) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
@@ -63,12 +65,13 @@ export function EmailList({
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
@@ -130,10 +133,20 @@ export function EmailList({
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
/**
* The row the menu was opened on, as the list currently has it. The menu holds
* the message it was handed when it opened, but tags can be applied from
* inside it without dismissing it, so what it draws has to keep up.
*/
const contextMenuEmail = contextMenu.data
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
: null;
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [isProcessing, setIsProcessing] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
// One tag treatment for the whole list, measured from the scroll container.
const tagDisplay = useMeasuredTagDisplay(parentRef);
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -330,6 +343,7 @@ export function EmailList({
}, [density, isFocusedMailLayout, showPreview]);
return (
<TagDisplayContext.Provider value={tagDisplay}>
<div className={cn("flex flex-col min-h-0", className)}>
{/* Batch Actions Toolbar */}
<div
@@ -541,7 +555,7 @@ export function EmailList({
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
onDelete={onDelete ? (email) => onDelete(email) : undefined}
onArchive={onArchive ? (email) => onArchive(email) : undefined}
onSetColorTag={onSetColorTag}
onSetTag={onSetTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
/>
@@ -568,9 +582,9 @@ export function EmailList({
</div>
{/* Context Menu */}
{contextMenu.data && (
{contextMenuEmail && (
<EmailContextMenu
email={contextMenu.data}
email={contextMenuEmail}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
@@ -578,24 +592,25 @@ export function EmailList({
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={effectiveMailboxRole}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
selectedCount={selectedEmailIds.size}
onReply={() => onReply?.(contextMenu.data!)}
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
onForward={() => onForward?.(contextMenu.data!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
onDelete={() => onDelete?.(contextMenu.data!)}
onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
onReply={() => onReply?.(contextMenuEmail!)}
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
onForward={() => onForward?.(contextMenuEmail!)}
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)}
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
onDelete={() => onDelete?.(contextMenuEmail!)}
onArchive={() => onArchive?.(contextMenuEmail!)}
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => {
@@ -642,5 +657,6 @@ export function EmailList({
<ConfirmDialog {...confirmDialogProps} />
</div>
</TagDisplayContext.Provider>
);
}
+80 -158
View File
@@ -12,6 +12,11 @@ import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { TagBadge } from "./tag-badge";
import { TagPicker } from "./tag-picker";
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getEmailTagIds } from "@/lib/thread-utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
import { generateEmailSource } from "@/lib/email-source";
@@ -19,6 +24,7 @@ import {
Reply,
ReplyAll,
Forward,
Paperclip,
Trash2,
Archive,
Star,
@@ -73,7 +79,7 @@ import {
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
@@ -109,11 +115,12 @@ interface EmailViewerProps {
onReply?: (draftText?: string) => void;
onReplyAll?: () => void;
onForward?: () => void;
onForwardAsAttachment?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onToggleStar?: () => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
onQuickReply?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
@@ -198,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment';
};
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
tags.push(
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
);
}
}
return tags;
};
// Helper function to format recipients with contextual display
const _formatRecipients = (
recipients: Array<{ name?: string; email: string }> | undefined,
@@ -621,11 +615,12 @@ export function EmailViewer({
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onDelete,
onArchive,
onToggleStar,
onMarkAsRead,
onSetColorTag,
onSetTag,
onDownloadAttachment,
onQuickReply,
onMarkAsSpam,
@@ -664,6 +659,7 @@ export function EmailViewer({
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { sortTagIds, tagColor } = useKeywordFormat();
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -706,12 +702,6 @@ export function EmailViewer({
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({
name: kw.label,
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
}));
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
@@ -816,8 +806,13 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
const currentTagIds = getEmailTagIds(email?.keywords);
const sortedTagIds = sortTagIds(currentTagIds);
// The header spans the reading pane, so it measures its own width rather than
// inheriting the message list's answer.
const headerTagsRef = useRef<HTMLDivElement>(null);
const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef);
const currentColor = currentTagIds[0] ?? null;
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
@@ -1015,7 +1010,7 @@ export function EmailViewer({
showToolbarLabels,
isLoading,
moveTree.length,
colorOptions.length,
emailKeywords.length,
currentColor,
isInJunkFolder,
isTablet,
@@ -2994,64 +2989,18 @@ export function EmailViewer({
<div ref={tagMenuRef} className="relative">
<button
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColors.length > 0 && "bg-muted/50"
)}
title={t('set_color')}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
title={t('set_tag')}
>
{currentColors.length > 0 ? (
<>
<span className="flex items-center gap-0.5">
{currentColors.slice(0, 3).map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
</span>
)}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
<Tag className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
</button>
{tagMenuOpen && (
<div className="absolute end-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
</>
)}
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</div>
)}
</div>
@@ -3243,7 +3192,7 @@ export function EmailViewer({
</div>
)}
{/* Overflow: tag - submenu */}
{colorOptions.length > 0 && (
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')}
onMouseLeave={() => setMoreMenuSub(null)}
@@ -3257,36 +3206,11 @@ export function EmailViewer({
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'tag' && (
<div className="absolute end-full top-0 me-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
</>
)}
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
</div>
)}
</div>
@@ -3340,6 +3264,16 @@ export function EmailViewer({
</button>
)}
<div className="h-px bg-border my-1" />
{/* Forward as attachment */}
{onForwardAsAttachment && email?.blobId && (
<button
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Paperclip className="w-4 h-4" />
{t('forward_as_attachment')}
</button>
)}
{/* Export email */}
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
@@ -3420,19 +3354,21 @@ export function EmailViewer({
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
</button>
{/* Tag (opens sub-view) */}
{colorOptions.length > 0 && (
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<button
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span>
{currentColors.length > 0 && (
{currentTagIds.length > 0 && (
<div className="flex -space-x-1 me-1">
{currentColors.slice(0, 3).map((c) => {
const opt = colorOptions.find((o) => o.value === c);
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
})}
{sortedTagIds.slice(0, 3).map((tagId) => (
<span
key={tagId}
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
/>
))}
</div>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
@@ -3462,6 +3398,15 @@ export function EmailViewer({
</button>
)}
<div className="h-px bg-border my-1" />
{onForwardAsAttachment && email?.blobId && (
<button
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Paperclip className="w-5 h-5" />
{t('forward_as_attachment')}
</button>
)}
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
@@ -3519,35 +3464,12 @@ export function EmailViewer({
};
return renderMobileNodes(moveTree);
})()}
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
<>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3 text-muted-foreground"
>
<X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
)}
</>
{moreMenuSub === 'tag' && (
<TagPicker
touch
selectedIds={currentTagIds}
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
/>
)}
</div>
</div>
@@ -3605,24 +3527,24 @@ export function EmailViewer({
)} />
</button>
)}
{/* Color tag dots */}
{currentColors.length > 0 && (
<span className="flex items-center gap-0.5">
{currentColors.map((tagId) => {
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
return (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
);
})}
</span>
)}
{isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')}
</span>
)}
</div>
{sortedTagIds.length > 0 && (
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
{sortedTagIds.map((tagId) => (
<TagBadge
key={tagId}
tagId={tagId}
variant={headerTagVariant}
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
/>
))}
</div>
)}
</div>
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="hidden sm:block flex-shrink-0 text-end">
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useShortenedText } from "@/hooks/use-shortened-text";
/**
* How much room the surface has for a tag.
* - `badge` names the tag; `dot` only identifies it by colour.
*/
export type TagBadgeVariant = "badge" | "dot";
/**
* The lozenge shape, shared so anything standing next to a tag lines up with
* it rather than approximating its padding and text size.
*/
export const TAG_LOZENGE_CLASS =
"inline-flex min-w-0 shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium";
/**
* The row a group of tags sits in. Using it for neighbouring lozenges too keeps
* the spacing between them the same as the spacing within them - a wider gap on
* one side is what makes a neighbour look indented.
*/
export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1";
/**
* A tag, drawn the one way tags are drawn.
*
* The lozenge carries the colour in its border and text rather than pairing a
* swatch with plain text: the name is the tag, and the colour is how you pick
* it out of a row at a glance. That also matches every other coloured pill in
* the app, all of which set a text colour alongside the background.
*
* A deep name shortens to fit its own box (`Work/../Acme`) before the browser
* clips it, so the outermost and innermost levels survive.
*/
export function TagBadge({
tagId,
variant,
onRemove,
className,
}: {
tagId: string;
variant: TagBadgeVariant;
/**
* Takes the tag off the message. Only the named form offers it - a dot is the
* size of the control it would have to hold.
*/
onRemove?: () => void;
className?: string;
}) {
const t = useTranslations("email_viewer");
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
const color = tagColor(tagId);
const name = tagName(tagId);
if (variant === "dot") {
return (
<span
className={cn("h-2.5 w-2.5 shrink-0 rounded-full", color.dot, className)}
title={name}
aria-label={name}
/>
);
}
return (
<span
className={cn(
TAG_LOZENGE_CLASS,
"max-w-[12rem] border",
color.fill,
color.border,
color.text,
className,
)}
title={name}
>
<span ref={labelRef} className="min-w-0 truncate">
{shortenedName}
</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ms-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
title={t("remove_tag")}
aria-label={t("remove_tag")}
>
<X className="w-3 h-3" />
</button>
)}
</span>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Check, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
/** Below this many tags a filter box costs more room than it saves. */
const SEARCH_THRESHOLD = 10;
/**
* The list of tags to apply to a message.
*
* Shared by all four places one appears - the toolbar popover, the overflow
* flyout, the mobile sheet and the context menu - because they had drifted into
* four different dot sizes, check alignments and separators, and only one of
* them capped its height.
*
* Nested tags are drawn as a tree rather than repeating the parent's name on
* every child. Filtering flattens it: with a query the hierarchy is noise, and
* the full path is what gets matched.
*/
export function TagPicker({
selectedIds,
onToggle,
touch = false,
}: {
selectedIds: string[];
onToggle: (tagId: string) => void;
/** Larger hit areas for the mobile sheet. */
touch?: boolean;
}) {
const t = useTranslations("email_viewer");
const keywords = useSettingsStore((state) => state.emailKeywords);
const nestedTags = useSettingsStore((state) => state.nestedTags);
const { tagName, tagColor } = useKeywordFormat();
const [query, setQuery] = useState("");
const trimmedQuery = query.trim().toLowerCase();
/**
* Tags on the message this client has no definition for - set from another
* client, or outliving the tag they were made with. Listing them is the only
* way to take one off, and they leave the list as they are deselected because
* nothing but the message itself records that they exist.
*/
const unknownIds = useMemo(
() =>
selectedIds
.filter((id) => !keywords.some((keyword) => keyword.id === id))
.sort((a, b) => tagName(a).localeCompare(tagName(b))),
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
[selectedIds, keywords, tagName],
);
const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD;
const matches = useMemo(
() =>
trimmedQuery
? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) =>
tagName(id).toLowerCase().includes(trimmedQuery),
)
: [],
[keywords, unknownIds, trimmedQuery, tagName],
);
const tree = useMemo(
() => (nestedTags ? buildKeywordTree(keywords) : keywords.map((k) => ({ ...k, children: [], depth: 0 }))),
[keywords, nestedTags],
);
const rowClass = cn(
"w-full text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
touch ? "px-4 py-2.5 min-h-[44px] text-sm gap-3" : "px-3 py-1.5 text-sm",
);
const dotClass = touch ? "w-3.5 h-3.5" : "w-3 h-3";
const checkClass = touch ? "w-4 h-4" : "w-3.5 h-3.5";
const renderRow = (id: string, label: string) => {
const isActive = selectedIds.includes(id);
return (
<button
key={id}
type="button"
role="menuitemcheckbox"
aria-checked={isActive}
onClick={() => onToggle(id)}
className={cn(rowClass, isActive && "bg-accent font-medium")}
title={tagName(id)}
>
<span className={cn("rounded-full flex-shrink-0", dotClass, tagColor(id).dot)} />
<span className="flex-1 min-w-0 truncate">{label}</span>
{isActive && <Check className={cn("ms-auto flex-shrink-0 text-foreground", checkClass)} />}
</button>
);
};
const renderBranch = (nodes: KeywordNode[]) =>
nodes.map((node) => (
<div key={node.id}>
{renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)}
{node.children.length > 0 && <div className="ps-4">{renderBranch(node.children)}</div>}
</div>
));
return (
<>
{showSearch && (
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("tag_filter_placeholder")}
aria-label={t("tag_filter_placeholder")}
className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
)}
<div className="max-h-[min(20rem,60vh)] overflow-y-auto">
{trimmedQuery ? (
matches.length > 0 ? (
matches.map((id) => renderRow(id, tagName(id)))
) : (
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
)
) : (
<>
{renderBranch(tree)}
{unknownIds.length > 0 && (
<>
{keywords.length > 0 && <div className="h-px bg-border my-1" />}
{unknownIds.map((id) => renderRow(id, tagName(id)))}
</>
)}
</>
)}
</div>
</>
);
}
+12
View File
@@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge } from "./tag-badge";
interface ThreadEmailItemProps {
email: Email;
@@ -35,6 +39,11 @@ export function ThreadEmailItem({
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { sortTagIds } = useKeywordFormat();
const { variant: tagVariant } = useTagDisplay();
// A message inside an expanded thread carries its own tags; the collapsed
// header pools them, so without this they disappear on the way in.
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const density = useSettingsStore((state) => state.density);
@@ -178,6 +187,9 @@ export function ThreadEmailItem({
{email.hasAttachment && (
<Paperclip className="w-3 h-3 text-muted-foreground" />
)}
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</div>
{/* Preview snippet */}
+136 -102
View File
@@ -6,11 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
import { useAccountStore } from "@/stores/account-store";
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item";
@@ -34,6 +37,28 @@ function SourceFolderTag({ name }: { name: string }) {
);
}
/**
* How many messages a collapsed thread stands for.
*
* Built from the tag lozenge so it lines up with the tags it sits next to: the
* same shape, and the same group spacing.
*/
function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) {
return (
<span
className={cn(
TAG_LOZENGE_CLASS,
"gap-0.5",
hasUnread ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
title={title}
>
<MessageSquare className="w-3 h-3" />
{count}
</span>
);
}
interface ThreadListItemProps {
thread: ThreadGroup;
isExpanded: boolean;
@@ -50,7 +75,7 @@ interface ThreadListItemProps {
onMarkAsRead?: (email: Email, read: boolean) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
}
@@ -62,18 +87,18 @@ interface SingleEmailItemProps {
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
rowTint: string | null;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onSetTag?: (tagId: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
}
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const isUnread = !email.keywords?.$seen;
@@ -89,7 +114,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { sortTagIds, tagColor } = useKeywordFormat();
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -110,14 +136,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? formatDateTime(email.scheduledSendAt, timeFormat)
: null;
// Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = !tintListRowsByTag ? null : (() => {
if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
})();
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null));
const { dragHandlers, isDragging } = useEmailDrag({
email,
@@ -172,19 +192,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
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 : (
resolvedRowTint ? resolvedRowTint : (
selected
? "bg-accent"
: "bg-background"
),
selected && !resolvedColorTag && "shadow-sm",
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
selected && !resolvedRowTint && "shadow-sm",
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedRowTint && "bg-accent/30",
isChecked && "ring-2 ring-primary/20",
isChecked && !resolvedRowTint && "bg-accent/40",
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
isPressed && "scale-[0.98] ring-2 ring-primary/30",
isPressed && !resolvedRowTint && "bg-muted"
)}
onClick={handleClick}
onDoubleClick={(e) => {
@@ -258,6 +280,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{sender?.name || sender?.email || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
{tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
@@ -281,9 +310,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</>
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -322,6 +348,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
{tagPlacement === 'sender' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
@@ -347,15 +380,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -378,13 +402,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
"min-w-0 flex-1 truncate text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</span>
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
@@ -406,12 +439,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{!email.isScheduled && (
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onSetTag={onSetTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
@@ -440,7 +473,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsRead,
onDelete,
onArchive,
onSetColorTag,
onSetTag,
onMarkAsSpam,
onUndoSpam,
}, ref) {
@@ -497,11 +530,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
);
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
const { sortTagIds, tagColor } = useKeywordFormat();
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// A collapsed row speaks for every message under it, so it carries their tags too.
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null;
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
@@ -518,12 +552,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
rowTint={rowTint}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
/>
@@ -597,19 +631,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{...threadLongPressHandlers}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
colorTag ? colorTag : (
rowTint ? rowTint : (
isSelected
? "bg-accent"
: "bg-background"
),
isSelected && !colorTag && "shadow-sm",
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30",
isSelected && !rowTint && "shadow-sm",
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
rowTint && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !rowTint && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50",
isChecked && "ring-2 ring-primary/20 bg-accent/40",
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
isChecked && "ring-2 ring-primary/20",
isChecked && !rowTint && "bg-accent/40",
isThreadPressed && "scale-[0.98] ring-2 ring-primary/30",
isThreadPressed && !rowTint && "bg-muted"
)}
onClick={handleHeaderClick}
onDoubleClick={(e) => {
@@ -706,22 +742,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/>
)}
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-44',
// Matches SingleEmailItem: the sender column sets where
// every row's tags and subject begin, so the two have to
// agree or thread rows sit 1rem further right.
'w-32 shrink-0 truncate text-sm lg:w-40',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{displayNames.join(', ')}
</span>
<span
className={cn(
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={TAG_GROUP_CLASS}>
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
<span className={cn(
'min-w-0 truncate',
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
@@ -745,9 +784,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</>
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -786,17 +822,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}>
{displayNames.join(", ")}
</span>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
<span className={TAG_GROUP_CLASS}>
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
{tagPlacement === 'sender' && tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
<div className="flex items-center gap-1.5">
{hasPinned && (
@@ -823,15 +857,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
@@ -854,13 +879,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
"min-w-0 flex-1 truncate text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
</span>
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
@@ -882,12 +916,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{!latestEmail.isScheduled && (
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
backgroundClassName={rowTint ? rowTint : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
+3 -1
View File
@@ -18,6 +18,7 @@ import type {
import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
interface FilterRuleModalProps {
rule?: FilterRule;
@@ -89,6 +90,7 @@ export function FilterRuleModal({
const t = useTranslations("settings.filters");
const isEdit = !!rule;
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagName } = useKeywordFormat();
const [name, setName] = useState(rule?.name || "");
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
@@ -477,7 +479,7 @@ export function FilterRuleModal({
>
<option value="">{t("label_placeholder")}</option>
{emailKeywords.map((kw) => (
<option key={kw.id} value={kw.id}>{kw.label}</option>
<option key={kw.id} value={kw.id}>{tagName(kw.id)}</option>
))}
</select>
)}
@@ -0,0 +1,61 @@
'use client';
import { useEffect } from 'react';
import { evictAll } from '@/lib/account-state-manager';
/**
* After a master-user impersonation handoff (`GET /api/auth/impersonate`), the
* server swaps the slot-0 session cookie but the client's *persisted* account
* registry still lists the PREVIOUS account — so the top-left account chip keeps
* showing the old mailbox even though the message list is correctly the new one.
* Only a manual sign-out (which clears `account-registry` / `auth-storage`) fixes
* it, because that state lives in localStorage and the impersonation redirect
* never reconciles it. (Reported downstream: jabali-panel #646.)
*
* The impersonate route now redirects to `/?impersonated=1`. Here we drop the
* stale persisted account + auth state (and the server-derived caches) and
* reload to a clean URL, so the app rehydrates empty and re-derives the single
* account from the fresh session cookie — the same result as the manual
* sign-out-then-reopen, done automatically. Cookies are untouched, so the
* just-granted impersonation session survives the reload.
*/
const STALE_KEYS = [
'account-registry',
'auth-storage',
'identity-storage',
'contact-storage',
'calendar-storage',
'calendar-notification-storage',
];
export function ImpersonationReconciler() {
useEffect(() => {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
if (params.get('impersonated') !== '1') return;
try {
evictAll();
} catch {
/* in-memory snapshots are best-effort */
}
for (const key of STALE_KEYS) {
try {
window.localStorage.removeItem(key);
} catch {
/* ignore storage access errors */
}
}
// Reload to a clean URL (drop the marker) so the now-empty persisted stores
// rehydrate and the app reconnects + re-derives the impersonated account
// from the session cookie. The marker is gone on the second load, so this
// runs exactly once.
params.delete('impersonated');
const query = params.toString();
window.location.replace(window.location.pathname + (query ? `?${query}` : ''));
}, []);
return null;
}
+174 -54
View File
@@ -35,9 +35,19 @@ import {
BellOff,
Mails,
MailOpen,
MoreHorizontal,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import {
buildKeywordTree,
countKeywordNodes,
filterKeywordTree,
hasChildKeywords,
type KeywordNode,
} from "@/lib/keyword-nesting";
import { useShortenedText } from "@/hooks/use-shortened-text";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { isEditableEventTarget } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
@@ -51,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
import { useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { useVacationStore } from "@/stores/vacation-store";
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
@@ -241,6 +251,9 @@ function SidebarRowCounts({
interface SidebarRowProps {
icon: ReactNode;
label: string;
/** Progressively shorter renderings of `label`, longest first. The widest one
* that fits the row is shown; without this the full label is used. */
labelCandidates?: string[];
depth?: number;
isSelected?: boolean;
isVirtual?: boolean;
@@ -266,6 +279,7 @@ interface SidebarRowProps {
function SidebarRow({
icon,
label,
labelCandidates,
depth = 0,
isSelected = false,
isVirtual = false,
@@ -288,6 +302,7 @@ function SidebarRow({
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
return (
<div
@@ -353,7 +368,7 @@ function SidebarRow({
</span>
{!isCollapsed && (
<>
<span className="flex-1 truncate">{label}</span>
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
<SidebarRowCounts
unread={unread}
total={total}
@@ -542,78 +557,120 @@ function MailboxTreeItem({
);
}
const TAG_ICON_COLOR: Record<string, string> = {
red: "text-red-600/75 dark:text-red-400/75",
orange: "text-orange-600/75 dark:text-orange-400/75",
yellow: "text-yellow-600/75 dark:text-yellow-400/75",
green: "text-green-600/75 dark:text-green-400/75",
blue: "text-blue-600/75 dark:text-blue-400/75",
purple: "text-purple-600/75 dark:text-purple-400/75",
pink: "text-pink-600/75 dark:text-pink-400/75",
teal: "text-teal-600/75 dark:text-teal-400/75",
cyan: "text-cyan-600/75 dark:text-cyan-400/75",
indigo: "text-indigo-600/75 dark:text-indigo-400/75",
amber: "text-amber-600/75 dark:text-amber-400/75",
lime: "text-lime-600/75 dark:text-lime-400/75",
gray: "text-gray-500",
};
function ShowAllTagsRow({
hiddenCount,
showAll,
onToggle,
isCollapsed,
}: {
hiddenCount: number;
showAll: boolean;
onToggle: () => void;
isCollapsed: boolean;
}) {
const t = useTranslations('sidebar');
return (
<SidebarRow
icon={<MoreHorizontal className="w-4 h-4 text-muted-foreground" />}
label={showAll ? t('show_fewer_tags') : t('show_all_tags', { count: hiddenCount })}
depth={0}
onClick={onToggle}
isCollapsed={isCollapsed}
/>
);
}
function TagItem({
kw,
isSelected,
node,
selectedKeyword,
expandedTags,
isCollapsed,
onTagSelect,
totalCount,
unreadCount,
onToggleExpand,
tagCounts,
colorful,
}: {
kw: KeywordDefinition;
isSelected: boolean;
node: KeywordNode;
selectedKeyword: string | null;
expandedTags: Set<string>;
isCollapsed: boolean;
onTagSelect?: (keywordId: string | null) => void;
totalCount: number;
unreadCount: number;
onToggleExpand: (keywordId: string) => void;
tagCounts: Record<string, { total: number; unread: number }>;
colorful: boolean;
}) {
const t = useTranslations('notifications');
const palette = KEYWORD_PALETTE[kw.color];
const { tagNameCandidates, tagColor } = useKeywordFormat();
const palette = tagColor(node.id);
const hasChildren = node.children.length > 0;
const isExpanded = expandedTags.has(node.id);
const isSelected = selectedKeyword === node.id;
// Nested rows are placed by their indentation, so they show their own name.
// A root spells out its path, which matters when an intermediate tag is
// missing from this client's settings and the row would otherwise read as a
// bare leaf name.
const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label];
const label = labelCandidates[0];
// Toasts have the room for the whole thing, and no indentation to lean on,
// so they always spell out the full path - otherwise two leaves with the
// same name in different branches (e.g. "Personal/Receipts" and
// "Work/Receipts") would read as the same tag.
const fullLabel = tagNameCandidates(node.id)[0];
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget } = useTagDrop({
tagId: kw.id,
onSuccess: (count, _tagLabel) => {
tagId: node.id,
onSuccess: (count) => {
if (count === 1) {
toast.success(t('email_tagged'), kw.label);
toast.success(t('email_tagged'), fullLabel);
} else {
toast.success(t('emails_tagged', { count }), kw.label);
toast.success(t('emails_tagged', { count }), fullLabel);
}
},
onError: () => {
toast.error(t('tag_failed'), kw.label);
toast.error(t('tag_failed'), fullLabel);
},
});
const tagIcon = colorful ? (
<Tag
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
fill="currentColor"
/>
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
) : (
<span className={cn("w-3 h-3 rounded-full", palette?.dot || "bg-gray-400")} />
<span className={cn("w-3 h-3 rounded-full", palette.dot)} />
);
return (
<SidebarRow
icon={tagIcon}
label={kw.label}
depth={0}
isSelected={isSelected}
unread={unreadCount}
total={totalCount}
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
<>
<SidebarRow
icon={tagIcon}
label={label}
labelCandidates={labelCandidates}
depth={node.depth}
isSelected={isSelected}
unread={tagCounts[node.id]?.unread ?? 0}
total={tagCounts[node.id]?.total ?? 0}
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
hasChildren={hasChildren}
isExpanded={isExpanded}
onExpandToggle={() => onToggleExpand(node.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
<TagItem
key={child.id}
node={child}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
onToggleExpand={onToggleExpand}
tagCounts={tagCounts}
colorful={colorful}
/>
))}
</>
);
}
@@ -737,6 +794,8 @@ export function Sidebar({
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
const [showAllTags, setShowAllTags] = useState(false);
const [foldersExpanded, setFoldersExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarFoldersExpanded');
@@ -779,6 +838,7 @@ export function Sidebar({
return new Set();
});
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const nestedTags = useSettingsStore(s => s.nestedTags);
const isEmbedded = useIsEmbedded();
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
// own AccountSwitcher would be a redundant second account UI in the same
@@ -842,6 +902,37 @@ export function Sidebar({
});
};
useEffect(() => {
const stored = localStorage.getItem('expandedTags');
if (stored) {
try {
const parsed = JSON.parse(stored);
setExpandedTags(new Set(parsed));
} catch (e) {
debug.error('Failed to parse expanded tags:', e);
}
} else {
setExpandedTags(
new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id))
);
}
}, [emailKeywords]);
const handleToggleTagExpand = (keywordId: string) => {
setExpandedTags((prev) => {
const next = new Set(prev);
if (next.has(keywordId)) {
next.delete(keywordId);
} else {
next.add(keywordId);
}
try {
localStorage.setItem('expandedTags', JSON.stringify(Array.from(next)));
} catch { /* storage full or unavailable */ }
return next;
});
};
// When the app renders its own virtual "Scheduled" folder (for delayed
// sends, driven by EmailSubmission), hide the server-provided scheduled
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
@@ -852,6 +943,26 @@ export function Sidebar({
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
// With nesting off every tag is its own root, so the same rows render through
// one path whether or not the ids describe a hierarchy.
const tagTree: KeywordNode[] = nestedTags
? buildKeywordTree(emailKeywords)
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
// Counts arrive from a separate JMAP round trip; until they land, treat every
// "show if unread" tag as visible rather than blanking the section and
// filling it back in.
const tagCountsLoaded = Object.keys(tagCounts).length > 0;
const isTagVisible = (node: KeywordNode) => {
if (showAllTags || node.id === selectedKeyword) return true;
const visibility = getKeywordVisibility(node);
if (visibility === 'hide') return false;
if (visibility === 'unread') return !tagCountsLoaded || (tagCounts[node.id]?.unread ?? 0) > 0;
return true;
};
const visibleTagTree = filterKeywordTree(tagTree, isTagVisible);
const hiddenTagCount = emailKeywords.length - countKeywordNodes(visibleTagTree);
// Multi-account mode (Pro shell): render every connected account as its
// own collapsible group. The active account's tree comes from the
// `mailboxes` prop (which is the live email-store value); other accounts
@@ -1265,18 +1376,27 @@ export function Sidebar({
/>
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
<>
{emailKeywords.map((kw) => (
{visibleTagTree.map((node) => (
<TagItem
key={kw.id}
kw={kw}
isSelected={selectedKeyword === kw.id}
key={node.id}
node={node}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
totalCount={tagCounts[kw.id]?.total ?? 0}
unreadCount={tagCounts[kw.id]?.unread ?? 0}
onToggleExpand={handleToggleTagExpand}
tagCounts={tagCounts}
colorful={colorfulSidebarIcons}
/>
))}
{(hiddenTagCount > 0 || showAllTags) && (
<ShowAllTagsRow
hiddenCount={hiddenTagCount}
showAll={showAllTags}
onToggle={() => setShowAllTags((prev) => !prev)}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</div>
+68 -12
View File
@@ -15,6 +15,8 @@ import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/st
import type { Email } from "@/lib/jmap/types";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { getQuoteBodies } from "@/lib/email-composer-utils";
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
interface ProEmailTabBodyProps {
tabId: string;
@@ -56,7 +58,6 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
const mailboxes = useEmailStore((s) => s.mailboxes);
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
const identities = useIdentityStore((s) => s.identities);
const multiAccountIdentities = useProMultiAccountIdentities();
@@ -136,6 +137,50 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
});
}, [email, openComposeTab, t]);
// Mirrors handleForward, but attaches the original as a message/rfc822
// file instead of quoting it inline - see lib/forward-as-attachment.ts.
// This is a separate, self-contained render path from the main Mail
// tab's EmailViewer (page.tsx) - Pro tabs fetch their own `email` and
// open compose tabs directly via useProTabStore, not through
// page.tsx's pendingDraft/selectedEmail plumbing - so it needed its own
// wiring rather than falling out of the page.tsx fix automatically.
const handleForwardAsAttachment = useCallback(() => {
if (!email) return;
const {
emailDownloadTemplate,
filenameSpaceReplacement,
filenameLowercase,
filenameStripDiacritics,
filenameCollapseSeparators,
} = useSettingsStore.getState();
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
template: emailDownloadTemplate,
spaceReplacement: filenameSpaceReplacement,
lowercase: filenameLowercase,
stripDiacritics: filenameStripDiacritics,
collapseSeparators: filenameCollapseSeparators,
});
if (!payload) return;
composerSessionIdRef.current += 1;
openComposeTab({
sessionId: composerSessionIdRef.current,
mode: 'forward',
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
},
sourceEmailId: email.id,
// payload.subject is intentionally blank for a subject-less email (to
// match normal Forward's *composer* subject behavior - see
// buildForwardAsAttachmentPayload). The Pro tab *title* is a separate
// UI label that still needs a sensible fallback, same as handleForward
// above uses - reusing payload.subject here would give the tab an
// empty title instead of e.g. "Fwd: New message".
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
});
}, [email, openComposeTab, t]);
const handleDelete = useCallback(async () => {
if (!client || !email) return;
try {
@@ -191,21 +236,31 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
}
}, [client, markAsRead]);
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
if (!email || email.id !== emailId) return;
// Drop existing color keywords, optionally add the new one. Matches the
// mail page's local optimistic update.
// Toggle one tag, or clear them all. Matches the mail page's local
// optimistic update, down to reaching tags this client cannot name.
const keywords = { ...(email.keywords ?? {}) };
for (const kw of settingsKeywords) {
delete keywords[`$label:${kw.id}`];
}
if (color) {
const def = settingsKeywords.find((k) => k.color === color);
if (def) keywords[`$label:${def.id}`] = true;
if (tagId === null) {
for (const key of Object.keys(keywords)) {
if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) {
keywords[key] = false;
}
}
} else {
const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId]
.filter(key => keywords[key]);
if (activeKeys.length > 0) {
for (const key of activeKeys) {
keywords[key] = false;
}
} else {
keywords[KEYWORD_PREFIX + tagId] = true;
}
}
setEmailKeywordsLocal(emailId, keywords);
setEmail({ ...email, keywords });
}, [email, settingsKeywords, setEmailKeywordsLocal]);
}, [email, setEmailKeywordsLocal]);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return;
@@ -283,11 +338,12 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onForwardAsAttachment={handleForwardAsAttachment}
onDelete={handleDelete}
onArchive={handleArchive}
onToggleStar={handleToggleStar}
onMarkAsRead={handleMarkAsRead}
onSetColorTag={handleSetColorTag}
onSetTag={handleSetTag}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onEditDraft={handleEditDraft}
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { KeywordSettings } from '../keyword-settings';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
// Mock SettingsSection to just render children
vi.mock('../settings-section', () => ({
// Mock SettingsSection to just render children, keeping the real controls
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('KeywordSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
});
it('renders all default keywords', () => {
@@ -31,11 +32,6 @@ describe('KeywordSettings', () => {
expect(screen.getByText('add_keyword')).toBeInTheDocument();
});
it('renders reset defaults button', () => {
render(<KeywordSettings />);
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
});
it('shows add form when add button clicked', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
@@ -114,18 +110,6 @@ describe('KeywordSettings', () => {
expect(kw?.label).toBe('Crimson');
});
it('resets to defaults when reset button clicked', () => {
// Modify keywords first
useSettingsStore.getState().removeKeyword('red');
useSettingsStore.getState().removeKeyword('blue');
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2);
render(<KeywordSettings />);
fireEvent.click(screen.getByText('reset_defaults'));
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
});
it('normalizes label to id correctly', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
@@ -139,4 +123,90 @@ describe('KeywordSettings', () => {
expect(added.id).toBe('my-custom-tag');
expect(added.label).toBe('My Custom Tag!');
});
it('offers no parent picker while nesting is off', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
});
it('nests a new tag under the selected parent', () => {
useSettingsStore.setState({
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
});
it('shows nested tags by their full path', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
});
it('rejects a path that would exceed the keyword length limit', () => {
const deepId = 'a'.repeat(240);
useSettingsStore.setState({
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
expect(screen.getByText('too_long')).toBeInTheDocument();
expect(screen.getByText('add').closest('button')).toBeDisabled();
});
it('locks the name and the delete action of a tag that has nested tags', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
fireEvent.click(screen.getAllByTitle('edit')[0]);
expect(screen.getByDisplayValue('Work')).toBeDisabled();
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
});
it('defaults every tag to always visible in the sidebar', () => {
render(<KeywordSettings />);
const pickers = screen.getAllByLabelText('visibility_field');
expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length);
pickers.forEach((picker) => expect(picker).toHaveValue('show'));
});
it('stores the visibility chosen for a tag', () => {
render(<KeywordSettings />);
fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } });
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
});
});
+154 -43
View File
@@ -2,15 +2,35 @@
import React, { useState } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import {
useSettingsStore,
KEYWORD_PALETTE,
KEYWORD_PALETTE_ROWS,
getKeywordVisibility,
type KeywordDefinition,
type KeywordVisibility,
} from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { SettingsSection } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
import {
buildKeywordTree,
composeKeywordId,
getParentKeywordId,
hasChildKeywords,
isKeywordDescendant,
keywordLevels,
type KeywordNode,
MAX_KEYWORD_ID_LENGTH,
} from "@/lib/keyword-nesting";
import { formatKeyword, keywordRenderings } from "@/lib/keyword-format";
import { useShortenedText } from "@/hooks/use-shortened-text";
import { TagBadge } from "@/components/email/tag-badge";
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
/** Lighter, base and darker shade of each hue, one row per shade. */
function KeywordColorPicker({
value,
onChange,
@@ -19,19 +39,23 @@ function KeywordColorPicker({
onChange: (color: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1.5">
{PALETTE_KEYS.map((colorKey) => (
<button
key={colorKey}
type="button"
onClick={() => onChange(colorKey)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
KEYWORD_PALETTE[colorKey].dot,
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
aria-label={colorKey}
/>
<div className="space-y-1.5">
{KEYWORD_PALETTE_ROWS.map((row, index) => (
<div key={index} className="flex flex-wrap gap-1.5">
{row.map((colorKey) => (
<button
key={colorKey}
type="button"
onClick={() => onChange(colorKey)}
className={cn(
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
KEYWORD_PALETTE[colorKey].dot,
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
aria-label={colorKey}
/>
))}
</div>
))}
</div>
);
@@ -39,8 +63,11 @@ function KeywordColorPicker({
function KeywordRow({
keyword,
keywords,
nestedTags,
onEdit,
onDelete,
onVisibilityChange,
onDragStart,
onDragOver,
onDrop,
@@ -49,8 +76,11 @@ function KeywordRow({
isDragging,
}: {
keyword: KeywordDefinition;
keywords: KeywordDefinition[];
nestedTags: boolean;
onEdit: () => void;
onDelete: () => void;
onVisibilityChange: (visibility: KeywordVisibility) => void;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
@@ -59,7 +89,16 @@ function KeywordRow({
isDragging: boolean;
}) {
const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color];
const hasChildren = hasChildKeywords(keyword.id, keywords);
// Measured with the prefix attached, since that is what occupies the column.
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
.map((rendering) => KEYWORD_PREFIX + rendering);
const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates);
const visibilityOptions = [
{ value: "show", label: t("visibility.show") },
{ value: "unread", label: t("visibility.unread") },
{ value: "hide", label: t("visibility.hide") },
];
return (
<div
@@ -75,9 +114,23 @@ function KeywordRow({
)}
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
<div className="flex min-w-0 flex-1">
<TagBadge tagId={keyword.id} variant="badge" className="text-xs" />
</div>
<span
ref={keywordRef}
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
title={KEYWORD_PREFIX + keyword.id}
>
{shortenedKeyword}
</span>
<Select
value={getKeywordVisibility(keyword)}
onChange={(value) => onVisibilityChange(value as KeywordVisibility)}
options={visibilityOptions}
ariaLabel={t("visibility_field")}
className="text-xs py-1"
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
@@ -90,8 +143,9 @@ function KeywordRow({
<button
type="button"
onClick={onDelete}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t("delete")}
disabled={hasChildren}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
title={hasChildren ? t("has_children_delete") : t("delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -102,37 +156,74 @@ function KeywordRow({
function KeywordEditForm({
initial,
keywords,
existingIds,
nestedTags,
onSave,
onCancel,
}: {
initial?: KeywordDefinition;
keywords: KeywordDefinition[];
existingIds: string[];
nestedTags: boolean;
onSave: (keyword: KeywordDefinition) => void;
onCancel: () => void;
}) {
const t = useTranslations("settings.keywords");
const [label, setLabel] = useState(initial?.label || "");
const [color, setColor] = useState(initial?.color || "blue");
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
const isEditing = !!initial;
const normalizedId = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
// Renaming or re-parenting a tag rewrites the keyword on every message below
// it, and this client only knows about the tags in its own settings - the
// server may hold nested keywords created elsewhere. Freeze the identity of a
// tag that has children and allow the color to change.
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH;
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
// Every tag is a candidate parent except the one being edited and anything
// already below it, which would detach the branch from its own root.
const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }];
const collectParentOptions = (nodes: KeywordNode[]) => {
for (const node of nodes) {
if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue;
parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) });
collectParentOptions(node.children);
}
};
collectParentOptions(buildKeywordTree(keywords));
const handleSave = () => {
if (!isValid) return;
if (isLocked && initial) {
onSave({ ...initial, color });
return;
}
onSave({ id: normalizedId, label: label.trim(), color });
};
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
{nestedTags && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("parent_field")}
</label>
<Select
value={parentId}
onChange={setParentId}
options={parentOptions}
disabled={isLocked}
ariaLabel={t("parent_field")}
className="w-full"
/>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("label_field")}
@@ -141,15 +232,29 @@ function KeywordEditForm({
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isLocked}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
placeholder={t("label_placeholder")}
autoFocus
maxLength={30}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
{nestedTags && normalizedId.length > 0 && (
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
{KEYWORD_PREFIX + normalizedId}
</p>
)}
{isLocked && (
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
)}
{isDuplicate && (
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
)}
{isTooLong && (
<p className="text-xs text-destructive mt-1">
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
</p>
)}
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
@@ -182,7 +287,7 @@ function KeywordEditForm({
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
@@ -260,12 +365,19 @@ export function KeywordSettings() {
removeKeyword(id);
};
const handleResetDefaults = () => {
reorderKeywords(DEFAULT_KEYWORDS);
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
updateKeyword(id, { visibility });
};
return (
<SettingsSection title={t("title")} description={t("description")}>
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
<ToggleSwitch
checked={nestedTags}
onChange={(checked) => updateSetting("nestedTags", checked)}
/>
</SettingItem>
<div className="space-y-2">
{isMigrating && (
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
@@ -278,7 +390,9 @@ export function KeywordSettings() {
<KeywordEditForm
key={keyword.id}
initial={keyword}
keywords={emailKeywords}
existingIds={existingIds.filter((id) => id !== keyword.id)}
nestedTags={nestedTags}
onSave={handleEdit}
onCancel={() => setEditingId(null)}
/>
@@ -286,11 +400,14 @@ export function KeywordSettings() {
<KeywordRow
key={keyword.id}
keyword={keyword}
keywords={emailKeywords}
nestedTags={nestedTags}
onEdit={() => {
setEditingId(keyword.id);
setIsAdding(false);
}}
onDelete={() => handleDelete(keyword.id)}
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
@@ -303,7 +420,9 @@ export function KeywordSettings() {
{isAdding ? (
<KeywordEditForm
keywords={emailKeywords}
existingIds={existingIds}
nestedTags={nestedTags}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
@@ -320,14 +439,6 @@ export function KeywordSettings() {
<Plus className="w-3.5 h-3.5" />
{t("add_keyword")}
</button>
<button
type="button"
onClick={handleResetDefaults}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("reset_defaults")}
</button>
</div>
)}
</div>
+11 -2
View File
@@ -111,15 +111,24 @@ interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
ariaLabel?: string;
}
export function Select({ value, onChange, options }: SelectProps) {
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label={ariaLabel}
dir="auto"
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
className={cn(
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
className
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>