diff --git a/FEATURES.md b/FEATURES.md index f561981a7..2ae105f0a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -17,6 +17,8 @@ - Multi-select for batch archive, delete, move, and tag - Archive directly, by year, or by month - Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them +- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree +- Each tag can be configured to show always, only when there are unread mails or always be hidden - Star or unstar, with a configurable mark-as-read delay - Large mailboxes scroll virtually, and the first page of mail prefetches at login - Quick reply, hover actions, favicon-based sender avatars, recipient popovers diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 98d71d310..a46274155 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -34,6 +34,7 @@ import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; import { cn } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; import { ErrorBoundary, SidebarErrorFallback, @@ -1833,7 +1834,7 @@ export default function Home() { keywords['$pinned'] = true; } - // Same unified-view routing as color tags: write to the email's own + // Same unified-view routing as tags: write to the email's own // account via the login it is reachable through. (#281) const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; @@ -1857,31 +1858,35 @@ export default function Home() { } }; - const handleSetColorTag = async (emailId: string, color: string | null) => { + const handleSetTag = async (emailId: string, tagId: string | null) => { if (!client) return; try { - // Remove any existing label/color tags + // Remove any existing tag keywords const email = emails.find(e => e.id === emailId); if (!email) return; const keywords = { ...email.keywords }; - if (color === null) { - // Remove all label/color tags + if (tagId === null) { + // Remove all tag keywords Object.keys(keywords).forEach(key => { - if (key.startsWith("$label:") || key.startsWith("$color:")) { + if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) { keywords[key] = false; } }); } else { - const jmapKey = `$label:${color}`; - if (keywords[jmapKey]) { - // Toggle off if already active - keywords[jmapKey] = false; + // Both prefixes name the same tag when read, so taking one off has to + // clear whichever spellings are actually set. + const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId] + .filter(key => keywords[key]); + if (activeKeys.length > 0) { + activeKeys.forEach(key => { + keywords[key] = false; + }); } else { // Add the tag without disturbing others - keywords[jmapKey] = true; + keywords[KEYWORD_PREFIX + tagId] = true; } } @@ -1907,7 +1912,7 @@ export default function Home() { // Refresh tag counts fetchTagCounts(client); } catch (error) { - console.error("Failed to set color tag:", error); + console.error("Failed to set tag:", error); } }; @@ -3309,8 +3314,8 @@ export default function Home() { onArchive={async (email) => { await handleArchive(email); }} - onSetColorTag={(emailId, color) => { - handleSetColorTag(emailId, color); + onSetTag={(emailId, color) => { + handleSetTag(emailId, color); }} onMoveToMailbox={async (emailId, mailboxId) => { if (client) { @@ -3534,7 +3539,7 @@ export default function Home() { }} onArchive={() => handleArchive()} onToggleStar={handleToggleStar} - onSetColorTag={handleSetColorTag} + onSetTag={handleSetTag} onMarkAsSpam={() => handleMarkAsSpam()} onUndoSpam={() => handleUndoSpam()} onMarkAsRead={async (emailId, read) => { diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index faec69686..41b34ffad 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -120,7 +120,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1), + id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work/clients/acme': true }, size: 5100, receivedAt: daysAgo(1), from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }], @@ -152,7 +152,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0), + id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:work/clients': true, '$label:receipts': true }, size: 6200, receivedAt: daysAgo(0), from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: '[bulwark-webmail] New issue: Add dark mode toggle (#42)', @@ -181,7 +181,7 @@ const emails: MockEmail[] = [ }, // Newsletter with full HTML { - id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0), + id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal/finance': true }, size: 18200, receivedAt: daysAgo(0), from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Launchpad Weekly #47 - The future of the open web', @@ -228,7 +228,7 @@ const emails: MockEmail[] = [ ], }, { - id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3), + id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal': true }, size: 4100, receivedAt: hoursAgo(3), from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Code review request: JMAP-342 contact import', @@ -255,7 +255,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 4700, receivedAt: daysAgo(1), + id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work': true }, size: 4700, receivedAt: daysAgo(1), from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Your Hetzner invoice is available - February 2026', @@ -355,7 +355,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6), + id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$color:work/archived': true }, size: 4100, receivedAt: daysAgo(6), from: [{ name: 'Stripe Developer', email: 'developer-updates@stripe.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Action required: API v2023-10 deprecation on April 15, 2026', diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx deleted file mode 100644 index 72d41e357..000000000 --- a/components/email/__tests__/email-list-item.test.tsx +++ /dev/null @@ -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 => ({ - 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(), - selectedMailbox: 'inbox', - }); - }); - - it('does not show tag badge when email has no label keyword', () => { - const email = makeEmail({ keywords: { $seen: true } }); - render(); - 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(); - expect(screen.getByText('Red')).toBeInTheDocument(); - }); - - it('shows tag badge for legacy $color: keyword', () => { - const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } }); - render(); - 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(); - // 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(); - expect(screen.getByText('Work')).toBeInTheDocument(); - }); - - it('updates badge when keyword definition changes', () => { - const email = makeEmail({ keywords: { $seen: true, '$label:red': true } }); - const { rerender } = render(); - expect(screen.getByText('Red')).toBeInTheDocument(); - - // Update label name - act(() => { - useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' }); - }); - rerender(); - expect(screen.getByText('Urgent')).toBeInTheDocument(); - expect(screen.queryByText('Red')).not.toBeInTheDocument(); - }); - - it('renders subject even without tag', () => { - const email = makeEmail({ subject: 'Hello World' }); - render(); - 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(); - - 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(); - // 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); - }); -}); diff --git a/components/email/__tests__/tag-badge.test.tsx b/components/email/__tests__/tag-badge.test.tsx new file mode 100644 index 000000000..70775ff6a --- /dev/null +++ b/components/email/__tests__/tag-badge.test.tsx @@ -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(); + expect(screen.getByText('Work/Clients')).toBeInTheDocument(); + }); + + it('names a tag it has no definition for by its id', () => { + render(); + expect(screen.getByText('from-elsewhere')).toBeInTheDocument(); + }); + + it('offers removal only when asked to', () => { + const onRemove = vi.fn(); + const { rerender } = render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'remove_tag' })); + expect(onRemove).toHaveBeenCalledOnce(); + }); + + it('leaves the dot alone, having nowhere to put the control', () => { + render( {}} />); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Work')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/tag-picker.test.tsx b/components/email/__tests__/tag-picker.test.tsx new file mode 100644 index 000000000..048d90964 --- /dev/null +++ b/components/email/__tests__/tag-picker.test.tsx @@ -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( {}} />); + + // 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( {}} />); + 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(); + + 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(); + + 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(); + 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( {}} />); + + 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( {}} />); + expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument(); + + useSettingsStore.setState({ emailKeywords: MANY_TAGS }); + render( {}} />); + 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( {}} />); + + 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( {}} />); + + 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( {}} />); + + expect(container.querySelectorAll('.ps-4').length).toBe(0); + expect(screen.getByText('Clients')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx new file mode 100644 index 000000000..c0935c261 --- /dev/null +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -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 => ({ + 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( + {}} + onEmailSelect={() => {}} + />, + ); +} + +describe('ThreadListItem tag badge', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + useEmailStore.setState({ + selectedEmailIds: new Set(), + 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( + {}} + 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(), + selectedMailbox: 'inbox', + }); + }); + + function renderThread(emails: Email[], expanded = false) { + const [thread] = groupEmailsByThread(emails); + return render( + {}} + 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(), + 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'); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index d74ed5cb0..2cef2b5b4 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -23,8 +23,6 @@ import { Archive, FolderInput, Tag, - X, - Check, Inbox, Send, File, @@ -36,9 +34,10 @@ import { 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; @@ -66,7 +65,7 @@ interface EmailContextMenuProps { onTogglePinned?: () => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMoveToMailbox?: (mailboxId: string) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; @@ -101,20 +100,6 @@ const getMailboxIcon = (role?: string) => { } }; -// Get all active label/color tag IDs from email keywords -const getCurrentColors = (keywords: Record | 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, @@ -135,7 +120,7 @@ export function EmailContextMenu({ onTogglePinned, onDelete, onArchive, - onSetColorTag, + onSetTag, onMoveToMailbox, onMarkAsSpam, onUndoSpam, @@ -152,14 +137,12 @@ export function EmailContextMenu({ }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const tSidebar = useTranslations("sidebar"); - const _tColor = useTranslations("email_viewer.color_tag"); const tEmailViewer = useTranslations("email_viewer"); - const emailKeywords = useSettingsStore((state) => state.emailKeywords); 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 @@ -168,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 @@ -380,37 +356,13 @@ export function EmailContextMenu({ {/* Set tag submenu - only for single email */} {!showBatchActions && ( - - {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> - - handleAction(() => onSetColorTag?.(null))} - /> - - )} + +
+ onSetTag?.(tagId)} + /> +
)} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index a2691da74..2435d1c1d 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -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?.(); diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx deleted file mode 100644 index 44c7e9154..000000000 --- a/components/email/email-list-item.tsx +++ /dev/null @@ -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 ( -
{ - 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)' }} - > -
- {/* Checkbox - only visible when in selection mode */} - {selectedEmailIds.size > 0 && ( - - )} - - {/* Unread indicator */} - {isUnread && ( -
- -
- )} - - {/* Avatar */} - {density !== 'extra-compact' && ( - toggleEmailSelection(email.id)} - selectLabel={tBatch('select')} - /> - )} - - {/* Content */} -
- {isFocusedMailLayout ? ( -
-
- - {sender?.name || sender?.email || 'Unknown'} - -
- - {email.subject || t('no_subject')} - - {inlinePreview && ( - {inlinePreview} - )} -
-
-
- {isPinned && } - {isStarred && } - {isImportant && } - {isAnswered && !isForwarded && } - {isForwarded && !isAnswered && } - {isAnswered && isForwarded && ( - <> - - - - )} - {email.hasAttachment && } - {keywordDefs.map((kd) => ( - - ))} - - {formatDate(email.receivedAt)} - -
-
- ) : ( - <> - {/* First Line: Sender and Date */} -
-
- - {sender?.name || sender?.email || "Unknown"} - -
- {isPinned && ( - - )} - {isStarred && ( - - )} - {isImportant && ( - - Important - - )} - - {isAnswered && !isForwarded && ( - - )} - {isForwarded && !isAnswered && ( - - )} - {isAnswered && isForwarded && ( - <> - - - - )} - {email.hasAttachment && ( - - )} -
-
-
- {keywordDefs.map((kd) => ( - - - {kd.label} - - ))} - - {formatDate(email.receivedAt)} - -
-
- - {/* Second Line: Subject */} -
- {email.subject || t('no_subject')} -
- - {/* Third Line: Preview (controlled by showPreview setting) */} - {showPreview && density !== 'extra-compact' && density !== 'compact' && ( -

- {trimmedPreview || t('no_preview_available')} -

- )} - - )} -
-
- - {/* Hover Quick Actions */} - -
- ); -} \ No newline at end of file diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 6dfe4c338..9251f78cb 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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"; @@ -39,7 +40,7 @@ interface EmailListProps { 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; @@ -70,7 +71,7 @@ export function EmailList({ onTogglePinned, onDelete, onArchive, - onSetColorTag, + onSetTag, onMarkAsSpam, onUndoSpam, onMoveToMailbox, @@ -132,10 +133,20 @@ export function EmailList({ }, [emails, disableThreading, isScheduledView, threadEmailCounts]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); + /** + * 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(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); @@ -332,6 +343,7 @@ export function EmailList({ }, [density, isFocusedMailLayout, showPreview]); return ( +
{/* Batch Actions Toolbar */}
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} /> @@ -570,9 +582,9 @@ export function EmailList({
{/* Context Menu */} - {contextMenu.data && ( + {contextMenuEmail && ( onReply?.(contextMenu.data!)} - onReplyAll={() => onReplyAll?.(contextMenu.data!)} - onForward={() => onForward?.(contextMenu.data!)} - onForwardAsAttachment={() => onForwardAsAttachment?.(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 () => { @@ -645,5 +657,6 @@ export function EmailList({
+
); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0aa7fb975..bbdc0a76b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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"; @@ -74,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"; @@ -115,7 +120,7 @@ interface EmailViewerProps { 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; onMarkAsSpam?: () => void; @@ -200,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st return 'Attachment'; }; -const getCurrentColors = (keywords: Record | 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, @@ -628,7 +620,7 @@ export function EmailViewer({ onArchive, onToggleStar, onMarkAsRead, - onSetColorTag, + onSetTag, onDownloadAttachment, onQuickReply, onMarkAsSpam, @@ -667,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); @@ -709,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(); @@ -819,8 +806,13 @@ export function EmailViewer({ const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [hiddenPriorities, setHiddenPriorities] = useState>(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(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 @@ -1018,7 +1010,7 @@ export function EmailViewer({ showToolbarLabels, isLoading, moveTree.length, - colorOptions.length, + emailKeywords.length, currentColor, isInJunkFolder, isTablet, @@ -2997,64 +2989,18 @@ export function EmailViewer({
{tagMenuOpen && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} +
+ { if (email) onSetTag?.(email.id, tagId); }} + />
)}
@@ -3246,7 +3192,7 @@ export function EmailViewer({
)} {/* Overflow: tag - submenu */} - {colorOptions.length > 0 && ( + {(emailKeywords.length > 0 || currentTagIds.length > 0) && (
setMoreMenuSub('tag')} onMouseLeave={() => setMoreMenuSub(null)} @@ -3260,36 +3206,11 @@ export function EmailViewer({ {moreMenuSub === 'tag' && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} +
+ { if (email) onSetTag?.(email.id, tagId); }} + />
)}
@@ -3433,19 +3354,21 @@ export function EmailViewer({ {isStarred ? t('tooltips.unstar') : t('tooltips.star')} {/* Tag (opens sub-view) */} - {colorOptions.length > 0 && ( + {(emailKeywords.length > 0 || currentTagIds.length > 0) && ( - ); - })} - {currentColors.length > 0 && ( - - )} - + {moreMenuSub === 'tag' && ( + { if (email) onSetTag?.(email.id, tagId); }} + /> )}
@@ -3627,24 +3527,24 @@ export function EmailViewer({ )} /> )} - {/* Color tag dots */} - {currentColors.length > 0 && ( - - {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 ( - - ); - })} - - )} {isImportant && ( {t('important')} )}
+ {sortedTagIds.length > 0 && ( +
+ {sortedTagIds.map((tagId) => ( + onSetTag(email.id, tagId) : undefined} + /> + ))} +
+ )} {/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
diff --git a/components/email/tag-badge.tsx b/components/email/tag-badge.tsx new file mode 100644 index 000000000..ca36f35f1 --- /dev/null +++ b/components/email/tag-badge.tsx @@ -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 ( + + ); + } + + return ( + + + {shortenedName} + + {onRemove && ( + + )} + + ); +} diff --git a/components/email/tag-picker.tsx b/components/email/tag-picker.tsx new file mode 100644 index 000000000..0f0948ef9 --- /dev/null +++ b/components/email/tag-picker.tsx @@ -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 ( + + ); + }; + + const renderBranch = (nodes: KeywordNode[]) => + nodes.map((node) => ( +
+ {renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)} + {node.children.length > 0 &&
{renderBranch(node.children)}
} +
+ )); + + return ( + <> + {showSearch && ( +
+ + 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" + /> +
+ )} + +
+ {trimmedQuery ? ( + matches.length > 0 ? ( + matches.map((id) => renderRow(id, tagName(id))) + ) : ( +

{t("tag_no_matches")}

+ ) + ) : ( + <> + {renderBranch(tree)} + {unknownIds.length > 0 && ( + <> + {keywords.length > 0 &&
} + {unknownIds.map((id) => renderRow(id, tagName(id)))} + + )} + + )} +
+ + ); +} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index bd35f4c59..a2859e666 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -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 && ( )} + {tagIds.map((id) => ( + + ))}
{/* Preview snippet */} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 51503972d..b54733418 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -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 ( + + + {count} + + ); +} + 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( - 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( ?? (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( ? 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( 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( {sender?.name || sender?.email || 'Unknown'}
+ {tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} ( )} {email.hasAttachment && } - {resolvedKeywordDefs.map((kd) => ( - - ))} {showSourceFolder && } {scheduledSendLabel ? ( ( )}> {sender?.name || sender?.email || "Unknown"} + {tagPlacement === 'sender' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )}
{isPinned && ( @@ -347,15 +380,6 @@ const SingleEmailItem = React.forwardRef(
- {resolvedKeywordDefs.map((kd) => ( - - - {kd.label} - - ))} {showSourceFolder && } {scheduledSendLabel ? ( (
-
- {email.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {email.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -406,12 +439,12 @@ const SingleEmailItem = React.forwardRef( {!email.isScheduled && ( 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 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 { @@ -706,22 +742,25 @@ export const ThreadListItem = React.forwardRef )} {displayNames.join(', ')} - - - {emailCount} -
+ + + {tagIds.map((id) => ( + + ))} + )} {hasAttachment && } - {keywordDef && ( - - )} {showSourceFolder && } {scheduledSendLabel ? ( {displayNames.join(", ")} - - - {emailCount} + + + {tagPlacement === 'sender' && tagIds.map((id) => ( + + ))}
{hasPinned && ( @@ -823,15 +857,6 @@ export const ThreadListItem = React.forwardRef
- {keywordDef && ( - - - {keywordDef.label} - - )} {showSourceFolder && } {scheduledSendLabel ? (
-
- {latestEmail.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {latestEmail.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -882,12 +916,12 @@ export const ThreadListItem = React.forwardRef 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'} diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 221a6e7a4..cca7a2799 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -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({ > {emailKeywords.map((kw) => ( - + ))} )} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index bc61bcc60..b3aa1078b 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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 (
{!isCollapsed && ( <> - {label} + {shortenedLabel} = { - 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 ( + } + 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; isCollapsed: boolean; onTagSelect?: (keywordId: string | null) => void; - totalCount: number; - unreadCount: number; + onToggleExpand: (keywordId: string) => void; + tagCounts: Record; 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 ? ( - + ) : ( - + ); return ( - onTagSelect?.(isSelected ? null : kw.id)} - isCollapsed={isCollapsed} - dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} - isValidDropTarget={isValidDropTarget} - /> + <> + onTagSelect?.(isSelected ? null : node.id)} + hasChildren={hasChildren} + isExpanded={isExpanded} + onExpandToggle={() => onToggleExpand(node.id)} + isCollapsed={isCollapsed} + dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} + isValidDropTarget={isValidDropTarget} + /> + + {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( + + ))} + ); } @@ -737,6 +794,8 @@ export function Sidebar({ const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [expandedTags, setExpandedTags] = useState>(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) => ( ))} + {(hiddenTagCount > 0 || showAllTags) && ( + setShowAllTags((prev) => !prev)} + isCollapsed={isCollapsed} + /> + )} )}
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 625a6982c..b37a5abef 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -16,6 +16,7 @@ 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; @@ -57,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(); @@ -236,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; @@ -333,7 +343,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { onArchive={handleArchive} onToggleStar={handleToggleStar} onMarkAsRead={handleMarkAsRead} - onSetColorTag={handleSetColorTag} + onSetTag={handleSetTag} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} onEditDraft={handleEditDraft} diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx index 1c3c5f945..c1664efaf 100644 --- a/components/settings/__tests__/keyword-settings.test.tsx +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -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()), SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, })); 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(); - expect(screen.getByText('reset_defaults')).toBeInTheDocument(); - }); - it('shows add form when add button clicked', () => { render(); 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(); - fireEvent.click(screen.getByText('reset_defaults')); - - expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS); - }); - it('normalizes label to id correctly', () => { render(); 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(); + 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(); + 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(); + + 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(); + 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(); + + 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(); + + 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(); + + fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } }); + + expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread'); + }); }); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index c4fcdb2da..51beee546 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -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 ( -
- {PALETTE_KEYS.map((colorKey) => ( -
))}
); @@ -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 (
-
- {keyword.label} - {"$label:" + keyword.id} +
+ +
+ + {shortenedKeyword} + + +
+ )}