Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts: # app/api/dev-jmap/[...path]/route.ts
This commit is contained in:
@@ -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,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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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 */}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user