feat: refactor email tagging system to use labels instead of colors

- Updated EmailContextMenu to replace color tag functionality with label tags.
- Modified EmailListItem to display label badges for emails based on keywords.
- Enhanced EmailViewer to include a tag picker for emails.
- Adjusted ThreadListItem to show label badges for email subjects.
- Added tests for email list item and keyword settings to ensure proper functionality of the new tagging system.
- Updated localization files to reflect changes from color tags to labels in multiple languages.
This commit is contained in:
Linus Rath
2026-03-11 18:56:43 +01:00
parent ffeb534740
commit 454d51e283
16 changed files with 610 additions and 134 deletions
@@ -0,0 +1,107 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EmailListItem } from '../email-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import type { Email } from '@/lib/jmap/types';
// Mock the drag hook
vi.mock('@/hooks/use-email-drag', () => ({
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
}));
// Mock identity badge
vi.mock('../email-identity-badge', () => ({
EmailIdentityBadge: () => null,
}));
// Mock auth store
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({ identities: [] }),
}));
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1000,
receivedAt: '2024-01-15T10:00:00Z',
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Test Subject',
hasAttachment: false,
...overrides,
});
describe('EmailListItem tag badge', () => {
beforeEach(() => {
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
});
useEmailStore.setState({
selectedEmailIds: new Set<string>(),
selectedMailbox: 'inbox',
});
});
it('does not show tag badge when email has no label keyword', () => {
const email = makeEmail({ keywords: { $seen: true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
// No keyword label should appear
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
});
});
it('shows tag badge with label when email has $label: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
});
it('shows tag badge for legacy $color: keyword', () => {
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Blue')).toBeInTheDocument();
});
it('does not show badge when keyword id not in settings', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
render(<EmailListItem email={email} />);
expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument();
});
it('shows custom keyword label', () => {
useSettingsStore.setState({
emailKeywords: [
...DEFAULT_KEYWORDS,
{ id: 'work', label: 'Work', color: 'teal' },
],
});
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
render(<EmailListItem email={email} />);
expect(screen.getByText('Work')).toBeInTheDocument();
});
it('updates badge when keyword definition changes', () => {
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
const { rerender } = render(<EmailListItem email={email} />);
expect(screen.getByText('Red')).toBeInTheDocument();
// Update label name
act(() => {
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
});
rerender(<EmailListItem email={email} />);
expect(screen.getByText('Urgent')).toBeInTheDocument();
expect(screen.queryByText('Red')).not.toBeInTheDocument();
});
it('renders subject even without tag', () => {
const email = makeEmail({ subject: 'Hello World' });
render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
});
+19 -41
View File
@@ -21,6 +21,7 @@ import {
FolderInput,
Tag,
X,
Check,
Inbox,
Send,
File,
@@ -270,49 +271,26 @@ export function EmailContextMenu({
<ContextMenuSeparator />
{/* Set color submenu - only for single email */}
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
<div
className="px-3 py-2 flex flex-wrap gap-2"
role="group"
aria-label={t("color_tag")}
onKeyDown={(e) => {
const buttons = Array.from(
e.currentTarget.querySelectorAll<HTMLButtonElement>("button")
);
const idx = buttons.indexOf(e.target as HTMLButtonElement);
if (idx < 0) return;
let next = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = (idx + 1) % buttons.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = (idx - 1 + buttons.length) % buttons.length;
}
if (next >= 0) {
e.preventDefault();
buttons[next].focus();
}
}}
>
{colorOptions.map((option, i) => (
<button
key={option.value}
tabIndex={i === 0 ? 0 : -1}
onClick={() =>
handleAction(() => onSetColorTag?.(option.value))
}
className={cn(
"w-8 h-8 rounded-full hover:scale-110 transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
option.color,
currentColor === option.value &&
"ring-2 ring-offset-2 ring-offset-background ring-foreground"
)}
title={option.name}
aria-label={option.name}
/>
))}
</div>
{colorOptions.map((option) => (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{currentColor === option.value && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
))}
{currentColor && (
<>
<ContextMenuSeparator />
+20 -9
View File
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { Paperclip, Star, Circle, CheckSquare, Square, Tag } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -157,14 +157,25 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || t('no_subject')}
{/* Second Line: Subject + Tag */}
<div className="flex items-center gap-1.5 mb-1">
<span className={cn(
"line-clamp-1 text-sm min-w-0",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || t('no_subject')}
</span>
{keywordDef && (
<span className={cn(
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
+47 -34
View File
@@ -33,7 +33,7 @@ import {
File,
Shield,
Image,
Circle,
Tag,
X,
Check,
AlertTriangle,
@@ -797,57 +797,70 @@ export function EmailViewer({
<div className="w-px h-5 bg-border mx-1 hidden lg:block" />
{/* Compact Dynamic Color Picker - hidden on mobile/tablet */}
{/* Tag Picker - hidden on mobile/tablet */}
<div className="relative group hidden lg:block">
<button
className="h-8 w-8 rounded hover:bg-muted flex items-center justify-center"
className={cn(
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
currentColor && "bg-muted/50"
)}
title={t('set_color')}
>
{(() => {
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
return dotClass
? <div className={cn("w-4 h-4 rounded-full", dotClass)} />
: <Circle className="w-4 h-4 text-gray-400" />;
return dotClass ? (
<>
<span className={cn("w-3 h-3 rounded-full", dotClass)} />
<span className="text-xs font-medium text-foreground">{kw!.label}</span>
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{t('tag')}</span>
</>
);
})()}
</button>
{/* Colors appear on hover */}
<div className="absolute right-0 top-full mt-1 p-1.5 bg-background rounded-lg shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
<div className="flex gap-1">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => {
if (email) {
onSetColorTag?.(email.id, option.value);
}
}}
className={cn(
"w-6 h-6 rounded-full hover:scale-110 transition-transform",
option.color,
currentColor === option.value && "ring-2 ring-offset-1 ring-gray-400"
)}
title={option.name}
/>
))}
{currentColor && (
<div className="w-px bg-gray-200 dark:bg-gray-700 mx-0.5" />
)}
{currentColor && (
{/* Tag dropdown on hover */}
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => {
if (email) {
onSetColorTag?.(email.id, option.value);
}
}}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && (
<Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />
)}
</button>
))}
{currentColor && (
<>
<div className="h-px bg-border my-1" />
<button
onClick={() => {
if (email) {
onSetColorTag?.(email.id, null);
}
}}
className="w-6 h-6 rounded-full border border-gray-300 dark:border-gray-600 hover:bg-gray-100 hover:bg-muted flex items-center justify-center"
title={t('remove_color')}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 text-muted-foreground" />
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
)}
</div>
</>
)}
</div>
</div>
+40 -18
View File
@@ -44,12 +44,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isChecked = selectedEmailIds.has(email.id);
// Resolve color from keyword definitions if not passed directly
// Resolve color and keyword definition from keyword definitions if not passed directly
const tagId = getEmailColorTag(email.keywords);
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null;
const resolvedColorTag = (() => {
if (colorTag) return colorTag;
const tagId = getEmailColorTag(email.keywords);
const kw = tagId ? emailKeywords.find(k => k.id === tagId) : null;
return kw ? KEYWORD_PALETTE[kw.color]?.bg ?? null : null;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
})();
const { dragHandlers, isDragging } = useEmailDrag({
@@ -139,13 +139,24 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</span>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
<div className="flex items-center gap-1.5 mb-1">
<span className={cn(
"line-clamp-1 text-sm min-w-0",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || "(no subject)"}
</span>
{resolvedKeywordDef && (
<span className={cn(
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
</span>
)}
</div>
{showPreview && (
@@ -353,13 +364,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</span>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
<div className="flex items-center gap-1.5 mb-1">
<span className={cn(
"line-clamp-1 text-sm min-w-0",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{latestEmail.subject || "(no subject)"}
</span>
{keywordDef && (
<span className={cn(
"flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
</div>
{showPreview && (
@@ -0,0 +1,142 @@
import { render, screen, fireEvent } from '@testing-library/react';
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', () => ({
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('KeywordSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
});
it('renders all default keywords', () => {
render(<KeywordSettings />);
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.getByText(kw.label)).toBeInTheDocument();
});
});
it('shows keyword JMAP id', () => {
render(<KeywordSettings />);
expect(screen.getByText('$label:red')).toBeInTheDocument();
expect(screen.getByText('$label:blue')).toBeInTheDocument();
});
it('renders add keyword button', () => {
render(<KeywordSettings />);
expect(screen.getByText('add_keyword')).toBeInTheDocument();
});
it('renders reset defaults button', () => {
render(<KeywordSettings />);
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
});
it('shows add form when add button clicked', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.getByPlaceholderText('label_placeholder')).toBeInTheDocument();
// Cancel and save buttons should appear
expect(screen.getByText('cancel')).toBeInTheDocument();
expect(screen.getByText('add')).toBeInTheDocument();
});
it('adds a new keyword through the form', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
const input = screen.getByPlaceholderText('label_placeholder');
fireEvent.change(input, { target: { value: 'Important' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
expect(keywords).toHaveLength(DEFAULT_KEYWORDS.length + 1);
expect(keywords[keywords.length - 1].label).toBe('Important');
expect(keywords[keywords.length - 1].id).toBe('important');
});
it('prevents adding keyword with duplicate id', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
const input = screen.getByPlaceholderText('label_placeholder');
fireEvent.change(input, { target: { value: 'Red' } });
// Should show duplicate warning
expect(screen.getByText('id_exists')).toBeInTheDocument();
});
it('cancels add form when cancel clicked', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.getByPlaceholderText('label_placeholder')).toBeInTheDocument();
fireEvent.click(screen.getByText('cancel'));
expect(screen.queryByPlaceholderText('label_placeholder')).not.toBeInTheDocument();
});
it('deletes keyword when delete button clicked', () => {
render(<KeywordSettings />);
// Find delete buttons (title="delete")
const deleteButtons = screen.getAllByTitle('delete');
expect(deleteButtons.length).toBe(DEFAULT_KEYWORDS.length);
// Delete the first keyword
fireEvent.click(deleteButtons[0]);
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 1);
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')).toBeUndefined();
});
it('shows edit form when edit button clicked', () => {
render(<KeywordSettings />);
const editButtons = screen.getAllByTitle('edit');
fireEvent.click(editButtons[0]); // edit first keyword (Red)
const input = screen.getByDisplayValue('Red');
expect(input).toBeInTheDocument();
expect(screen.getByText('save')).toBeInTheDocument();
});
it('updates keyword label through edit form', () => {
render(<KeywordSettings />);
const editButtons = screen.getAllByTitle('edit');
fireEvent.click(editButtons[0]); // edit "Red"
const input = screen.getByDisplayValue('Red');
fireEvent.change(input, { target: { value: 'Crimson' } });
fireEvent.click(screen.getByText('save'));
const kw = useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red');
expect(kw?.label).toBe('Crimson');
});
it('resets to defaults when reset button clicked', () => {
// Modify keywords first
useSettingsStore.getState().removeKeyword('red');
useSettingsStore.getState().removeKeyword('blue');
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2);
render(<KeywordSettings />);
fireEvent.click(screen.getByText('reset_defaults'));
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
});
it('normalizes label to id correctly', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
const input = screen.getByPlaceholderText('label_placeholder');
fireEvent.change(input, { target: { value: 'My Custom Tag!' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
const added = keywords[keywords.length - 1];
expect(added.id).toBe('my-custom-tag');
expect(added.label).toBe('My Custom Tag!');
});
});