@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { EmailListItem } from '../email-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// Mock the drag hook
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
|
||||
// Mock identity badge
|
||||
vi.mock('../email-identity-badge', () => ({
|
||||
EmailIdentityBadge: () => null,
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: () => ({ identities: [] }),
|
||||
}));
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('EmailListItem tag badge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show tag badge when email has no label keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
// No keyword label should appear
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows tag badge with label when email has $label: keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows tag badge for legacy $color: keyword', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$color:blue': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a gray fallback badge when keyword id is not in settings', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
// Unknown tags fall back to the raw id as label with a gray dot
|
||||
// (see email-list-item.tsx: keywordDefs fallback).
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows custom keyword label', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
...DEFAULT_KEYWORDS,
|
||||
{ id: 'work', label: 'Work', color: 'teal' },
|
||||
],
|
||||
});
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:work': true } });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates badge when keyword definition changes', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
const { rerender } = render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
|
||||
// Update label name
|
||||
act(() => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
||||
});
|
||||
rerender(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders subject even without tag', () => {
|
||||
const email = makeEmail({ subject: 'Hello World' });
|
||||
render(<EmailListItem email={email} />);
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inline preview text in focused mail layout', () => {
|
||||
useSettingsStore.setState({
|
||||
showPreview: true,
|
||||
mailLayout: 'focus',
|
||||
});
|
||||
const email = makeEmail({ preview: 'Inline preview content' });
|
||||
const { container } = render(<EmailListItem email={email} />);
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailListItem shift-range checkbox', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
|
||||
});
|
||||
|
||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||
// selection mode active (so the checkbox renders), anchor on e1
|
||||
useEmailStore.setState({
|
||||
emails: [e1, e2, e3],
|
||||
selectedEmailIds: new Set(['e1']),
|
||||
lastSelectedEmailId: 'e1',
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
|
||||
render(<EmailListItem email={e3} />);
|
||||
// the checkbox is the first button in the row (shown in selection mode)
|
||||
const checkbox = screen.getAllByRole('button')[0];
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
|
||||
const sel = useEmailStore.getState().selectedEmailIds;
|
||||
expect(sel.has('e1')).toBe(true);
|
||||
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
|
||||
expect(sel.has('e3')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TagBadge } from '../tag-badge';
|
||||
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
];
|
||||
|
||||
describe('TagBadge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
it('names the tag by its full path', () => {
|
||||
render(<TagBadge tagId="work/clients" variant="badge" />);
|
||||
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a tag it has no definition for by its id', () => {
|
||||
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
|
||||
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers removal only when asked to', () => {
|
||||
const onRemove = vi.fn();
|
||||
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('leaves the dot alone, having nowhere to put the control', () => {
|
||||
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Work')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TagPicker } from '../tag-picker';
|
||||
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
{ id: 'work/clients/acme', label: 'Acme', color: 'red' },
|
||||
{ id: 'personal', label: 'Personal', color: 'purple' },
|
||||
];
|
||||
|
||||
/** Ten tags is the point at which the filter box appears. */
|
||||
const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `tag-${i}`,
|
||||
label: i === 0 ? 'Invoices' : `Tag ${i}`,
|
||||
color: 'blue',
|
||||
}));
|
||||
|
||||
describe('TagPicker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
it('names a nested tag by its own label, not the whole path', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
// The tree conveys the hierarchy, so a child needs only its own name.
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('indents each level below its parent', () => {
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
const acme = screen.getByText('Acme');
|
||||
|
||||
// Two levels down: two nested indent wrappers between it and the list.
|
||||
const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4');
|
||||
expect(indents).not.toBeNull();
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(2);
|
||||
});
|
||||
|
||||
it('marks the applied tags and reports toggles by id', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<TagPicker selectedIds={['work/clients']} onToggle={onToggle} />);
|
||||
|
||||
const row = screen.getByText('Clients').closest('button')!;
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onToggle).toHaveBeenCalledWith('work/clients');
|
||||
});
|
||||
|
||||
it('lists a tag it has no definition for, so it can be taken off', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
|
||||
|
||||
const row = screen.getByText('from-elsewhere').closest('button')!;
|
||||
expect(row).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
|
||||
|
||||
// Nothing but the message says it exists, so deselecting is the last of it.
|
||||
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
|
||||
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts undefined tags towards the filter box, and matches them', () => {
|
||||
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
|
||||
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
|
||||
expect(within(container).getByText('stray-3')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the filter box until the list is long enough to need one', () => {
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument();
|
||||
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('flattens to matches while filtering, and says so when there are none', () => {
|
||||
useSettingsStore.setState({ emailKeywords: MANY_TAGS });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } });
|
||||
expect(within(container).getByText('Invoices')).toBeInTheDocument();
|
||||
expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } });
|
||||
expect(within(container).getByText('tag_no_matches')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches the full path, so a child is reachable by its parent name', () => {
|
||||
useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } });
|
||||
// Filtered rows are flat, so they carry the whole path.
|
||||
expect(within(container).getByText('Work/Clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists tags flat when nesting is off', () => {
|
||||
useSettingsStore.setState({ nestedTags: false });
|
||||
const { container } = render(<TagPicker selectedIds={[]} onToggle={() => {}} />);
|
||||
|
||||
expect(container.querySelectorAll('.ps-4').length).toBe(0);
|
||||
expect(screen.getByText('Clients')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ThreadListItem } from '../thread-list-item';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { groupEmailsByThread } from '@/lib/thread-utils';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/hooks/use-email-drag', () => ({
|
||||
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: () => ({ identities: [] }),
|
||||
}));
|
||||
|
||||
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
||||
id: 'email-1',
|
||||
threadId: 'thread-1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1000,
|
||||
receivedAt: '2024-01-15T10:00:00Z',
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Test Subject',
|
||||
hasAttachment: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A one-message thread, built through the real grouping so the fixture cannot
|
||||
* drift from what the list actually feeds this component. `ThreadListItem`
|
||||
* delegates to `SingleEmailItem` at that size, which is what draws every
|
||||
* single-message row in the app.
|
||||
*/
|
||||
function renderRow(email: Email) {
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
return render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThreadListItem tag badge', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show a tag badge when the email has no label keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(screen.queryByText(kw.label)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a tag badge for a $label: keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a tag badge for the legacy $color: keyword', () => {
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$color:blue': true } }));
|
||||
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw id when the tag is not in settings', () => {
|
||||
// A keyword created by another client, or one whose definition was deleted.
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }));
|
||||
|
||||
expect(screen.getByText('unknown-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a custom tag label', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS, { id: 'work', label: 'Work', color: 'teal' }],
|
||||
});
|
||||
renderRow(makeEmail({ keywords: { $seen: true, '$label:work': true } }));
|
||||
|
||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('follows a renamed tag definition', () => {
|
||||
const email = makeEmail({ keywords: { $seen: true, '$label:red': true } });
|
||||
const { rerender } = renderRow(email);
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useSettingsStore.getState().updateKeyword('red', { label: 'Urgent' });
|
||||
});
|
||||
const [thread] = groupEmailsByThread([email]);
|
||||
rerender(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Urgent')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Red')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem multi-message thread', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
function renderThread(emails: Email[], expanded = false) {
|
||||
const [thread] = groupEmailsByThread(emails);
|
||||
return render(
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={expanded}
|
||||
expandedEmails={expanded ? emails : undefined}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('carries the tags of every message, not just the first', () => {
|
||||
// A collapsed row stands in for the whole thread, so a tag applied only to
|
||||
// a later message still has to surface.
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText('Red')).toBeInTheDocument();
|
||||
expect(screen.getByText('Blue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names a tag shared by several messages once', () => {
|
||||
renderThread([
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
]);
|
||||
|
||||
expect(screen.getAllByText('Red')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows each message its own tags once the thread is expanded', () => {
|
||||
renderThread(
|
||||
[
|
||||
makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
// Once on the header and once on the message that carries it.
|
||||
expect(screen.getAllByText('Red').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem row content', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the subject without a tag', () => {
|
||||
renderRow(makeEmail({ subject: 'Hello World' }));
|
||||
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders preview text inline in the focused layout', () => {
|
||||
useSettingsStore.setState({ showPreview: true, mailLayout: 'focus' });
|
||||
const { container } = renderRow(makeEmail({ preview: 'Inline preview content' }));
|
||||
|
||||
expect(screen.getByText('Test Subject')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
|
||||
// Focused rows are one line: the preview shares the subject's element
|
||||
// rather than getting a paragraph of its own.
|
||||
expect(container.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem shift-range checkbox', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
});
|
||||
|
||||
it('shift-clicking the checkbox extends the selection from the anchor', () => {
|
||||
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
|
||||
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
|
||||
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
|
||||
// Selection mode active so the checkbox renders, with the anchor on e1.
|
||||
useEmailStore.setState({
|
||||
emails: [e1, e2, e3],
|
||||
selectedEmailIds: new Set(['e1']),
|
||||
lastSelectedEmailId: 'e1',
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
|
||||
renderRow(e3);
|
||||
const checkbox = screen.getAllByRole('button')[0];
|
||||
act(() => {
|
||||
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
|
||||
});
|
||||
|
||||
const selected = useEmailStore.getState().selectedEmailIds;
|
||||
expect(selected.has('e1')).toBe(true);
|
||||
expect(selected.has('e2')).toBe(true); // the row in between got filled in
|
||||
expect(selected.has('e3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem row tint', () => {
|
||||
const rowClasses = (container: HTMLElement) =>
|
||||
container.querySelector('[data-email-id="email-1"]')!.className.split(' ');
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
tintListRowsByTag: true,
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set(['email-1']),
|
||||
selectedMailbox: 'inbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a checked row tinted, and says so to either theme', () => {
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).toContain('bg-red-50');
|
||||
expect(classes).toContain('dark:bg-red-950/30');
|
||||
expect(classes).not.toContain('bg-accent/40');
|
||||
expect(classes).toContain('ring-primary/20');
|
||||
});
|
||||
|
||||
it('washes a checked row that has no tint to keep', () => {
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).toContain('bg-accent/40');
|
||||
expect(classes).toContain('ring-primary/20');
|
||||
});
|
||||
|
||||
it('leaves the tint alone when the setting is off', () => {
|
||||
useSettingsStore.setState({ tintListRowsByTag: false });
|
||||
const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } }));
|
||||
const classes = rowClasses(container);
|
||||
|
||||
expect(classes).not.toContain('bg-red-50');
|
||||
expect(classes).toContain('bg-accent/40');
|
||||
});
|
||||
});
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
Archive,
|
||||
FolderInput,
|
||||
Tag,
|
||||
X,
|
||||
Check,
|
||||
Inbox,
|
||||
Send,
|
||||
File,
|
||||
@@ -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<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
export function EmailContextMenu({
|
||||
email,
|
||||
position,
|
||||
@@ -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 && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitem"
|
||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="flex-1">{option.name}</span>
|
||||
{isActive && (
|
||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={X}
|
||||
label={t("remove_color")}
|
||||
onClick={() => handleAction(() => onSetColorTag?.(null))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => onSetTag?.(tagId)}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ interface EmailHoverActionsProps {
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
|
||||
// the spam quick-action flips to "not spam".
|
||||
@@ -76,7 +76,7 @@ export function EmailHoverActions({
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
isInJunk = false,
|
||||
onUndoSpam,
|
||||
@@ -112,7 +112,7 @@ export function EmailHoverActions({
|
||||
onArchive?.();
|
||||
break;
|
||||
case "tag":
|
||||
onSetColorTag?.(null);
|
||||
onSetTag?.(null);
|
||||
break;
|
||||
case "spam":
|
||||
if (isInJunk) onUndoSpam?.();
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback } from "react";
|
||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { getEmailColorTags } from "@/lib/thread-utils";
|
||||
|
||||
interface EmailListItemProps {
|
||||
email: Email;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const { identities } = useAuthStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isPinned = email.keywords?.['$pinned'] === true;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
|
||||
// In aggregate role-views the selected mailbox is virtual → fall back to the
|
||||
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
|
||||
// Resolve color tags using keyword definitions from settings; unknown tags fall back to gray
|
||||
const colorTagIds = getEmailColorTags(email.keywords);
|
||||
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
// Use first tag for background coloring
|
||||
const keywordDef = keywordDefs[0] ?? null;
|
||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
|
||||
// Drag and drop functionality
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
|
||||
email
|
||||
);
|
||||
}, [onContextMenu, email]),
|
||||
isMobile
|
||||
);
|
||||
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
// Shift-click extends the selection from the anchor to here, like
|
||||
// shift-clicking the row (the checkbox stops propagation, so the
|
||||
// row's shift handler never runs — replicate it here).
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
toggleEmailSelection(email.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...dragHandlers}
|
||||
{...longPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
// Apply color tag as background, with selected and unread states
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
? "bg-selection"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !selected && !colorTag && "bg-warning/10",
|
||||
// Add visual feedback for checked state
|
||||
isChecked && "ring-2 ring-primary/20 bg-selection/60",
|
||||
// Drag state visual feedback
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||
// Long press visual feedback
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleEmailSelection(email.id);
|
||||
} else if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
if (selectedEmailIds.size > 0) clearSelection();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
if (!onDoubleClick) return;
|
||||
e.preventDefault();
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<div
|
||||
className={cn('px-4', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
|
||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
||||
>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
className={cn(
|
||||
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||
!isFocusedMailLayout && 'mt-2',
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
isChecked && "text-primary"
|
||||
)}
|
||||
>
|
||||
{isChecked ? (
|
||||
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
|
||||
) : (
|
||||
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-unread text-unread" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{density !== 'extra-compact' && (
|
||||
<SelectableAvatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size={isFocusedMailLayout ? "sm" : "md"}
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
disableImages={hideJunkAvatarImages}
|
||||
checked={isChecked}
|
||||
onToggle={() => toggleEmailSelection(email.id)}
|
||||
selectLabel={tBatch('select')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{isAnswered && isForwarded && (
|
||||
<>
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
<span className={cn(
|
||||
'text-xs tabular-nums',
|
||||
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
)}
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
|
||||
Important
|
||||
</span>
|
||||
)}
|
||||
<EmailIdentityBadge email={email} identities={identities} compact={true} />
|
||||
{isAnswered && !isForwarded && (
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{isForwarded && !isAnswered && (
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{isAnswered && isForwarded && (
|
||||
<>
|
||||
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{keywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn(
|
||||
"text-xs tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={colorTag ? colorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { SearchChips } from "@/components/search/search-chips";
|
||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
|
||||
@@ -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<Email>();
|
||||
/**
|
||||
* The row the menu was opened on, as the list currently has it. The menu holds
|
||||
* the message it was handed when it opened, but tags can be applied from
|
||||
* inside it without dismissing it, so what it draws has to keep up.
|
||||
*/
|
||||
const contextMenuEmail = contextMenu.data
|
||||
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
||||
: null;
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// One tag treatment for the whole list, measured from the scroll container.
|
||||
const tagDisplay = useMeasuredTagDisplay(parentRef);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -332,6 +343,7 @@ export function EmailList({
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
return (
|
||||
<TagDisplayContext.Provider value={tagDisplay}>
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
{/* Batch Actions Toolbar */}
|
||||
<div
|
||||
@@ -543,7 +555,7 @@ export function EmailList({
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onSetTag={onSetTag}
|
||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
|
||||
/>
|
||||
@@ -570,9 +582,9 @@ export function EmailList({
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.data && (
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
email={contextMenu.data}
|
||||
email={contextMenuEmail}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onClose={closeContextMenu}
|
||||
@@ -580,25 +592,25 @@ export function EmailList({
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
currentMailboxRole={effectiveMailboxRole}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
|
||||
selectedCount={selectedEmailIds.size}
|
||||
onReply={() => onReply?.(contextMenu.data!)}
|
||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
||||
onForward={() => onForward?.(contextMenu.data!)}
|
||||
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({
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</TagDisplayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { generateEmailSource } from "@/lib/email-source";
|
||||
@@ -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<void>;
|
||||
onMarkAsSpam?: () => void;
|
||||
@@ -200,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
|
||||
return 'Attachment';
|
||||
};
|
||||
|
||||
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||
tags.push(
|
||||
key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
// Helper function to format recipients with contextual display
|
||||
const _formatRecipients = (
|
||||
recipients: Array<{ name?: string; email: string }> | undefined,
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
|
||||
const currentColors = getCurrentColors(email?.keywords);
|
||||
const currentColor = currentColors[0] ?? null;
|
||||
const currentTagIds = getEmailTagIds(email?.keywords);
|
||||
const sortedTagIds = sortTagIds(currentTagIds);
|
||||
// The header spans the reading pane, so it measures its own width rather than
|
||||
// inheriting the message list's answer.
|
||||
const headerTagsRef = useRef<HTMLDivElement>(null);
|
||||
const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef);
|
||||
const currentColor = currentTagIds[0] ?? null;
|
||||
|
||||
// Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
|
||||
// onRenderEmailBody hook. Verification/decryption status UI is provided by the
|
||||
@@ -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({
|
||||
<div ref={tagMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
className={cn(
|
||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||
currentColors.length > 0 && "bg-muted/50"
|
||||
)}
|
||||
title={t('set_color')}
|
||||
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
|
||||
title={t('set_tag')}
|
||||
>
|
||||
{currentColors.length > 0 ? (
|
||||
<>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.slice(0, 3).map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||
})}
|
||||
</span>
|
||||
{showToolbarLabels && currentColors.length === 1 && (
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag className="w-4 h-4 text-muted-foreground" />
|
||||
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
|
||||
</>
|
||||
)}
|
||||
<Tag className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
|
||||
</button>
|
||||
{tagMenuOpen && (
|
||||
<div className="absolute end-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<X className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3246,7 +3192,7 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
{/* Overflow: tag - submenu */}
|
||||
{colorOptions.length > 0 && (
|
||||
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
|
||||
onMouseEnter={() => setMoreMenuSub('tag')}
|
||||
onMouseLeave={() => setMoreMenuSub(null)}
|
||||
@@ -3260,36 +3206,11 @@ export function EmailViewer({
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<div className="absolute end-full top-0 me-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<X className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3433,19 +3354,21 @@ export function EmailViewer({
|
||||
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||
</button>
|
||||
{/* Tag (opens sub-view) */}
|
||||
{colorOptions.length > 0 && (
|
||||
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
|
||||
<button
|
||||
onClick={() => setMoreMenuSub('tag')}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Tag className="w-5 h-5" />
|
||||
<span className="flex-1">{t('tag')}</span>
|
||||
{currentColors.length > 0 && (
|
||||
{currentTagIds.length > 0 && (
|
||||
<div className="flex -space-x-1 me-1">
|
||||
{currentColors.slice(0, 3).map((c) => {
|
||||
const opt = colorOptions.find((o) => o.value === c);
|
||||
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
|
||||
})}
|
||||
{sortedTagIds.slice(0, 3).map((tagId) => (
|
||||
<span
|
||||
key={tagId}
|
||||
className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -3541,35 +3464,12 @@ export function EmailViewer({
|
||||
};
|
||||
return renderMobileNodes(moveTree);
|
||||
})()}
|
||||
{moreMenuSub === 'tag' && colorOptions.length > 0 && (
|
||||
<>
|
||||
{colorOptions.map((option) => {
|
||||
const isActive = currentColors.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
|
||||
isActive && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentColors.length > 0 && (
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<TagPicker
|
||||
touch
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3627,24 +3527,24 @@ export function EmailViewer({
|
||||
)} />
|
||||
</button>
|
||||
)}
|
||||
{/* Color tag dots */}
|
||||
{currentColors.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||
return (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{isImportant && (
|
||||
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
|
||||
{t('important')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sortedTagIds.length > 0 && (
|
||||
<div ref={headerTagsRef} className="mt-1.5 flex flex-wrap items-center gap-1">
|
||||
{sortedTagIds.map((tagId) => (
|
||||
<TagBadge
|
||||
key={tagId}
|
||||
tagId={tagId}
|
||||
variant={headerTagVariant}
|
||||
onRemove={onSetTag && email ? () => onSetTag(email.id, tagId) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
|
||||
<div className="hidden sm:block flex-shrink-0 text-end">
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
|
||||
/**
|
||||
* How much room the surface has for a tag.
|
||||
* - `badge` names the tag; `dot` only identifies it by colour.
|
||||
*/
|
||||
export type TagBadgeVariant = "badge" | "dot";
|
||||
|
||||
/**
|
||||
* The lozenge shape, shared so anything standing next to a tag lines up with
|
||||
* it rather than approximating its padding and text size.
|
||||
*/
|
||||
export const TAG_LOZENGE_CLASS =
|
||||
"inline-flex min-w-0 shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium";
|
||||
|
||||
/**
|
||||
* The row a group of tags sits in. Using it for neighbouring lozenges too keeps
|
||||
* the spacing between them the same as the spacing within them - a wider gap on
|
||||
* one side is what makes a neighbour look indented.
|
||||
*/
|
||||
export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1";
|
||||
|
||||
/**
|
||||
* A tag, drawn the one way tags are drawn.
|
||||
*
|
||||
* The lozenge carries the colour in its border and text rather than pairing a
|
||||
* swatch with plain text: the name is the tag, and the colour is how you pick
|
||||
* it out of a row at a glance. That also matches every other coloured pill in
|
||||
* the app, all of which set a text colour alongside the background.
|
||||
*
|
||||
* A deep name shortens to fit its own box (`Work/../Acme`) before the browser
|
||||
* clips it, so the outermost and innermost levels survive.
|
||||
*/
|
||||
export function TagBadge({
|
||||
tagId,
|
||||
variant,
|
||||
onRemove,
|
||||
className,
|
||||
}: {
|
||||
tagId: string;
|
||||
variant: TagBadgeVariant;
|
||||
/**
|
||||
* Takes the tag off the message. Only the named form offers it - a dot is the
|
||||
* size of the control it would have to hold.
|
||||
*/
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const { tagName, tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId));
|
||||
const color = tagColor(tagId);
|
||||
const name = tagName(tagId);
|
||||
|
||||
if (variant === "dot") {
|
||||
return (
|
||||
<span
|
||||
className={cn("h-2.5 w-2.5 shrink-0 rounded-full", color.dot, className)}
|
||||
title={name}
|
||||
aria-label={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"max-w-[12rem] border",
|
||||
color.fill,
|
||||
color.border,
|
||||
color.text,
|
||||
className,
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
<span ref={labelRef} className="min-w-0 truncate">
|
||||
{shortenedName}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ms-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title={t("remove_tag")}
|
||||
aria-label={t("remove_tag")}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
|
||||
/** Below this many tags a filter box costs more room than it saves. */
|
||||
const SEARCH_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* The list of tags to apply to a message.
|
||||
*
|
||||
* Shared by all four places one appears - the toolbar popover, the overflow
|
||||
* flyout, the mobile sheet and the context menu - because they had drifted into
|
||||
* four different dot sizes, check alignments and separators, and only one of
|
||||
* them capped its height.
|
||||
*
|
||||
* Nested tags are drawn as a tree rather than repeating the parent's name on
|
||||
* every child. Filtering flattens it: with a query the hierarchy is noise, and
|
||||
* the full path is what gets matched.
|
||||
*/
|
||||
export function TagPicker({
|
||||
selectedIds,
|
||||
onToggle,
|
||||
touch = false,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
onToggle: (tagId: string) => void;
|
||||
/** Larger hit areas for the mobile sheet. */
|
||||
touch?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const keywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const nestedTags = useSettingsStore((state) => state.nestedTags);
|
||||
const { tagName, tagColor } = useKeywordFormat();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
|
||||
/**
|
||||
* Tags on the message this client has no definition for - set from another
|
||||
* client, or outliving the tag they were made with. Listing them is the only
|
||||
* way to take one off, and they leave the list as they are deselected because
|
||||
* nothing but the message itself records that they exist.
|
||||
*/
|
||||
const unknownIds = useMemo(
|
||||
() =>
|
||||
selectedIds
|
||||
.filter((id) => !keywords.some((keyword) => keyword.id === id))
|
||||
.sort((a, b) => tagName(a).localeCompare(tagName(b))),
|
||||
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
|
||||
[selectedIds, keywords, tagName],
|
||||
);
|
||||
|
||||
const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD;
|
||||
|
||||
const matches = useMemo(
|
||||
() =>
|
||||
trimmedQuery
|
||||
? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) =>
|
||||
tagName(id).toLowerCase().includes(trimmedQuery),
|
||||
)
|
||||
: [],
|
||||
[keywords, unknownIds, trimmedQuery, tagName],
|
||||
);
|
||||
|
||||
const tree = useMemo(
|
||||
() => (nestedTags ? buildKeywordTree(keywords) : keywords.map((k) => ({ ...k, children: [], depth: 0 }))),
|
||||
[keywords, nestedTags],
|
||||
);
|
||||
|
||||
const rowClass = cn(
|
||||
"w-full text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
||||
touch ? "px-4 py-2.5 min-h-[44px] text-sm gap-3" : "px-3 py-1.5 text-sm",
|
||||
);
|
||||
const dotClass = touch ? "w-3.5 h-3.5" : "w-3 h-3";
|
||||
const checkClass = touch ? "w-4 h-4" : "w-3.5 h-3.5";
|
||||
|
||||
const renderRow = (id: string, label: string) => {
|
||||
const isActive = selectedIds.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={isActive}
|
||||
onClick={() => onToggle(id)}
|
||||
className={cn(rowClass, isActive && "bg-accent font-medium")}
|
||||
title={tagName(id)}
|
||||
>
|
||||
<span className={cn("rounded-full flex-shrink-0", dotClass, tagColor(id).dot)} />
|
||||
<span className="flex-1 min-w-0 truncate">{label}</span>
|
||||
{isActive && <Check className={cn("ms-auto flex-shrink-0 text-foreground", checkClass)} />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBranch = (nodes: KeywordNode[]) =>
|
||||
nodes.map((node) => (
|
||||
<div key={node.id}>
|
||||
{renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)}
|
||||
{node.children.length > 0 && <div className="ps-4">{renderBranch(node.children)}</div>}
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSearch && (
|
||||
<div className={cn("relative", touch ? "px-3 pb-2" : "px-2 pb-1")}>
|
||||
<Search className="absolute start-4 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("tag_filter_placeholder")}
|
||||
aria-label={t("tag_filter_placeholder")}
|
||||
className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[min(20rem,60vh)] overflow-y-auto">
|
||||
{trimmedQuery ? (
|
||||
matches.length > 0 ? (
|
||||
matches.map((id) => renderRow(id, tagName(id)))
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{renderBranch(tree)}
|
||||
{unknownIds.length > 0 && (
|
||||
<>
|
||||
{keywords.length > 0 && <div className="h-px bg-border my-1" />}
|
||||
{unknownIds.map((id) => renderRow(id, tagName(id)))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
|
||||
interface ThreadEmailItemProps {
|
||||
email: Email;
|
||||
@@ -35,6 +39,11 @@ export function ThreadEmailItem({
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isAnswered = email.keywords?.$answered;
|
||||
const isForwarded = email.keywords?.$forwarded;
|
||||
const { sortTagIds } = useKeywordFormat();
|
||||
const { variant: tagVariant } = useTagDisplay();
|
||||
// A message inside an expanded thread carries its own tags; the collapsed
|
||||
// header pools them, so without this they disappear on the way in.
|
||||
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
@@ -178,6 +187,9 @@ export function ThreadEmailItem({
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview snippet */}
|
||||
|
||||
@@ -6,11 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||
import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { useTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
@@ -34,6 +37,28 @@ function SourceFolderTag({ name }: { name: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many messages a collapsed thread stands for.
|
||||
*
|
||||
* Built from the tag lozenge so it lines up with the tags it sits next to: the
|
||||
* same shape, and the same group spacing.
|
||||
*/
|
||||
function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
TAG_LOZENGE_CLASS,
|
||||
"gap-0.5",
|
||||
hasUnread ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListItemProps {
|
||||
thread: ThreadGroup;
|
||||
isExpanded: boolean;
|
||||
@@ -50,7 +75,7 @@ interface ThreadListItemProps {
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onSetTag?: (emailId: string, tagId: string | null) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
onUndoSpam?: (email: Email) => void;
|
||||
}
|
||||
@@ -62,18 +87,18 @@ interface SingleEmailItemProps {
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
rowTint: string | null;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onSetTag?: (tagId: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const tBatch = useTranslations('email_list.batch_actions');
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -89,7 +114,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
@@ -110,14 +136,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? formatDateTime(email.scheduledSendAt, timeFormat)
|
||||
: null;
|
||||
|
||||
// Resolve color tags using keyword definitions; unknown tags fall back to gray
|
||||
const tagIds = getEmailColorTags(email.keywords);
|
||||
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
|
||||
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
|
||||
const resolvedColorTag = !tintListRowsByTag ? null : (() => {
|
||||
if (colorTag) return colorTag;
|
||||
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
|
||||
})();
|
||||
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
|
||||
const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null));
|
||||
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
@@ -172,19 +192,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
data-unread={isUnread ? 'true' : 'false'}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
resolvedColorTag ? resolvedColorTag : (
|
||||
resolvedRowTint ? resolvedRowTint : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !resolvedColorTag && "shadow-sm",
|
||||
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedColorTag && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
selected && !resolvedRowTint && "shadow-sm",
|
||||
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !resolvedRowTint && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20",
|
||||
isChecked && !resolvedRowTint && "bg-accent/40",
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
|
||||
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
isPressed && "scale-[0.98] ring-2 ring-primary/30",
|
||||
isPressed && !resolvedRowTint && "bg-muted"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(e) => {
|
||||
@@ -258,6 +280,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
{tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
@@ -281,9 +310,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</>
|
||||
)}
|
||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -322,6 +348,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
{tagPlacement === 'sender' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPinned && (
|
||||
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||
@@ -347,15 +380,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{resolvedKeywordDefs.map((kd) => (
|
||||
<span key={kd.id} className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||
{kd.label}
|
||||
</span>
|
||||
))}
|
||||
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -378,13 +402,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
@@ -406,12 +439,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{!email.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onSetTag={onSetTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
onUndoSpam={onUndoSpam}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
@@ -440,7 +473,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onSetTag,
|
||||
onMarkAsSpam,
|
||||
onUndoSpam,
|
||||
}, ref) {
|
||||
@@ -497,11 +530,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
);
|
||||
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||
const { sortTagIds, tagColor } = useKeywordFormat();
|
||||
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
|
||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
|
||||
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||
// A collapsed row speaks for every message under it, so it carries their tags too.
|
||||
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
|
||||
const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
@@ -518,12 +552,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
rowTint={rowTint}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
/>
|
||||
@@ -597,19 +631,21 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{...threadLongPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||
colorTag ? colorTag : (
|
||||
rowTint ? rowTint : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isSelected && !rowTint && "shadow-sm",
|
||||
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
|
||||
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
|
||||
rowTint && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !rowTint && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
|
||||
isChecked && "ring-2 ring-primary/20",
|
||||
isChecked && !rowTint && "bg-accent/40",
|
||||
isThreadPressed && "scale-[0.98] ring-2 ring-primary/30",
|
||||
isThreadPressed && !rowTint && "bg-muted"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onDoubleClick={(e) => {
|
||||
@@ -706,22 +742,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
// Matches SingleEmailItem: the sender column sets where
|
||||
// every row's tags and subject begin, so the two have to
|
||||
// agree or thread rows sit 1rem further right.
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{displayNames.join(', ')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
|
||||
hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{emailCount}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
<ThreadCountPill
|
||||
count={emailCount}
|
||||
hasUnread={hasUnread}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'min-w-0 truncate',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
@@ -745,9 +784,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</>
|
||||
)}
|
||||
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||
{keywordDef && (
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -786,17 +822,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}>
|
||||
{displayNames.join(", ")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||
hasUnread
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{emailCount}
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
<ThreadCountPill
|
||||
count={emailCount}
|
||||
hasUnread={hasUnread}
|
||||
title={t('messages_tooltip', { count: emailCount })}
|
||||
/>
|
||||
{tagPlacement === 'sender' && tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPinned && (
|
||||
@@ -823,15 +857,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{keywordDef && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
||||
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
|
||||
)}>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||
{keywordDef.label}
|
||||
</span>
|
||||
)}
|
||||
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||
{scheduledSendLabel ? (
|
||||
<span
|
||||
@@ -854,13 +879,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1.5">
|
||||
{tagPlacement === 'subject' && tagIds.length > 0 && (
|
||||
<span className={TAG_GROUP_CLASS}>
|
||||
{tagIds.map((id) => (
|
||||
<TagBadge key={id} tagId={id} variant={tagVariant} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
@@ -882,12 +916,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{!latestEmail.isScheduled && (
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
backgroundClassName={rowTint ? rowTint : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||
isInJunk={currentMailboxRole === 'junk'}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
rule?: FilterRule;
|
||||
@@ -89,6 +90,7 @@ export function FilterRuleModal({
|
||||
const t = useTranslations("settings.filters");
|
||||
const isEdit = !!rule;
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const { tagName } = useKeywordFormat();
|
||||
|
||||
const [name, setName] = useState(rule?.name || "");
|
||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||
@@ -477,7 +479,7 @@ export function FilterRuleModal({
|
||||
>
|
||||
<option value="">{t("label_placeholder")}</option>
|
||||
{emailKeywords.map((kw) => (
|
||||
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
||||
<option key={kw.id} value={kw.id}>{tagName(kw.id)}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
+174
-54
@@ -35,9 +35,19 @@ import {
|
||||
BellOff,
|
||||
Mails,
|
||||
MailOpen,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import {
|
||||
buildKeywordTree,
|
||||
countKeywordNodes,
|
||||
filterKeywordTree,
|
||||
hasChildKeywords,
|
||||
type KeywordNode,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -51,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
|
||||
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -241,6 +251,9 @@ function SidebarRowCounts({
|
||||
interface SidebarRowProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
/** Progressively shorter renderings of `label`, longest first. The widest one
|
||||
* that fits the row is shown; without this the full label is used. */
|
||||
labelCandidates?: string[];
|
||||
depth?: number;
|
||||
isSelected?: boolean;
|
||||
isVirtual?: boolean;
|
||||
@@ -266,6 +279,7 @@ interface SidebarRowProps {
|
||||
function SidebarRow({
|
||||
icon,
|
||||
label,
|
||||
labelCandidates,
|
||||
depth = 0,
|
||||
isSelected = false,
|
||||
isVirtual = false,
|
||||
@@ -288,6 +302,7 @@ function SidebarRow({
|
||||
}: SidebarRowProps) {
|
||||
const t = useTranslations('sidebar');
|
||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -353,7 +368,7 @@ function SidebarRow({
|
||||
</span>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
|
||||
<SidebarRowCounts
|
||||
unread={unread}
|
||||
total={total}
|
||||
@@ -542,78 +557,120 @@ function MailboxTreeItem({
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_ICON_COLOR: Record<string, string> = {
|
||||
red: "text-red-600/75 dark:text-red-400/75",
|
||||
orange: "text-orange-600/75 dark:text-orange-400/75",
|
||||
yellow: "text-yellow-600/75 dark:text-yellow-400/75",
|
||||
green: "text-green-600/75 dark:text-green-400/75",
|
||||
blue: "text-blue-600/75 dark:text-blue-400/75",
|
||||
purple: "text-purple-600/75 dark:text-purple-400/75",
|
||||
pink: "text-pink-600/75 dark:text-pink-400/75",
|
||||
teal: "text-teal-600/75 dark:text-teal-400/75",
|
||||
cyan: "text-cyan-600/75 dark:text-cyan-400/75",
|
||||
indigo: "text-indigo-600/75 dark:text-indigo-400/75",
|
||||
amber: "text-amber-600/75 dark:text-amber-400/75",
|
||||
lime: "text-lime-600/75 dark:text-lime-400/75",
|
||||
gray: "text-gray-500",
|
||||
};
|
||||
function ShowAllTagsRow({
|
||||
hiddenCount,
|
||||
showAll,
|
||||
onToggle,
|
||||
isCollapsed,
|
||||
}: {
|
||||
hiddenCount: number;
|
||||
showAll: boolean;
|
||||
onToggle: () => void;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
return (
|
||||
<SidebarRow
|
||||
icon={<MoreHorizontal className="w-4 h-4 text-muted-foreground" />}
|
||||
label={showAll ? t('show_fewer_tags') : t('show_all_tags', { count: hiddenCount })}
|
||||
depth={0}
|
||||
onClick={onToggle}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TagItem({
|
||||
kw,
|
||||
isSelected,
|
||||
node,
|
||||
selectedKeyword,
|
||||
expandedTags,
|
||||
isCollapsed,
|
||||
onTagSelect,
|
||||
totalCount,
|
||||
unreadCount,
|
||||
onToggleExpand,
|
||||
tagCounts,
|
||||
colorful,
|
||||
}: {
|
||||
kw: KeywordDefinition;
|
||||
isSelected: boolean;
|
||||
node: KeywordNode;
|
||||
selectedKeyword: string | null;
|
||||
expandedTags: Set<string>;
|
||||
isCollapsed: boolean;
|
||||
onTagSelect?: (keywordId: string | null) => void;
|
||||
totalCount: number;
|
||||
unreadCount: number;
|
||||
onToggleExpand: (keywordId: string) => void;
|
||||
tagCounts: Record<string, { total: number; unread: number }>;
|
||||
colorful: boolean;
|
||||
}) {
|
||||
const t = useTranslations('notifications');
|
||||
const palette = KEYWORD_PALETTE[kw.color];
|
||||
const { tagNameCandidates, tagColor } = useKeywordFormat();
|
||||
const palette = tagColor(node.id);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedTags.has(node.id);
|
||||
const isSelected = selectedKeyword === node.id;
|
||||
// Nested rows are placed by their indentation, so they show their own name.
|
||||
// A root spells out its path, which matters when an intermediate tag is
|
||||
// missing from this client's settings and the row would otherwise read as a
|
||||
// bare leaf name.
|
||||
const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label];
|
||||
const label = labelCandidates[0];
|
||||
// Toasts have the room for the whole thing, and no indentation to lean on,
|
||||
// so they always spell out the full path - otherwise two leaves with the
|
||||
// same name in different branches (e.g. "Personal/Receipts" and
|
||||
// "Work/Receipts") would read as the same tag.
|
||||
const fullLabel = tagNameCandidates(node.id)[0];
|
||||
const { isDragging: globalDragging } = useDragDropContext();
|
||||
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
||||
tagId: kw.id,
|
||||
onSuccess: (count, _tagLabel) => {
|
||||
tagId: node.id,
|
||||
onSuccess: (count) => {
|
||||
if (count === 1) {
|
||||
toast.success(t('email_tagged'), kw.label);
|
||||
toast.success(t('email_tagged'), fullLabel);
|
||||
} else {
|
||||
toast.success(t('emails_tagged', { count }), kw.label);
|
||||
toast.success(t('emails_tagged', { count }), fullLabel);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('tag_failed'), kw.label);
|
||||
toast.error(t('tag_failed'), fullLabel);
|
||||
},
|
||||
});
|
||||
|
||||
const tagIcon = colorful ? (
|
||||
<Tag
|
||||
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
|
||||
) : (
|
||||
<span className={cn("w-3 h-3 rounded-full", palette?.dot || "bg-gray-400")} />
|
||||
<span className={cn("w-3 h-3 rounded-full", palette.dot)} />
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarRow
|
||||
icon={tagIcon}
|
||||
label={kw.label}
|
||||
depth={0}
|
||||
isSelected={isSelected}
|
||||
unread={unreadCount}
|
||||
total={totalCount}
|
||||
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
|
||||
isCollapsed={isCollapsed}
|
||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
/>
|
||||
<>
|
||||
<SidebarRow
|
||||
icon={tagIcon}
|
||||
label={label}
|
||||
labelCandidates={labelCandidates}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
unread={tagCounts[node.id]?.unread ?? 0}
|
||||
total={tagCounts[node.id]?.total ?? 0}
|
||||
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
|
||||
hasChildren={hasChildren}
|
||||
isExpanded={isExpanded}
|
||||
onExpandToggle={() => onToggleExpand(node.id)}
|
||||
isCollapsed={isCollapsed}
|
||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
/>
|
||||
|
||||
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
||||
<TagItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
selectedKeyword={selectedKeyword}
|
||||
expandedTags={expandedTags}
|
||||
isCollapsed={isCollapsed}
|
||||
onTagSelect={onTagSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
tagCounts={tagCounts}
|
||||
colorful={colorful}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -737,6 +794,8 @@ export function Sidebar({
|
||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||
@@ -779,6 +838,7 @@ export function Sidebar({
|
||||
return new Set();
|
||||
});
|
||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
||||
// own AccountSwitcher would be a redundant second account UI in the same
|
||||
@@ -842,6 +902,37 @@ export function Sidebar({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('expandedTags');
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
setExpandedTags(new Set(parsed));
|
||||
} catch (e) {
|
||||
debug.error('Failed to parse expanded tags:', e);
|
||||
}
|
||||
} else {
|
||||
setExpandedTags(
|
||||
new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id))
|
||||
);
|
||||
}
|
||||
}, [emailKeywords]);
|
||||
|
||||
const handleToggleTagExpand = (keywordId: string) => {
|
||||
setExpandedTags((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(keywordId)) {
|
||||
next.delete(keywordId);
|
||||
} else {
|
||||
next.add(keywordId);
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('expandedTags', JSON.stringify(Array.from(next)));
|
||||
} catch { /* storage full or unavailable */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
|
||||
@@ -852,6 +943,26 @@ export function Sidebar({
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
||||
|
||||
// With nesting off every tag is its own root, so the same rows render through
|
||||
// one path whether or not the ids describe a hierarchy.
|
||||
const tagTree: KeywordNode[] = nestedTags
|
||||
? buildKeywordTree(emailKeywords)
|
||||
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
|
||||
|
||||
// Counts arrive from a separate JMAP round trip; until they land, treat every
|
||||
// "show if unread" tag as visible rather than blanking the section and
|
||||
// filling it back in.
|
||||
const tagCountsLoaded = Object.keys(tagCounts).length > 0;
|
||||
const isTagVisible = (node: KeywordNode) => {
|
||||
if (showAllTags || node.id === selectedKeyword) return true;
|
||||
const visibility = getKeywordVisibility(node);
|
||||
if (visibility === 'hide') return false;
|
||||
if (visibility === 'unread') return !tagCountsLoaded || (tagCounts[node.id]?.unread ?? 0) > 0;
|
||||
return true;
|
||||
};
|
||||
const visibleTagTree = filterKeywordTree(tagTree, isTagVisible);
|
||||
const hiddenTagCount = emailKeywords.length - countKeywordNodes(visibleTagTree);
|
||||
|
||||
// Multi-account mode (Pro shell): render every connected account as its
|
||||
// own collapsible group. The active account's tree comes from the
|
||||
// `mailboxes` prop (which is the live email-store value); other accounts
|
||||
@@ -1265,18 +1376,27 @@ export function Sidebar({
|
||||
/>
|
||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{emailKeywords.map((kw) => (
|
||||
{visibleTagTree.map((node) => (
|
||||
<TagItem
|
||||
key={kw.id}
|
||||
kw={kw}
|
||||
isSelected={selectedKeyword === kw.id}
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedKeyword={selectedKeyword}
|
||||
expandedTags={expandedTags}
|
||||
isCollapsed={isCollapsed}
|
||||
onTagSelect={onTagSelect}
|
||||
totalCount={tagCounts[kw.id]?.total ?? 0}
|
||||
unreadCount={tagCounts[kw.id]?.unread ?? 0}
|
||||
onToggleExpand={handleToggleTagExpand}
|
||||
tagCounts={tagCounts}
|
||||
colorful={colorfulSidebarIcons}
|
||||
/>
|
||||
))}
|
||||
{(hiddenTagCount > 0 || showAllTags) && (
|
||||
<ShowAllTagsRow
|
||||
hiddenCount={hiddenTagCount}
|
||||
showAll={showAllTags}
|
||||
onToggle={() => setShowAllTags((prev) => !prev)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { KeywordSettings } from '../keyword-settings';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||
|
||||
// Mock SettingsSection to just render children
|
||||
vi.mock('../settings-section', () => ({
|
||||
// Mock SettingsSection to just render children, keeping the real controls
|
||||
vi.mock('../settings-section', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe('KeywordSettings', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
|
||||
});
|
||||
|
||||
it('renders all default keywords', () => {
|
||||
@@ -31,11 +32,6 @@ describe('KeywordSettings', () => {
|
||||
expect(screen.getByText('add_keyword')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders reset defaults button', () => {
|
||||
render(<KeywordSettings />);
|
||||
expect(screen.getByText('reset_defaults')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows add form when add button clicked', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
@@ -114,18 +110,6 @@ describe('KeywordSettings', () => {
|
||||
expect(kw?.label).toBe('Crimson');
|
||||
});
|
||||
|
||||
it('resets to defaults when reset button clicked', () => {
|
||||
// Modify keywords first
|
||||
useSettingsStore.getState().removeKeyword('red');
|
||||
useSettingsStore.getState().removeKeyword('blue');
|
||||
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 2);
|
||||
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('reset_defaults'));
|
||||
|
||||
expect(useSettingsStore.getState().emailKeywords).toEqual(DEFAULT_KEYWORDS);
|
||||
});
|
||||
|
||||
it('normalizes label to id correctly', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
@@ -139,4 +123,90 @@ describe('KeywordSettings', () => {
|
||||
expect(added.id).toBe('my-custom-tag');
|
||||
expect(added.label).toBe('My Custom Tag!');
|
||||
});
|
||||
|
||||
it('offers no parent picker while nesting is off', () => {
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('nests a new tag under the selected parent', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
|
||||
fireEvent.click(screen.getByText('add'));
|
||||
|
||||
const keywords = useSettingsStore.getState().emailKeywords;
|
||||
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
|
||||
});
|
||||
|
||||
it('shows nested tags by their full path', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
|
||||
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
|
||||
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects a path that would exceed the keyword length limit', () => {
|
||||
const deepId = 'a'.repeat(240);
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
fireEvent.click(screen.getByText('add_keyword'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
|
||||
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
|
||||
|
||||
expect(screen.getByText('too_long')).toBeInTheDocument();
|
||||
expect(screen.getByText('add').closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('locks the name and the delete action of a tag that has nested tags', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
],
|
||||
nestedTags: true,
|
||||
});
|
||||
render(<KeywordSettings />);
|
||||
|
||||
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getAllByTitle('edit')[0]);
|
||||
expect(screen.getByDisplayValue('Work')).toBeDisabled();
|
||||
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults every tag to always visible in the sidebar', () => {
|
||||
render(<KeywordSettings />);
|
||||
|
||||
const pickers = screen.getAllByLabelText('visibility_field');
|
||||
expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length);
|
||||
pickers.forEach((picker) => expect(picker).toHaveValue('show'));
|
||||
});
|
||||
|
||||
it('stores the visibility chosen for a tag', () => {
|
||||
render(<KeywordSettings />);
|
||||
|
||||
fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } });
|
||||
|
||||
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,35 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
||||
import {
|
||||
useSettingsStore,
|
||||
KEYWORD_PALETTE,
|
||||
KEYWORD_PALETTE_ROWS,
|
||||
getKeywordVisibility,
|
||||
type KeywordDefinition,
|
||||
type KeywordVisibility,
|
||||
} from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
|
||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
|
||||
import {
|
||||
buildKeywordTree,
|
||||
composeKeywordId,
|
||||
getParentKeywordId,
|
||||
hasChildKeywords,
|
||||
isKeywordDescendant,
|
||||
keywordLevels,
|
||||
type KeywordNode,
|
||||
MAX_KEYWORD_ID_LENGTH,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import { formatKeyword, keywordRenderings } from "@/lib/keyword-format";
|
||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||
import { TagBadge } from "@/components/email/tag-badge";
|
||||
|
||||
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
|
||||
|
||||
/** Lighter, base and darker shade of each hue, one row per shade. */
|
||||
function KeywordColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
@@ -19,19 +39,23 @@ function KeywordColorPicker({
|
||||
onChange: (color: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PALETTE_KEYS.map((colorKey) => (
|
||||
<button
|
||||
key={colorKey}
|
||||
type="button"
|
||||
onClick={() => onChange(colorKey)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
KEYWORD_PALETTE[colorKey].dot,
|
||||
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
aria-label={colorKey}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
{KEYWORD_PALETTE_ROWS.map((row, index) => (
|
||||
<div key={index} className="flex flex-wrap gap-1.5">
|
||||
{row.map((colorKey) => (
|
||||
<button
|
||||
key={colorKey}
|
||||
type="button"
|
||||
onClick={() => onChange(colorKey)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
KEYWORD_PALETTE[colorKey].dot,
|
||||
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||
)}
|
||||
aria-label={colorKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -39,8 +63,11 @@ function KeywordColorPicker({
|
||||
|
||||
function KeywordRow({
|
||||
keyword,
|
||||
keywords,
|
||||
nestedTags,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onVisibilityChange,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
@@ -49,8 +76,11 @@ function KeywordRow({
|
||||
isDragging,
|
||||
}: {
|
||||
keyword: KeywordDefinition;
|
||||
keywords: KeywordDefinition[];
|
||||
nestedTags: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onVisibilityChange: (visibility: KeywordVisibility) => void;
|
||||
onDragStart: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: () => void;
|
||||
@@ -59,7 +89,16 @@ function KeywordRow({
|
||||
isDragging: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings.keywords");
|
||||
const palette = KEYWORD_PALETTE[keyword.color];
|
||||
const hasChildren = hasChildKeywords(keyword.id, keywords);
|
||||
// Measured with the prefix attached, since that is what occupies the column.
|
||||
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
|
||||
.map((rendering) => KEYWORD_PREFIX + rendering);
|
||||
const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates);
|
||||
const visibilityOptions = [
|
||||
{ value: "show", label: t("visibility.show") },
|
||||
{ value: "unread", label: t("visibility.unread") },
|
||||
{ value: "hide", label: t("visibility.hide") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -75,9 +114,23 @@ function KeywordRow({
|
||||
)}
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
||||
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
|
||||
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<TagBadge tagId={keyword.id} variant="badge" className="text-xs" />
|
||||
</div>
|
||||
<span
|
||||
ref={keywordRef}
|
||||
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
|
||||
title={KEYWORD_PREFIX + keyword.id}
|
||||
>
|
||||
{shortenedKeyword}
|
||||
</span>
|
||||
<Select
|
||||
value={getKeywordVisibility(keyword)}
|
||||
onChange={(value) => onVisibilityChange(value as KeywordVisibility)}
|
||||
options={visibilityOptions}
|
||||
ariaLabel={t("visibility_field")}
|
||||
className="text-xs py-1"
|
||||
/>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
@@ -90,8 +143,9 @@ function KeywordRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={t("delete")}
|
||||
disabled={hasChildren}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
||||
title={hasChildren ? t("has_children_delete") : t("delete")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -102,37 +156,74 @@ function KeywordRow({
|
||||
|
||||
function KeywordEditForm({
|
||||
initial,
|
||||
keywords,
|
||||
existingIds,
|
||||
nestedTags,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: KeywordDefinition;
|
||||
keywords: KeywordDefinition[];
|
||||
existingIds: string[];
|
||||
nestedTags: boolean;
|
||||
onSave: (keyword: KeywordDefinition) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations("settings.keywords");
|
||||
const [label, setLabel] = useState(initial?.label || "");
|
||||
const [color, setColor] = useState(initial?.color || "blue");
|
||||
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
|
||||
const isEditing = !!initial;
|
||||
|
||||
const normalizedId = label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
// Renaming or re-parenting a tag rewrites the keyword on every message below
|
||||
// it, and this client only knows about the tags in its own settings - the
|
||||
// server may hold nested keywords created elsewhere. Freeze the identity of a
|
||||
// tag that has children and allow the color to change.
|
||||
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
|
||||
|
||||
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
|
||||
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
|
||||
const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH;
|
||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
|
||||
|
||||
// Every tag is a candidate parent except the one being edited and anything
|
||||
// already below it, which would detach the branch from its own root.
|
||||
const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }];
|
||||
const collectParentOptions = (nodes: KeywordNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue;
|
||||
parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) });
|
||||
collectParentOptions(node.children);
|
||||
}
|
||||
};
|
||||
collectParentOptions(buildKeywordTree(keywords));
|
||||
|
||||
const handleSave = () => {
|
||||
if (!isValid) return;
|
||||
if (isLocked && initial) {
|
||||
onSave({ ...initial, color });
|
||||
return;
|
||||
}
|
||||
onSave({ id: normalizedId, label: label.trim(), color });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||
{nestedTags && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("parent_field")}
|
||||
</label>
|
||||
<Select
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
options={parentOptions}
|
||||
disabled={isLocked}
|
||||
ariaLabel={t("parent_field")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("label_field")}
|
||||
@@ -141,15 +232,29 @@ function KeywordEditForm({
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={isLocked}
|
||||
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
|
||||
placeholder={t("label_placeholder")}
|
||||
autoFocus
|
||||
maxLength={30}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
/>
|
||||
{nestedTags && normalizedId.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
|
||||
{KEYWORD_PREFIX + normalizedId}
|
||||
</p>
|
||||
)}
|
||||
{isLocked && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
|
||||
)}
|
||||
{isDuplicate && (
|
||||
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
|
||||
)}
|
||||
{isTooLong && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
||||
@@ -182,7 +287,7 @@ function KeywordEditForm({
|
||||
|
||||
export function KeywordSettings() {
|
||||
const t = useTranslations("settings.keywords");
|
||||
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
|
||||
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
|
||||
useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
const { fetchTagCounts } = useEmailStore();
|
||||
@@ -260,12 +365,19 @@ export function KeywordSettings() {
|
||||
removeKeyword(id);
|
||||
};
|
||||
|
||||
const handleResetDefaults = () => {
|
||||
reorderKeywords(DEFAULT_KEYWORDS);
|
||||
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
|
||||
updateKeyword(id, { visibility });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
|
||||
<ToggleSwitch
|
||||
checked={nestedTags}
|
||||
onChange={(checked) => updateSetting("nestedTags", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<div className="space-y-2">
|
||||
{isMigrating && (
|
||||
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
|
||||
@@ -278,7 +390,9 @@ export function KeywordSettings() {
|
||||
<KeywordEditForm
|
||||
key={keyword.id}
|
||||
initial={keyword}
|
||||
keywords={emailKeywords}
|
||||
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
||||
nestedTags={nestedTags}
|
||||
onSave={handleEdit}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
@@ -286,11 +400,14 @@ export function KeywordSettings() {
|
||||
<KeywordRow
|
||||
key={keyword.id}
|
||||
keyword={keyword}
|
||||
keywords={emailKeywords}
|
||||
nestedTags={nestedTags}
|
||||
onEdit={() => {
|
||||
setEditingId(keyword.id);
|
||||
setIsAdding(false);
|
||||
}}
|
||||
onDelete={() => handleDelete(keyword.id)}
|
||||
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
@@ -303,7 +420,9 @@ export function KeywordSettings() {
|
||||
|
||||
{isAdding ? (
|
||||
<KeywordEditForm
|
||||
keywords={emailKeywords}
|
||||
existingIds={existingIds}
|
||||
nestedTags={nestedTags}
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setIsAdding(false)}
|
||||
/>
|
||||
@@ -320,14 +439,6 @@ export function KeywordSettings() {
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t("add_keyword")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetDefaults}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("reset_defaults")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -111,15 +111,24 @@ interface SelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function Select({ value, onChange, options }: SelectProps) {
|
||||
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
dir="auto"
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
|
||||
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useKeywordFormat } from '../use-keyword-format';
|
||||
import { useSettingsStore, KEYWORD_PALETTE, type KeywordDefinition } from '@/stores/settings-store';
|
||||
|
||||
const TAGS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'green' },
|
||||
{ id: 'archive', label: 'Archive', color: 'red-dark' },
|
||||
];
|
||||
|
||||
describe('useKeywordFormat', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
|
||||
});
|
||||
|
||||
describe('tagColor', () => {
|
||||
it('resolves a tag to its palette entry, including the new shades', () => {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.tagColor('work')).toBe(KEYWORD_PALETTE.blue);
|
||||
expect(result.current.tagColor('archive')).toBe(KEYWORD_PALETTE['red-dark']);
|
||||
});
|
||||
|
||||
it('falls back to grey for a keyword this client has no definition for', () => {
|
||||
// Set on the message by another client, or its tag was deleted here.
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.tagColor('never-heard-of-it')).toBe(KEYWORD_PALETTE.gray);
|
||||
});
|
||||
|
||||
it('falls back to grey for a colour that is not in the palette', () => {
|
||||
useSettingsStore.setState({ emailKeywords: [{ id: 'odd', label: 'Odd', color: 'chartreuse' }] });
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.tagColor('odd')).toBe(KEYWORD_PALETTE.gray);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortTagIds', () => {
|
||||
it('follows the order the user arranged in settings', () => {
|
||||
// Settings order is work, work/clients, archive - drag-reorderable, and
|
||||
// deliberately not alphabetical.
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.sortTagIds(['archive', 'work/clients', 'work'])).toEqual([
|
||||
'work',
|
||||
'work/clients',
|
||||
'archive',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is stable however the keywords happen to arrive', () => {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
const expected = ['work', 'work/clients', 'archive'];
|
||||
|
||||
expect(result.current.sortTagIds(['work', 'archive', 'work/clients'])).toEqual(expected);
|
||||
expect(result.current.sortTagIds(['archive', 'work', 'work/clients'])).toEqual(expected);
|
||||
});
|
||||
|
||||
it('follows a reordering of the settings list', () => {
|
||||
useSettingsStore.setState({ emailKeywords: [TAGS[2], TAGS[0], TAGS[1]] });
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.sortTagIds(['work', 'archive'])).toEqual(['archive', 'work']);
|
||||
});
|
||||
|
||||
it('puts a tag with no local definition last, ordered by name', () => {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
|
||||
expect(result.current.sortTagIds(['zz-unknown', 'work', 'aa-unknown'])).toEqual([
|
||||
'work',
|
||||
'aa-unknown',
|
||||
'zz-unknown',
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the caller's array alone", () => {
|
||||
const { result } = renderHook(() => useKeywordFormat());
|
||||
const input = ['archive', 'work'];
|
||||
|
||||
result.current.sortTagIds(input);
|
||||
|
||||
expect(input).toEqual(['archive', 'work']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { useShortenedText } from '../use-shortened-text';
|
||||
|
||||
const CANDIDATES = ['Work/Clients/Acme/Sales', 'Work/../Acme/Sales', 'Work/.../Sales'];
|
||||
|
||||
/**
|
||||
* Reports `width` for the observed element and measures text at 10px per
|
||||
* character, so a width of N*10 fits any candidate of N characters or fewer.
|
||||
*/
|
||||
function stubMeasurement(width: number) {
|
||||
// Implementing the interface rather than passing an anonymous class keeps the
|
||||
// members the hook never calls from reading as dead code.
|
||||
class StubResizeObserver implements ResizeObserver {
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
|
||||
/** The hook observes once on mount; hand it `width` straight back. */
|
||||
observe(target: Element) {
|
||||
this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this);
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
font: '',
|
||||
measureText: (text: string) => ({ width: text.length * 10 }),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
}
|
||||
|
||||
function Probe({ candidates }: { candidates: string[] }) {
|
||||
const [ref, text] = useShortenedText(candidates);
|
||||
return <span ref={ref} data-testid="probe">{text}</span>;
|
||||
}
|
||||
|
||||
function renderProbe(candidates: string[]): string {
|
||||
render(<Probe candidates={candidates} />);
|
||||
return screen.getByTestId('probe').textContent ?? '';
|
||||
}
|
||||
|
||||
describe('useShortenedText', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns the longest candidate where the DOM cannot be measured', () => {
|
||||
// No ResizeObserver: server rendering, and jsdom by default. Showing the
|
||||
// whole path beats shortening it on a guess.
|
||||
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
|
||||
});
|
||||
|
||||
it('keeps the full path when the element is wide enough', () => {
|
||||
stubMeasurement(230);
|
||||
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
|
||||
});
|
||||
|
||||
it('steps down only as far as the width requires', () => {
|
||||
stubMeasurement(200);
|
||||
expect(renderProbe(CANDIDATES)).toBe('Work/../Acme/Sales');
|
||||
});
|
||||
|
||||
it('falls back to the shortest candidate when none of them fit', () => {
|
||||
stubMeasurement(40);
|
||||
expect(renderProbe(CANDIDATES)).toBe('Work/.../Sales');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useSettingsStore,
|
||||
KEYWORD_PALETTE,
|
||||
FALLBACK_KEYWORD_COLOR,
|
||||
type KeywordColor,
|
||||
} from "@/stores/settings-store";
|
||||
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||
|
||||
/**
|
||||
* Names and colours tags for the screen, bound to the user's tag settings.
|
||||
*
|
||||
* Resolving the definitions and the nesting setting here rather than at every
|
||||
* call site means no caller can forget the setting and render a nested name to
|
||||
* someone who never asked for nesting. Subscribing to them also keeps tags in
|
||||
* step the moment either changes: reading the store inside the formatter would
|
||||
* leave every list stale until something else happened to re-render it.
|
||||
*/
|
||||
export function useKeywordFormat() {
|
||||
const keywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const nested = useSettingsStore((state) => state.nestedTags);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
/** The tag's display name. */
|
||||
tagName: (id: string) => formatKeyword(id, keywords, nested),
|
||||
|
||||
/** Its progressively shorter forms, longest first, for `useShortenedText`. */
|
||||
tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)),
|
||||
|
||||
/**
|
||||
* The tag's colour. Falls back to grey for a keyword this client has no
|
||||
* definition for - one created on another device, or whose tag was
|
||||
* deleted - so such a tag still shows rather than silently vanishing.
|
||||
*/
|
||||
tagColor: (id: string): KeywordColor => {
|
||||
const color = keywords.find((keyword) => keyword.id === id)?.color;
|
||||
return (color ? KEYWORD_PALETTE[color] : undefined) ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR];
|
||||
},
|
||||
|
||||
/**
|
||||
* Tag ids in the order the user arranged them in settings.
|
||||
*
|
||||
* The keywords on a message arrive as an unordered JMAP map, so without
|
||||
* this the same two tags can swap places between rows. A tag with no
|
||||
* local definition has no place in that order, so it sorts last, by name.
|
||||
*/
|
||||
sortTagIds: (ids: string[]): string[] => {
|
||||
const rank = (id: string) => {
|
||||
const index = keywords.findIndex((keyword) => keyword.id === id);
|
||||
return index === -1 ? keywords.length : index;
|
||||
};
|
||||
return [...ids].sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
formatKeyword(a, keywords, nested).localeCompare(formatKeyword(b, keywords, nested)),
|
||||
);
|
||||
},
|
||||
}),
|
||||
[keywords, nested],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* Measures text the way the browser will, using the font the element actually
|
||||
* renders with. One canvas is reused for every measurement.
|
||||
*/
|
||||
let measureContext: CanvasRenderingContext2D | null | undefined;
|
||||
|
||||
function measureText(text: string, font: string): number {
|
||||
if (measureContext === undefined) {
|
||||
measureContext = document.createElement("canvas").getContext("2d");
|
||||
}
|
||||
if (!measureContext) return 0;
|
||||
measureContext.font = font;
|
||||
return measureContext.measureText(text).width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the first of `candidates` that fits the element the returned ref is
|
||||
* attached to, remeasuring whenever that element is resized.
|
||||
*
|
||||
* Candidates run longest first, so the result is the most complete one there is
|
||||
* room for. A character budget cannot do this job: the columns this is used in
|
||||
* are resized by the user and share their row with controls whose width depends
|
||||
* on the locale, so any fixed number is either so generous that it never
|
||||
* triggers or so tight that it shortens text that would have fit.
|
||||
*
|
||||
* Attach the ref to an element whose width does *not* depend on its own text -
|
||||
* a flex child that is allowed to shrink, i.e. one with `truncate` or
|
||||
* `min-w-0`. On anything else, picking a shorter candidate would change the
|
||||
* width that picked it and the two would oscillate.
|
||||
*
|
||||
* Where measurement is unavailable - server rendering, and jsdom under test -
|
||||
* this returns the first candidate, so the text is complete rather than
|
||||
* arbitrarily shortened.
|
||||
*/
|
||||
export function useShortenedText(
|
||||
candidates: string[],
|
||||
): [(node: HTMLElement | null) => void, string] {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const [box, setBox] = useState<{ width: number; font: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const style = window.getComputedStyle(element);
|
||||
setBox({
|
||||
width: entry.contentRect.width,
|
||||
font: `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
|
||||
});
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [element]);
|
||||
|
||||
// Candidates are rebuilt on every render, so key the choice on their content.
|
||||
// They must not contain a newline, which keeps this join unambiguous.
|
||||
const key = candidates.join("\n");
|
||||
|
||||
return [
|
||||
setElement,
|
||||
useMemo(() => {
|
||||
const options = key.split("\n");
|
||||
if (!box || box.width === 0) return options[0];
|
||||
return (
|
||||
options.find((option) => measureText(option, box.font) <= box.width)
|
||||
?? options[options.length - 1]
|
||||
);
|
||||
}, [key, box]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type RefObject } from "react";
|
||||
import type { TagBadgeVariant } from "@/components/email/tag-badge";
|
||||
|
||||
/**
|
||||
* Below this, a named tag beside the subject would leave the subject nothing to
|
||||
* occupy, so tags move up to the sender line instead. The split list runs
|
||||
* 240-600px wide and defaults to 384, so it reads that way until widened, while
|
||||
* the full-width focus and bottom-pane layouts keep tags with the subject.
|
||||
*/
|
||||
const TAG_BESIDE_SUBJECT_MIN_WIDTH = 560;
|
||||
|
||||
/**
|
||||
* Below this there is no room to name a tag anywhere on the row, and colour
|
||||
* alone has to carry it. Well under the split list's default, because the
|
||||
* sender line still has room for a name long after the subject line does not.
|
||||
*/
|
||||
const TAG_NAME_MIN_WIDTH = 320;
|
||||
|
||||
export interface TagDisplay {
|
||||
/** Whether a tag is named or shown as colour alone. */
|
||||
variant: TagBadgeVariant;
|
||||
/** Which line of a multi-line row the tags belong on. */
|
||||
placement: "subject" | "sender";
|
||||
}
|
||||
|
||||
const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject" };
|
||||
|
||||
/**
|
||||
* How message rows should draw their tags.
|
||||
*
|
||||
* One value for the whole list, never per row: rows are all the same width, so
|
||||
* measuring each would burn a `ResizeObserver` per virtualised row and, worse,
|
||||
* let neighbours disagree - one naming its tags while the next showed dots.
|
||||
*/
|
||||
export const TagDisplayContext = createContext<TagDisplay>(NAMED_BESIDE_SUBJECT);
|
||||
|
||||
export function useTagDisplay(): TagDisplay {
|
||||
return useContext(TagDisplayContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches a container and reports what its rows have room for. Falls back to
|
||||
* naming tags beside the subject where measurement is unavailable - server
|
||||
* rendering, and jsdom under test - since that is the most informative form.
|
||||
*/
|
||||
export function useMeasuredTagDisplay(ref: RefObject<HTMLElement | null>): TagDisplay {
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const measured = entries[0]?.contentRect.width;
|
||||
if (measured !== undefined) setWidth(measured);
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (width === null) return NAMED_BESIDE_SUBJECT;
|
||||
return {
|
||||
variant: width >= TAG_NAME_MIN_WIDTH ? "badge" : "dot",
|
||||
placement: width >= TAG_BESIDE_SUBJECT_MIN_WIDTH ? "subject" : "sender",
|
||||
};
|
||||
}, [width]);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
|
||||
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||
|
||||
// Work
|
||||
// Clients
|
||||
// Acme
|
||||
// Personal
|
||||
const KEYWORDS: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/personal", "Personal"),
|
||||
];
|
||||
|
||||
describe("formatKeyword with nesting on", () => {
|
||||
it("joins the display name of every level", () => {
|
||||
expect(formatKeyword("work/clients/acme", KEYWORDS, true)).toBe("Work/Clients/Acme");
|
||||
});
|
||||
|
||||
it("returns the plain display name for a tag with one level", () => {
|
||||
expect(formatKeyword("work", KEYWORDS, true)).toBe("Work");
|
||||
});
|
||||
|
||||
it("falls back to the raw level for one this client does not know", () => {
|
||||
expect(formatKeyword("work/archive/2026", KEYWORDS, true)).toBe("Work/archive/2026");
|
||||
expect(formatKeyword("unknown", [], true)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatKeyword with nesting off", () => {
|
||||
it("names a tag by its own label, leaving a slash in the id uninterpreted", () => {
|
||||
// The setting says a slash means nothing, so an id that happens to contain
|
||||
// one - from before it was turned off, or from another client - is a single
|
||||
// opaque token rather than a hierarchy.
|
||||
expect(formatKeyword("work/clients/acme", KEYWORDS, false)).toBe("Acme");
|
||||
expect(formatKeyword("work", KEYWORDS, false)).toBe("Work");
|
||||
});
|
||||
|
||||
it("falls back to the whole id when the tag has no definition", () => {
|
||||
expect(formatKeyword("work/archive/2026", KEYWORDS, false)).toBe("work/archive/2026");
|
||||
});
|
||||
|
||||
it("offers no shortening, leaving the markup to clip", () => {
|
||||
expect(keywordRenderings(formatKeywordLabels("work/clients/acme", KEYWORDS, false)))
|
||||
.toEqual(["Acme"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keywordRenderings", () => {
|
||||
it("shortens by one intermediate level at a time, outermost first", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme", "EU", "Sales"])).toEqual([
|
||||
"Work/Clients/Acme/EU/Sales",
|
||||
"Work/../Acme/EU/Sales",
|
||||
"Work/.../EU/Sales",
|
||||
"Work/.../Sales",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses to a single ... as soon as the run covers more than one level", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme", "Sales"])).toEqual([
|
||||
"Work/Clients/Acme/Sales",
|
||||
"Work/../Acme/Sales",
|
||||
"Work/.../Sales",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses .. for a lone intermediate level, never ...", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme"])).toEqual([
|
||||
"Work/Clients/Acme",
|
||||
"Work/../Acme",
|
||||
]);
|
||||
});
|
||||
|
||||
it("has nothing to shorten without an intermediate level", () => {
|
||||
expect(keywordRenderings(["Work", "Acme"])).toEqual(["Work/Acme"]);
|
||||
expect(keywordRenderings(["Work"])).toEqual(["Work"]);
|
||||
});
|
||||
|
||||
it("drops a rendering that would not come out shorter", () => {
|
||||
// "../" costs as much as the level it replaces, so shortening buys nothing.
|
||||
expect(keywordRenderings(["a", "it", "b"])).toEqual(["a/it/b"]);
|
||||
expect(keywordRenderings(["a", "x", "b"])).toEqual(["a/x/b"]);
|
||||
});
|
||||
});
|
||||
|
||||
// How the components use the two together: resolve a tag to its display names,
|
||||
// then hand the ladder to `useShortenedText` to pick a rung.
|
||||
describe("keywordRenderings over formatKeywordLabels", () => {
|
||||
it("shortens a display name by the same ladder as an id", () => {
|
||||
const deep: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/clients/acme/eu", "Europe"),
|
||||
];
|
||||
|
||||
expect(keywordRenderings(formatKeywordLabels("work/clients/acme/eu", deep, true))).toEqual([
|
||||
"Work/Clients/Acme/Europe",
|
||||
"Work/../Acme/Europe",
|
||||
"Work/.../Europe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a slash inside one display name as part of that name, not a level", () => {
|
||||
const slashed: KeywordDefinition[] = [kw("work", "Work"), kw("work/acme-r-d", "Acme/R&D")];
|
||||
|
||||
// Two levels, so there is no intermediate level to shorten.
|
||||
expect(keywordRenderings(formatKeywordLabels("work/acme-r-d", slashed, true))).toEqual([
|
||||
"Work/Acme/R&D",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
MAX_KEYWORD_ID_LENGTH,
|
||||
buildKeywordTree,
|
||||
composeKeywordId,
|
||||
countKeywordNodes,
|
||||
filterKeywordTree,
|
||||
getParentKeywordId,
|
||||
hasChildKeywords,
|
||||
isKeywordDescendant,
|
||||
keywordLevels,
|
||||
normalizeKeywordLevel,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
|
||||
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||
|
||||
// Work
|
||||
// Clients
|
||||
// Acme
|
||||
// Personal
|
||||
const KEYWORDS: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/personal", "Personal"),
|
||||
];
|
||||
|
||||
describe("normalizeKeywordLevel", () => {
|
||||
it("lowercases and folds unsupported characters into single dashes", () => {
|
||||
expect(normalizeKeywordLevel("My Custom Tag!")).toBe("my-custom-tag");
|
||||
expect(normalizeKeywordLevel(" Spaced Out ")).toBe("spaced-out");
|
||||
expect(normalizeKeywordLevel("--Trimmed--")).toBe("trimmed");
|
||||
});
|
||||
|
||||
it("treats a slash as part of the name, not as a level", () => {
|
||||
expect(normalizeKeywordLevel("Acme/R&D")).toBe("acme-r-d");
|
||||
});
|
||||
|
||||
it("returns an empty string when nothing usable is left", () => {
|
||||
expect(normalizeKeywordLevel(" ")).toBe("");
|
||||
expect(normalizeKeywordLevel("!!!")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeKeywordId", () => {
|
||||
it("returns a bare slug at the top level", () => {
|
||||
expect(composeKeywordId(null, "Work")).toBe("work");
|
||||
expect(composeKeywordId("", "Work")).toBe("work");
|
||||
});
|
||||
|
||||
it("appends the slug below the parent", () => {
|
||||
expect(composeKeywordId("work/clients", "Acme")).toBe("work/clients/acme");
|
||||
});
|
||||
|
||||
it("never produces a trailing separator for an unusable name", () => {
|
||||
expect(composeKeywordId("work", "!!!")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keywordLevels", () => {
|
||||
it("splits an id into its levels", () => {
|
||||
expect(keywordLevels("work/clients/acme")).toEqual(["work", "clients", "acme"]);
|
||||
expect(keywordLevels("work")).toEqual(["work"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getParentKeywordId", () => {
|
||||
it("drops the last level", () => {
|
||||
expect(getParentKeywordId("work/clients/acme")).toBe("work/clients");
|
||||
});
|
||||
|
||||
it("returns null for a top-level tag", () => {
|
||||
expect(getParentKeywordId("work")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isKeywordDescendant", () => {
|
||||
it("matches anything below the ancestor", () => {
|
||||
expect(isKeywordDescendant("work/clients/acme", "work")).toBe(true);
|
||||
expect(isKeywordDescendant("work/clients", "work")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the ancestor itself or a shared name prefix", () => {
|
||||
expect(isKeywordDescendant("work", "work")).toBe(false);
|
||||
expect(isKeywordDescendant("workshop/tools", "work")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasChildKeywords", () => {
|
||||
it("reports whether any defined tag sits below the given one", () => {
|
||||
expect(hasChildKeywords("work", KEYWORDS)).toBe(true);
|
||||
expect(hasChildKeywords("work/clients", KEYWORDS)).toBe(true);
|
||||
expect(hasChildKeywords("work/clients/acme", KEYWORDS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_KEYWORD_ID_LENGTH", () => {
|
||||
it("leaves room for the `$label:` prefix within the 255-character keyword limit", () => {
|
||||
expect(MAX_KEYWORD_ID_LENGTH).toBe(248);
|
||||
expect("$label:".length + MAX_KEYWORD_ID_LENGTH).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildKeywordTree", () => {
|
||||
it("nests each tag under its parent and records the depth", () => {
|
||||
const [work] = buildKeywordTree(KEYWORDS);
|
||||
|
||||
expect(work.id).toBe("work");
|
||||
expect(work.depth).toBe(0);
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients", "work/personal"]);
|
||||
|
||||
const clients = work.children[0];
|
||||
expect(clients.depth).toBe(1);
|
||||
expect(clients.children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||
expect(clients.children[0].depth).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the manual order within a level", () => {
|
||||
const reordered = [KEYWORDS[0], KEYWORDS[3], KEYWORDS[1], KEYWORDS[2]];
|
||||
const [work] = buildKeywordTree(reordered);
|
||||
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/personal", "work/clients"]);
|
||||
});
|
||||
|
||||
it("keeps a tag whose parent is not defined at the root", () => {
|
||||
const orphan = buildKeywordTree([kw("work/clients/acme", "Acme")]);
|
||||
|
||||
expect(orphan).toHaveLength(1);
|
||||
expect(orphan[0].id).toBe("work/clients/acme");
|
||||
expect(orphan[0].depth).toBe(0);
|
||||
});
|
||||
|
||||
it("returns every tag as a root when no id describes a hierarchy", () => {
|
||||
const flat = buildKeywordTree([kw("red", "Red"), kw("blue", "Blue")]);
|
||||
|
||||
expect(flat.map((n) => n.id)).toEqual(["red", "blue"]);
|
||||
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterKeywordTree", () => {
|
||||
const tree = buildKeywordTree(KEYWORDS);
|
||||
|
||||
it("drops the nodes the predicate rejects", () => {
|
||||
const kept = filterKeywordTree(tree, (node) => node.id !== "work/personal");
|
||||
const [work] = kept;
|
||||
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||
});
|
||||
|
||||
it("keeps a rejected node when a descendant survives, so nothing is stranded", () => {
|
||||
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||
const [work] = kept;
|
||||
|
||||
expect(work.id).toBe("work");
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||
expect(work.children[0].children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||
});
|
||||
|
||||
it("keeps the depth of a surviving node so its indentation does not shift", () => {
|
||||
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||
|
||||
expect(kept[0].children[0].children[0].depth).toBe(2);
|
||||
});
|
||||
|
||||
it("returns nothing when the predicate rejects everything", () => {
|
||||
expect(filterKeywordTree(tree, () => false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the original tree untouched", () => {
|
||||
filterKeywordTree(tree, (node) => node.id === "work");
|
||||
|
||||
expect(countKeywordNodes(tree)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countKeywordNodes", () => {
|
||||
it("counts every level, not just the roots", () => {
|
||||
expect(countKeywordNodes(buildKeywordTree(KEYWORDS))).toBe(4);
|
||||
expect(countKeywordNodes([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
sortThreadGroups,
|
||||
getThreadParticipants,
|
||||
mergeThreadEmails,
|
||||
getEmailColorTag,
|
||||
getThreadColorTag,
|
||||
getEmailTagId,
|
||||
getEmailTagIds,
|
||||
getThreadTagId,
|
||||
getThreadTagIds,
|
||||
} from '../thread-utils';
|
||||
import type { Email, ThreadGroup } from '../jmap/types';
|
||||
|
||||
@@ -245,47 +247,71 @@ describe('mergeThreadEmails', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailColorTag', () => {
|
||||
it('returns label from $label: keyword', () => {
|
||||
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
|
||||
describe('getEmailTagIds', () => {
|
||||
it('gathers every tag set on the message', () => {
|
||||
expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true }))
|
||||
.toEqual(['red', 'work']);
|
||||
});
|
||||
|
||||
it('returns label from legacy $color: keyword', () => {
|
||||
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
|
||||
it('reads the legacy prefix alongside the current one', () => {
|
||||
expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']);
|
||||
});
|
||||
|
||||
it('returns null when no color keyword', () => {
|
||||
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined keywords', () => {
|
||||
expect(getEmailColorTag(undefined)).toBeNull();
|
||||
it('reports a tag written under both prefixes once', () => {
|
||||
expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']);
|
||||
});
|
||||
|
||||
it('ignores keywords set to false', () => {
|
||||
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||
expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']);
|
||||
});
|
||||
|
||||
it('prefers $label: over $color: when both exist', () => {
|
||||
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles custom keyword ids', () => {
|
||||
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||
});
|
||||
|
||||
it('returns null for empty keywords object', () => {
|
||||
expect(getEmailColorTag({})).toBeNull();
|
||||
it('is empty for an untagged message or none at all', () => {
|
||||
expect(getEmailTagIds({ $seen: true })).toEqual([]);
|
||||
expect(getEmailTagIds(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadColorTag', () => {
|
||||
describe('getEmailTagId', () => {
|
||||
it('returns label from $label: keyword', () => {
|
||||
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
|
||||
});
|
||||
|
||||
it('returns label from legacy $color: keyword', () => {
|
||||
expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
|
||||
});
|
||||
|
||||
it('returns null when no color keyword', () => {
|
||||
expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined keywords', () => {
|
||||
expect(getEmailTagId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores keywords set to false', () => {
|
||||
expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers $label: over $color: when both exist', () => {
|
||||
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles custom keyword ids', () => {
|
||||
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||
});
|
||||
|
||||
it('returns null for empty keywords object', () => {
|
||||
expect(getEmailTagId({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadTagId', () => {
|
||||
it('returns first color found across thread emails', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('blue');
|
||||
expect(getThreadTagId(emails)).toBe('blue');
|
||||
});
|
||||
|
||||
it('returns null when no emails have color tags', () => {
|
||||
@@ -293,7 +319,7 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBeNull();
|
||||
expect(getThreadTagId(emails)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns first tag from earliest tagged email', () => {
|
||||
@@ -301,7 +327,7 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('red');
|
||||
expect(getThreadTagId(emails)).toBe('red');
|
||||
});
|
||||
|
||||
it('returns legacy tag from thread emails', () => {
|
||||
@@ -309,10 +335,41 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$color:green': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('green');
|
||||
expect(getThreadTagId(emails)).toBe('green');
|
||||
});
|
||||
|
||||
it('returns null for empty email array', () => {
|
||||
expect(getThreadColorTag([])).toBeNull();
|
||||
expect(getThreadTagId([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadTagIds', () => {
|
||||
it('gathers the tags of every message in the thread', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']);
|
||||
});
|
||||
|
||||
it('reports a tag shared by several messages once', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails)).toEqual(['red']);
|
||||
});
|
||||
|
||||
it('reads the legacy prefix alongside the current one', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$color:green': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']);
|
||||
});
|
||||
|
||||
it('is empty for an untagged or empty thread', () => {
|
||||
expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]);
|
||||
expect(getThreadTagIds([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Naming a tag on screen.
|
||||
*
|
||||
* A nested tag is written out level by level - `Work/Clients/Acme` - and a flat
|
||||
* one is simply its own name, so nothing here asks the caller which kind it
|
||||
* has. `keywordRenderings` additionally offers progressively shorter forms for
|
||||
* a name with nowhere to fit, which `useShortenedText` measures against the
|
||||
* room actually available.
|
||||
*/
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
import { KEYWORD_SEPARATOR, keywordLevels } from "./keyword-nesting";
|
||||
|
||||
/** Stands in for one level left out of a name. */
|
||||
export const KEYWORD_SHORTENED_LEVEL = "..";
|
||||
|
||||
/** Stands in for a run of more than one level left out of a name. */
|
||||
export const KEYWORD_SHORTENED_RUN = "...";
|
||||
|
||||
/**
|
||||
* The display name of a tag, one entry per level, outermost first. A tag with
|
||||
* one level yields a single entry, so callers need not care either way.
|
||||
*
|
||||
* `nested` is the user's setting. With nesting off a slash carries no meaning,
|
||||
* so the id is one opaque token and the tag is named by its own label - nobody
|
||||
* who left the setting alone should find their tags rewritten because an id
|
||||
* happens to contain a slash, which can outlast turning nesting off, or arrive
|
||||
* through settings sync or another client.
|
||||
*
|
||||
* With nesting on, each level resolves to that tag's display name, falling back
|
||||
* to the raw level of the id when it has no definition - the settings list only
|
||||
* describes the tags this client knows about. Levels stay separate entries
|
||||
* because a display name may itself contain a slash, which is part of that one
|
||||
* name rather than a level of its own.
|
||||
*/
|
||||
export function formatKeywordLabels(
|
||||
id: string,
|
||||
keywords: KeywordDefinition[],
|
||||
nested: boolean,
|
||||
): string[] {
|
||||
const label = (levelId: string) => keywords.find((keyword) => keyword.id === levelId)?.label;
|
||||
if (!nested) return [label(id) ?? id];
|
||||
|
||||
const levels = keywordLevels(id);
|
||||
return levels.map((level, index) =>
|
||||
label(levels.slice(0, index + 1).join(KEYWORD_SEPARATOR)) ?? level,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The display name of a tag: `Work/Clients/Acme` for a nested one, its own name
|
||||
* otherwise. The general way to name a tag on screen.
|
||||
*/
|
||||
export function formatKeyword(
|
||||
id: string,
|
||||
keywords: KeywordDefinition[],
|
||||
nested: boolean,
|
||||
): string {
|
||||
return formatKeywordLabels(id, keywords, nested).join(KEYWORD_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way a name can be written, longest first: in full, then with an ever
|
||||
* longer run of intermediate levels replaced by `..`, collapsing to a single
|
||||
* `...` as soon as that run covers more than one level.
|
||||
*
|
||||
* The outermost and innermost levels always survive - between them they say
|
||||
* which branch a tag belongs to and which tag it is, which is exactly what a
|
||||
* trailing ellipsis destroys. A rendering that would not actually come out
|
||||
* shorter than the one before it (levels named `it`, say) is dropped, so
|
||||
* walking the list never makes the text grow.
|
||||
*/
|
||||
export function keywordRenderings(levels: string[]): string[] {
|
||||
const renderings = [levels.join(KEYWORD_SEPARATOR)];
|
||||
for (let shortened = 1; shortened <= levels.length - 2; shortened++) {
|
||||
const marker = shortened === 1 ? KEYWORD_SHORTENED_LEVEL : KEYWORD_SHORTENED_RUN;
|
||||
const rendering = [levels[0], marker, ...levels.slice(shortened + 1)]
|
||||
.join(KEYWORD_SEPARATOR);
|
||||
if (rendering.length < renderings[renderings.length - 1].length) {
|
||||
renderings.push(rendering);
|
||||
}
|
||||
}
|
||||
return renderings;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tag nesting.
|
||||
*
|
||||
* A tag is stored on the server as the JMAP keyword `$label:<id>`, where `id`
|
||||
* is a slug derived from the display name. Nesting reuses that single id: the
|
||||
* levels are joined with a forward slash, so `$label:work/clients` is the child
|
||||
* of `$label:work`. Keeping the hierarchy inside the id means the server stays
|
||||
* the source of truth for tag membership and existing lookups by keyword keep
|
||||
* working.
|
||||
*
|
||||
* RFC 8621 section 4.1.1 allows a keyword of 1-255 characters from the ASCII
|
||||
* range %x21-%x7e minus `( ) { ] % * " \`, so the separator is legal but the
|
||||
* length of a deep id is not free - `MAX_KEYWORD_ID_LENGTH` is the budget a
|
||||
* composed id has to stay within.
|
||||
*
|
||||
* Turning any of this into text for the screen lives in `keyword-format`.
|
||||
*/
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
import { KEYWORD_PREFIX } from "./thread-utils";
|
||||
|
||||
/** Separates parent from child inside a tag id. */
|
||||
export const KEYWORD_SEPARATOR = "/";
|
||||
|
||||
/** Longest keyword a JMAP server has to accept (RFC 8621, section 4.1.1). */
|
||||
export const MAX_KEYWORD_LENGTH = 255;
|
||||
|
||||
/** What is left for the id once the `$label:` prefix is spent. */
|
||||
export const MAX_KEYWORD_ID_LENGTH = MAX_KEYWORD_LENGTH - KEYWORD_PREFIX.length;
|
||||
|
||||
/** A tag definition placed in the hierarchy its id describes. */
|
||||
export interface KeywordNode extends KeywordDefinition {
|
||||
children: KeywordNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a display name to one level of an id: lowercase, and everything
|
||||
* outside `[a-z0-9_-]` folded to a single dash. The separator is not exempt -
|
||||
* a slash typed into the name is a literal part of that name, not a level.
|
||||
* The only slug function for tag ids; keep it the only one.
|
||||
*/
|
||||
export function normalizeKeywordLevel(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/** Builds the id a tag named `name` gets under `parentId` (null = top level). */
|
||||
export function composeKeywordId(parentId: string | null, name: string): string {
|
||||
const level = normalizeKeywordLevel(name);
|
||||
if (!parentId || !level) return level;
|
||||
return `${parentId}${KEYWORD_SEPARATOR}${level}`;
|
||||
}
|
||||
|
||||
/** Splits `work/clients/acme` into `["work", "clients", "acme"]`. */
|
||||
export function keywordLevels(id: string): string[] {
|
||||
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The id of the tag one level up, or null for a top-level tag. */
|
||||
export function getParentKeywordId(id: string): string | null {
|
||||
const index = id.lastIndexOf(KEYWORD_SEPARATOR);
|
||||
return index === -1 ? null : id.slice(0, index);
|
||||
}
|
||||
|
||||
/** True when `candidateId` sits anywhere below `ancestorId`. */
|
||||
export function isKeywordDescendant(candidateId: string, ancestorId: string): boolean {
|
||||
return candidateId.startsWith(`${ancestorId}${KEYWORD_SEPARATOR}`);
|
||||
}
|
||||
|
||||
/** True when any defined tag sits below `id`. */
|
||||
export function hasChildKeywords(id: string, keywords: KeywordDefinition[]): boolean {
|
||||
return keywords.some((keyword) => isKeywordDescendant(keyword.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Arranges tag definitions into the tree their ids describe, preserving the
|
||||
* user's manual order within each level.
|
||||
*
|
||||
* A tag whose direct parent is not defined stays at the root rather than being
|
||||
* hidden or grafted onto a grandparent; callers name such a root in full so the
|
||||
* missing level is still visible.
|
||||
*/
|
||||
export function buildKeywordTree(keywords: KeywordDefinition[]): KeywordNode[] {
|
||||
const nodes = new Map<string, KeywordNode>();
|
||||
for (const keyword of keywords) {
|
||||
nodes.set(keyword.id, { ...keyword, children: [], depth: 0 });
|
||||
}
|
||||
|
||||
const roots: KeywordNode[] = [];
|
||||
for (const keyword of keywords) {
|
||||
const node = nodes.get(keyword.id)!;
|
||||
const parentId = getParentKeywordId(keyword.id);
|
||||
const parent = parentId ? nodes.get(parentId) : undefined;
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const setDepth = (node: KeywordNode, depth: number) => {
|
||||
node.depth = depth;
|
||||
node.children.forEach((child) => setDepth(child, depth + 1));
|
||||
};
|
||||
roots.forEach((root) => setDepth(root, 0));
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prunes a tag tree down to the nodes worth showing.
|
||||
*
|
||||
* A node survives when the predicate accepts it or when any of its descendants
|
||||
* survives, so hiding a parent never strands the children below it. Depths are
|
||||
* left untouched: a kept node keeps the indentation of its original level even
|
||||
* when the level above it is only there to carry it.
|
||||
*/
|
||||
export function filterKeywordTree(
|
||||
nodes: KeywordNode[],
|
||||
isVisible: (node: KeywordNode) => boolean,
|
||||
): KeywordNode[] {
|
||||
const kept: KeywordNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const children = filterKeywordTree(node.children, isVisible);
|
||||
if (children.length > 0 || isVisible(node)) {
|
||||
kept.push({ ...node, children });
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Total number of nodes in a tag tree, at every level. */
|
||||
export function countKeywordNodes(nodes: KeywordNode[]): number {
|
||||
return nodes.reduce((total, node) => total + 1 + countKeywordNodes(node.children), 0);
|
||||
}
|
||||
+30
-12
@@ -168,41 +168,59 @@ export const KEYWORD_PREFIX = "$label:";
|
||||
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
||||
|
||||
/**
|
||||
* Gets all active label/color tag IDs from email keywords.
|
||||
* Gets every tag id set on a message.
|
||||
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||
* A tag written under both spellings is one tag, so it is returned once.
|
||||
*/
|
||||
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
|
||||
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
const tags = new Set<string>();
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
||||
tags.push(
|
||||
tags.add(
|
||||
key.startsWith(KEYWORD_PREFIX)
|
||||
? key.slice(KEYWORD_PREFIX.length)
|
||||
: key.slice(KEYWORD_PREFIX_LEGACY.length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets label/color tag from email keywords (if any).
|
||||
* Gets the first tag id set on a message, if any.
|
||||
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||
* @deprecated Use getEmailColorTags for multi-tag support.
|
||||
* @deprecated Use getEmailTagIds for multi-tag support.
|
||||
*/
|
||||
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
||||
const tags = getEmailColorTags(keywords);
|
||||
export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
|
||||
const tags = getEmailTagIds(keywords);
|
||||
return tags.length > 0 ? tags[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a thread has any color tag (returns first found).
|
||||
* The first tag id found anywhere in a thread, if any.
|
||||
*/
|
||||
export function getThreadColorTag(emails: Email[]): string | null {
|
||||
export function getThreadTagId(emails: Email[]): string | null {
|
||||
for (const email of emails) {
|
||||
const color = getEmailColorTag(email.keywords);
|
||||
const color = getEmailTagId(email.keywords);
|
||||
if (color) return color;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tag anywhere in a thread, deduplicated.
|
||||
*
|
||||
* A collapsed thread row stands in for all its messages, so it has to account
|
||||
* for all their tags - showing only the first message's would hide the rest
|
||||
* with nothing to indicate they exist.
|
||||
*/
|
||||
export function getThreadTagIds(emails: Email[]): string[] {
|
||||
const tags = new Set<string>();
|
||||
for (const email of emails) {
|
||||
for (const tag of getEmailTagIds(email.keywords)) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "إعادة تعيين",
|
||||
"demo_tour": "جولة",
|
||||
"tags": "الوسوم",
|
||||
"show_all_tags": "إظهار الكل ({count})",
|
||||
"show_fewer_tags": "إظهار أقل",
|
||||
"folders": "المجلدات",
|
||||
"shared": "مشترك",
|
||||
"mail": "البريد",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "عرض جهة الاتصال",
|
||||
"message_details": "تفاصيل الرسالة",
|
||||
"more_reply_options": "خيارات رد إضافية",
|
||||
"set_color": "تعيين وسم",
|
||||
"set_tag": "تعيين وسم",
|
||||
"tag": "وسم",
|
||||
"more_actions": "المزيد من الإجراءات",
|
||||
"previous": "السابق",
|
||||
"next": "التالي",
|
||||
"move_to": "نقل إلى...",
|
||||
"remove_color": "إزالة الوسم",
|
||||
"remove_tag": "إزالة الوسم",
|
||||
"tag_filter_placeholder": "تصفية الوسوم",
|
||||
"tag_no_matches": "لا توجد وسوم مطابقة",
|
||||
"more_count": "+{count} أخرى",
|
||||
"characters_count": "{count} حرفًا",
|
||||
"quick_reply_placeholder": "اكتب ردًا سريعًا...",
|
||||
@@ -425,17 +429,6 @@
|
||||
"message_id": "معرّف الرسالة",
|
||||
"list_info": "معلومات القائمة"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "وسم لوني",
|
||||
"red": "أحمر",
|
||||
"orange": "برتقالي",
|
||||
"yellow": "أصفر",
|
||||
"green": "أخضر",
|
||||
"blue": "أزرق",
|
||||
"purple": "بنفسجي",
|
||||
"pink": "وردي",
|
||||
"none": "بلا"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "رد (r)",
|
||||
"reply_all": "الرد على الجميع (a)",
|
||||
@@ -1019,7 +1012,6 @@
|
||||
"title": "وسوم البريد",
|
||||
"description": "عرّف وسومًا لتنظيم رسائلك بالألوان. تُخزَّن هذه ككلمات مفتاحية JMAP على الخادم.",
|
||||
"add_keyword": "إضافة وسم",
|
||||
"reset_defaults": "إعادة التعيين للافتراضي",
|
||||
"label_field": "الاسم المعروض",
|
||||
"label_placeholder": "مثال: عمل، شخصي، عاجل",
|
||||
"id_field": "معرّف الوسم",
|
||||
@@ -1032,7 +1024,22 @@
|
||||
"add": "إضافة",
|
||||
"cancel": "إلغاء",
|
||||
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
|
||||
"migration_error": "فشل تحديث الوسم على الرسائل الحالية"
|
||||
"migration_error": "فشل تحديث الوسم على الرسائل الحالية",
|
||||
"nesting": {
|
||||
"label": "وسوم متداخلة",
|
||||
"description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي."
|
||||
},
|
||||
"parent_field": "الوسم الأصل",
|
||||
"no_parent": "بدون وسم أصل",
|
||||
"too_long": "مسار الوسم طويل جدًا ({max} حرفًا على الأكثر)",
|
||||
"has_children_locked": "توجد وسوم أخرى متداخلة تحت هذا الوسم، لذا فإن اسمه ووسمه الأصل مقفلان. انقلها أو احذفها أولًا.",
|
||||
"has_children_delete": "احذف أولًا الوسوم المتداخلة تحت هذا الوسم",
|
||||
"visibility_field": "الظهور في الشريط الجانبي",
|
||||
"visibility": {
|
||||
"show": "إظهار",
|
||||
"unread": "إظهار عند وجود غير مقروء",
|
||||
"hide": "إخفاء"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "اختبار صوت الإشعار",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "حذف",
|
||||
"mark_as_spam": "الإبلاغ عن بريد مزعج",
|
||||
"not_spam": "ليس مزعجًا",
|
||||
"color_tag": "وسم",
|
||||
"remove_color": "إزالة الوسم",
|
||||
"tag": "وسم",
|
||||
"items_selected": "{count} رسالة محددة",
|
||||
"edit_draft": "تعديل المسودة",
|
||||
"cancel_scheduled_send": "إلغاء الإرسال",
|
||||
|
||||
+55
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Reinicia",
|
||||
"demo_tour": "Visita guiada",
|
||||
"tags": "Etiquetes",
|
||||
"show_all_tags": "Mostra-ho tot ({count})",
|
||||
"show_fewer_tags": "Mostra'n menys",
|
||||
"folders": "Carpetes",
|
||||
"shared": "Compartit",
|
||||
"mail": "Correu",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Mostra el contacte",
|
||||
"message_details": "Detalls del missatge",
|
||||
"more_reply_options": "Més opcions de resposta",
|
||||
"set_color": "Estableix l'etiqueta",
|
||||
"set_tag": "Estableix l'etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"more_actions": "Més accions",
|
||||
"previous": "Anterior",
|
||||
"next": "Següent",
|
||||
"move_to": "Mou a...",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
"remove_tag": "Elimina l'etiqueta",
|
||||
"tag_filter_placeholder": "Filtra les etiquetes",
|
||||
"tag_no_matches": "Cap etiqueta coincident",
|
||||
"more_count": "+{count} més",
|
||||
"characters_count": "{count} caràcters",
|
||||
"quick_reply_placeholder": "Escriviu una resposta ràpida...",
|
||||
@@ -425,17 +429,6 @@
|
||||
"message_id": "ID del missatge",
|
||||
"list_info": "Informació de la llista"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etiqueta de color",
|
||||
"red": "Vermell",
|
||||
"orange": "Taronja",
|
||||
"yellow": "Groc",
|
||||
"green": "Verd",
|
||||
"blue": "Blau",
|
||||
"purple": "Lila",
|
||||
"pink": "Rosa",
|
||||
"none": "Cap"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Respon (r)",
|
||||
"reply_all": "Respon a tots (a)",
|
||||
@@ -687,6 +680,38 @@
|
||||
"recipient_name_placeholder": "Nom mostrat",
|
||||
"autocomplete_search_server": "Cerca al servidor",
|
||||
"autocomplete_searching": "Cercant...",
|
||||
"toolbar": {
|
||||
"bold": "Negreta",
|
||||
"italic": "Cursiva",
|
||||
"underline": "Subratllat",
|
||||
"strikethrough": "Ratllat",
|
||||
"text_color": "Color del text",
|
||||
"remove_color": "Elimina el color",
|
||||
"heading_1": "Encapçalament 1",
|
||||
"heading_2": "Encapçalament 2",
|
||||
"bullet_list": "Llista de pics",
|
||||
"ordered_list": "Llista numerada",
|
||||
"quote": "Cita",
|
||||
"code_block": "Bloc de codi",
|
||||
"align_left": "Alinea a l'esquerra",
|
||||
"align_center": "Centra",
|
||||
"align_right": "Alinea a la dreta",
|
||||
"text_direction": "Direcció del text (RTL/LTR)",
|
||||
"link": "Enllaç",
|
||||
"table": "Taula",
|
||||
"clear_formatting": "Neteja el format",
|
||||
"undo": "Desfés",
|
||||
"redo": "Refés",
|
||||
"add_row_above": "Afegeix una fila a sobre",
|
||||
"add_row_below": "Afegeix una fila a sota",
|
||||
"add_column_before": "Afegeix una columna abans",
|
||||
"add_column_after": "Afegeix una columna després",
|
||||
"delete_row": "Elimina la fila",
|
||||
"delete_column": "Elimina la columna",
|
||||
"toggle_header_row": "Commuta la fila de capçalera",
|
||||
"delete_table": "Elimina la taula",
|
||||
"pick_size": "Tria la mida"
|
||||
},
|
||||
"send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet."
|
||||
},
|
||||
"confirm_dialog": {
|
||||
@@ -987,7 +1012,6 @@
|
||||
"title": "Etiquetes de correu",
|
||||
"description": "Definiu etiquetes per organitzar els correus amb colors. Es desen com a paraules clau JMAP al servidor.",
|
||||
"add_keyword": "Afegeix una etiqueta",
|
||||
"reset_defaults": "Restableix als valors per defecte",
|
||||
"label_field": "Nom mostrat",
|
||||
"label_placeholder": "p. ex. Feina, Personal, Urgent",
|
||||
"id_field": "ID de l'etiqueta",
|
||||
@@ -1000,7 +1024,22 @@
|
||||
"add": "Afegeix",
|
||||
"cancel": "Cancel·la",
|
||||
"migrating": "Actualitzant l'etiqueta als correus existents…",
|
||||
"migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents"
|
||||
"migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents",
|
||||
"nesting": {
|
||||
"label": "Etiquetes imbricades",
|
||||
"description": "Imbrica etiquetes sota altres etiquetes i mostra-les com un arbre a la barra lateral."
|
||||
},
|
||||
"parent_field": "Etiqueta principal",
|
||||
"no_parent": "Sense etiqueta principal",
|
||||
"too_long": "Aquest camí d'etiqueta és massa llarg (com a màxim {max} caràcters)",
|
||||
"has_children_locked": "Hi ha altres etiquetes imbricades sota aquesta, per això el seu nom i la seva etiqueta principal estan bloquejats. Mou-les o elimina-les primer.",
|
||||
"has_children_delete": "Elimina primer les etiquetes imbricades sota aquesta",
|
||||
"visibility_field": "Visibilitat a la barra lateral",
|
||||
"visibility": {
|
||||
"show": "Mostra",
|
||||
"unread": "Mostra si hi ha no llegits",
|
||||
"hide": "Amaga"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Prova el so de notificació",
|
||||
@@ -2002,8 +2041,7 @@
|
||||
"delete": "Suprimeix",
|
||||
"mark_as_spam": "Denuncia com a brossa",
|
||||
"not_spam": "No és brossa",
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"items_selected": "{count} correus seleccionats",
|
||||
"edit_draft": "Edita l'esborrany",
|
||||
"cancel_scheduled_send": "Cancel·la l'enviament",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Resetovat",
|
||||
"demo_tour": "Průvodce",
|
||||
"tags": "Štítky",
|
||||
"show_all_tags": "Zobrazit vše ({count})",
|
||||
"show_fewer_tags": "Zobrazit méně",
|
||||
"folders": "Složky",
|
||||
"shared": "Sdílené",
|
||||
"mail": "Pošta",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Zobrazit kontakt",
|
||||
"message_details": "Podrobnosti zprávy",
|
||||
"more_reply_options": "Další možnosti odpovědi",
|
||||
"set_color": "Nastavit štítek",
|
||||
"set_tag": "Nastavit štítek",
|
||||
"tag": "Štítek",
|
||||
"more_actions": "Další akce",
|
||||
"previous": "Předchozí",
|
||||
"next": "Další",
|
||||
"move_to": "Přesunout do...",
|
||||
"remove_color": "Odebrat štítek",
|
||||
"remove_tag": "Odebrat štítek",
|
||||
"tag_filter_placeholder": "Filtrovat štítky",
|
||||
"tag_no_matches": "Žádné odpovídající štítky",
|
||||
"more_count": "+{count} dalších",
|
||||
"characters_count": "{count} znaků",
|
||||
"quick_reply_placeholder": "Napsat rychlou odpověď...",
|
||||
@@ -400,17 +404,6 @@
|
||||
"message_id": "ID zprávy",
|
||||
"list_info": "Informace o konferenci"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Barevný štítek",
|
||||
"red": "Červený",
|
||||
"orange": "Oranžový",
|
||||
"yellow": "Žlutý",
|
||||
"green": "Zelený",
|
||||
"blue": "Modrý",
|
||||
"purple": "Fialový",
|
||||
"pink": "Růžový",
|
||||
"none": "Žádný"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Odpovědět (r)",
|
||||
"reply_all": "Odpovědět všem (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "E-mailové štítky",
|
||||
"description": "Definujte štítky pro organizaci e-mailů pomocí barev. Ukládají se jako klíčová slova JMAP na serveru.",
|
||||
"add_keyword": "Přidat štítek",
|
||||
"reset_defaults": "Obnovit výchozí",
|
||||
"label_field": "Zobrazovaný název",
|
||||
"label_placeholder": "např. Práce, Osobní, Naliehavé",
|
||||
"id_field": "ID štítku",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Přidat",
|
||||
"cancel": "Zrušit",
|
||||
"migrating": "Aktualizace štítku v existujících e-mailech…",
|
||||
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech"
|
||||
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech",
|
||||
"nesting": {
|
||||
"label": "Vnořené štítky",
|
||||
"description": "Vnořujte štítky pod jiné štítky a zobrazujte je v postranním panelu jako strom."
|
||||
},
|
||||
"parent_field": "Nadřazený štítek",
|
||||
"no_parent": "Bez nadřazeného štítku",
|
||||
"too_long": "Tato cesta štítku je příliš dlouhá (nejvýše {max} znaků)",
|
||||
"has_children_locked": "Pod tímto štítkem jsou vnořeny další štítky, proto jsou jeho název a nadřazený štítek uzamčeny. Nejprve je přesuňte nebo odeberte.",
|
||||
"has_children_delete": "Nejprve odeberte štítky vnořené pod tímto",
|
||||
"visibility_field": "Viditelnost v postranním panelu",
|
||||
"visibility": {
|
||||
"show": "Zobrazit",
|
||||
"unread": "Zobrazit při nepřečtených",
|
||||
"hide": "Skrýt"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Otestovat zvuk oznámení",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Odstranit",
|
||||
"mark_as_spam": "Nahlásit spam",
|
||||
"not_spam": "Není spam",
|
||||
"color_tag": "Štítek",
|
||||
"remove_color": "Odebrat štítek",
|
||||
"tag": "Štítek",
|
||||
"items_selected": "{count} vybraných zpráv",
|
||||
"edit_draft": "Upravit koncept",
|
||||
"cancel_scheduled_send": "Zrušit odeslání",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Nulstil",
|
||||
"demo_tour": "Rundvisning",
|
||||
"tags": "Tags",
|
||||
"show_all_tags": "Vis alle ({count})",
|
||||
"show_fewer_tags": "Vis færre",
|
||||
"folders": "Mapper",
|
||||
"shared": "Delt",
|
||||
"mail": "Mail",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Vis kontakt",
|
||||
"message_details": "Beskeddetaljer",
|
||||
"more_reply_options": "Flere svar-muligheder",
|
||||
"set_color": "Sæt tag",
|
||||
"set_tag": "Sæt tag",
|
||||
"tag": "Tag",
|
||||
"more_actions": "Flere handlinger",
|
||||
"previous": "Forrige",
|
||||
"next": "Næste",
|
||||
"move_to": "Flyt til...",
|
||||
"remove_color": "Fjern tag",
|
||||
"remove_tag": "Fjern tag",
|
||||
"tag_filter_placeholder": "Filtrer tags",
|
||||
"tag_no_matches": "Ingen matchende tags",
|
||||
"more_count": "+{count} mere",
|
||||
"characters_count": "{count} tegn",
|
||||
"quick_reply_placeholder": "Skriv et hurtigt svar...",
|
||||
@@ -425,17 +429,6 @@
|
||||
"message_id": "Besked-ID",
|
||||
"list_info": "Listeinformation"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Farvetag",
|
||||
"red": "Rød",
|
||||
"orange": "Orange",
|
||||
"yellow": "Gul",
|
||||
"green": "Grøn",
|
||||
"blue": "Blå",
|
||||
"purple": "Lilla",
|
||||
"pink": "Pink",
|
||||
"none": "Ingen"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Svar (r)",
|
||||
"reply_all": "Svar alle (a)",
|
||||
@@ -1019,7 +1012,6 @@
|
||||
"title": "E-mail-tags",
|
||||
"description": "Definér tags til at organisere dine e-mails med farver. Disse gemmes som JMAP-nøgleord på serveren.",
|
||||
"add_keyword": "Tilføj tag",
|
||||
"reset_defaults": "Nulstil til standard",
|
||||
"label_field": "Visningsnavn",
|
||||
"label_placeholder": "f.eks. Arbejde, Privat, Vigtigt",
|
||||
"id_field": "Tag-ID",
|
||||
@@ -1032,7 +1024,22 @@
|
||||
"add": "Tilføj",
|
||||
"cancel": "Annuller",
|
||||
"migrating": "Opdaterer tag på eksisterende e-mails…",
|
||||
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails"
|
||||
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails",
|
||||
"nesting": {
|
||||
"label": "Indlejrede tags",
|
||||
"description": "Indlejr tags under andre tags og vis dem som et træ i sidepanelet."
|
||||
},
|
||||
"parent_field": "Overordnet tag",
|
||||
"no_parent": "Intet overordnet tag",
|
||||
"too_long": "Denne tagsti er for lang (højst {max} tegn)",
|
||||
"has_children_locked": "Andre tags er indlejret under dette, så dets navn og overordnede tag er låst. Flyt eller fjern dem først.",
|
||||
"has_children_delete": "Fjern først de tags, der er indlejret under dette",
|
||||
"visibility_field": "Synlighed i sidepanel",
|
||||
"visibility": {
|
||||
"show": "Vis",
|
||||
"unread": "Vis ved ulæste",
|
||||
"hide": "Skjul"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Test notifikationslyd",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Slet",
|
||||
"mark_as_spam": "Rapportér spam",
|
||||
"not_spam": "Ikke spam",
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Fjern tag",
|
||||
"tag": "Tag",
|
||||
"items_selected": "{count} e-mails valgt",
|
||||
"edit_draft": "Redigér kladde",
|
||||
"cancel_scheduled_send": "Annuller afsendelse",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Zurücksetzen",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Tags",
|
||||
"show_all_tags": "Alle anzeigen ({count})",
|
||||
"show_fewer_tags": "Weniger anzeigen",
|
||||
"folders": "Ordner",
|
||||
"mail": "E-Mail",
|
||||
"nav_label": "Navigation",
|
||||
@@ -325,11 +327,13 @@
|
||||
"view_contact": "Kontakt anzeigen",
|
||||
"message_details": "Nachrichtendetails",
|
||||
"more_reply_options": "Weitere Antwortoptionen",
|
||||
"set_color": "Label setzen",
|
||||
"set_tag": "Label setzen",
|
||||
"tag": "Label",
|
||||
"more_actions": "Weitere Aktionen",
|
||||
"move_to": "Verschieben nach...",
|
||||
"remove_color": "Label entfernen",
|
||||
"remove_tag": "Label entfernen",
|
||||
"tag_filter_placeholder": "Labels filtern",
|
||||
"tag_no_matches": "Keine passenden Labels",
|
||||
"more_count": "+{count} weitere",
|
||||
"characters_count": "{count} Zeichen",
|
||||
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
|
||||
@@ -398,17 +402,6 @@
|
||||
"message_id": "Nachrichten-ID",
|
||||
"list_info": "Listeninformationen"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Farb-Tag",
|
||||
"red": "Rot",
|
||||
"orange": "Orange",
|
||||
"yellow": "Gelb",
|
||||
"green": "Grün",
|
||||
"blue": "Blau",
|
||||
"purple": "Violett",
|
||||
"pink": "Rosa",
|
||||
"none": "Keine"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Antworten",
|
||||
"reply_all": "Allen antworten (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "E-Mail-Labels",
|
||||
"description": "Labels definieren, um Ihre E-Mails mit Farben zu organisieren. Diese werden als JMAP-Keywords auf dem Server gespeichert.",
|
||||
"add_keyword": "Label hinzufügen",
|
||||
"reset_defaults": "Auf Standard zurücksetzen",
|
||||
"label_field": "Anzeigename",
|
||||
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
|
||||
"id_field": "Label-ID",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Hinzufügen",
|
||||
"cancel": "Abbrechen",
|
||||
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
|
||||
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden"
|
||||
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden",
|
||||
"nesting": {
|
||||
"label": "Verschachtelte Labels",
|
||||
"description": "Labels unter anderen Labels verschachteln und als Baum in der Seitenleiste anzeigen."
|
||||
},
|
||||
"parent_field": "Übergeordnetes Label",
|
||||
"no_parent": "Kein übergeordnetes Label",
|
||||
"too_long": "Dieser Label-Pfad ist zu lang (höchstens {max} Zeichen)",
|
||||
"has_children_locked": "Unter diesem Label sind andere Labels verschachtelt, daher sind Name und übergeordnetes Label gesperrt. Verschieben oder entfernen Sie diese zuerst.",
|
||||
"has_children_delete": "Entfernen Sie zuerst die Labels, die unter diesem verschachtelt sind",
|
||||
"visibility_field": "Sichtbarkeit in der Seitenleiste",
|
||||
"visibility": {
|
||||
"show": "Anzeigen",
|
||||
"unread": "Bei Ungelesenen anzeigen",
|
||||
"hide": "Ausblenden"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Benachrichtigungston testen",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Löschen",
|
||||
"mark_as_spam": "Spam melden",
|
||||
"not_spam": "Kein Spam",
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label entfernen",
|
||||
"tag": "Label",
|
||||
"items_selected": "{count} E-Mails ausgewählt",
|
||||
"edit_draft": "Entwurf bearbeiten",
|
||||
"cancel_scheduled_send": "Senden abbrechen",
|
||||
|
||||
+24
-18
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Reset",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Tags",
|
||||
"show_all_tags": "Show all ({count})",
|
||||
"show_fewer_tags": "Show less",
|
||||
"folders": "Folders",
|
||||
"shared": "Shared",
|
||||
"mail": "Mail",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "View contact",
|
||||
"message_details": "Message Details",
|
||||
"more_reply_options": "More reply options",
|
||||
"set_color": "Set tag",
|
||||
"set_tag": "Set tag",
|
||||
"tag": "Tag",
|
||||
"more_actions": "More actions",
|
||||
"previous": "Prev",
|
||||
"next": "Next",
|
||||
"move_to": "Move to...",
|
||||
"remove_color": "Remove tag",
|
||||
"remove_tag": "Remove tag",
|
||||
"tag_filter_placeholder": "Filter tags",
|
||||
"tag_no_matches": "No matching tags",
|
||||
"more_count": "+{count} more",
|
||||
"characters_count": "{count} characters",
|
||||
"quick_reply_placeholder": "Write a quick reply...",
|
||||
@@ -425,17 +429,6 @@
|
||||
"message_id": "Message ID",
|
||||
"list_info": "List Information"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Color Tag",
|
||||
"red": "Red",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"green": "Green",
|
||||
"blue": "Blue",
|
||||
"purple": "Purple",
|
||||
"pink": "Pink",
|
||||
"none": "None"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Reply (r)",
|
||||
"reply_all": "Reply All (a)",
|
||||
@@ -1017,9 +1010,8 @@
|
||||
},
|
||||
"keywords": {
|
||||
"title": "Email Tags",
|
||||
"description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.",
|
||||
"description": "Define tags to organize your emails. These are stored as JMAP keywords on the server.",
|
||||
"add_keyword": "Add Tag",
|
||||
"reset_defaults": "Reset to Defaults",
|
||||
"label_field": "Display Name",
|
||||
"label_placeholder": "e.g. Work, Personal, Urgent",
|
||||
"id_field": "Tag ID",
|
||||
@@ -1032,7 +1024,22 @@
|
||||
"add": "Add",
|
||||
"cancel": "Cancel",
|
||||
"migrating": "Updating tag on existing emails…",
|
||||
"migration_error": "Failed to update tag on existing emails"
|
||||
"migration_error": "Failed to update tag on existing emails",
|
||||
"nesting": {
|
||||
"label": "Nested Tags",
|
||||
"description": "Nest tags underneath other tags and show them as a tree in the sidebar."
|
||||
},
|
||||
"parent_field": "Parent Tag",
|
||||
"no_parent": "No parent",
|
||||
"too_long": "This tag path is too long (at most {max} characters)",
|
||||
"has_children_locked": "Other tags are nested under this one, so its name and parent are locked. Move or remove them first.",
|
||||
"has_children_delete": "Remove the tags nested under this one first",
|
||||
"visibility_field": "Sidebar visibility",
|
||||
"visibility": {
|
||||
"show": "Show",
|
||||
"unread": "Show if unread",
|
||||
"hide": "Hide"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Test notification sound",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Delete",
|
||||
"mark_as_spam": "Report spam",
|
||||
"not_spam": "Not spam",
|
||||
"color_tag": "Tag",
|
||||
"remove_color": "Remove tag",
|
||||
"tag": "Tag",
|
||||
"items_selected": "{count} emails selected",
|
||||
"edit_draft": "Edit Draft",
|
||||
"cancel_scheduled_send": "Cancel send",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Restablecer",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Etiquetas",
|
||||
"show_all_tags": "Mostrar todo ({count})",
|
||||
"show_fewer_tags": "Mostrar menos",
|
||||
"folders": "Carpetas",
|
||||
"mail": "Correo",
|
||||
"nav_label": "Navegación",
|
||||
@@ -325,11 +327,13 @@
|
||||
"view_contact": "Ver contacto",
|
||||
"message_details": "Detalles del Mensaje",
|
||||
"more_reply_options": "Más opciones de respuesta",
|
||||
"set_color": "Establecer etiqueta",
|
||||
"set_tag": "Establecer etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"more_actions": "Más acciones",
|
||||
"move_to": "Mover a...",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
"remove_tag": "Eliminar etiqueta",
|
||||
"tag_filter_placeholder": "Filtrar etiquetas",
|
||||
"tag_no_matches": "No hay etiquetas coincidentes",
|
||||
"more_count": "+{count} más",
|
||||
"characters_count": "{count} caracteres",
|
||||
"quick_reply_placeholder": "Escriba una respuesta rápida...",
|
||||
@@ -398,17 +402,6 @@
|
||||
"message_id": "ID del Mensaje",
|
||||
"list_info": "Información de Lista"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etiqueta de Color",
|
||||
"red": "Rojo",
|
||||
"orange": "Naranja",
|
||||
"yellow": "Amarillo",
|
||||
"green": "Verde",
|
||||
"blue": "Azul",
|
||||
"purple": "Morado",
|
||||
"pink": "Rosa",
|
||||
"none": "Ninguno"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Responder",
|
||||
"reply_all": "Responder a todos (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "Etiquetas de correo",
|
||||
"description": "Define etiquetas para organizar tus correos con colores. Se almacenan como palabras clave JMAP en el servidor.",
|
||||
"add_keyword": "Añadir etiqueta",
|
||||
"reset_defaults": "Restablecer valores predeterminados",
|
||||
"label_field": "Nombre para mostrar",
|
||||
"label_placeholder": "ej. Trabajo, Personal, Urgente",
|
||||
"id_field": "ID de etiqueta",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Añadir",
|
||||
"cancel": "Cancelar",
|
||||
"migrating": "Actualizando etiqueta en correos existentes…",
|
||||
"migration_error": "Error al actualizar la etiqueta en correos existentes"
|
||||
"migration_error": "Error al actualizar la etiqueta en correos existentes",
|
||||
"nesting": {
|
||||
"label": "Etiquetas anidadas",
|
||||
"description": "Anida etiquetas debajo de otras etiquetas y muéstralas como un árbol en la barra lateral."
|
||||
},
|
||||
"parent_field": "Etiqueta principal",
|
||||
"no_parent": "Sin etiqueta principal",
|
||||
"too_long": "Esta ruta de etiqueta es demasiado larga (máximo {max} caracteres)",
|
||||
"has_children_locked": "Hay otras etiquetas anidadas bajo esta, por lo que su nombre y su etiqueta principal están bloqueados. Muévelas o elimínalas primero.",
|
||||
"has_children_delete": "Elimina primero las etiquetas anidadas bajo esta",
|
||||
"visibility_field": "Visibilidad en la barra lateral",
|
||||
"visibility": {
|
||||
"show": "Mostrar",
|
||||
"unread": "Mostrar si hay no leídos",
|
||||
"hide": "Ocultar"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Probar sonido de notificación",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Eliminar",
|
||||
"mark_as_spam": "Reportar spam",
|
||||
"not_spam": "No es spam",
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"items_selected": "{count} correos seleccionados",
|
||||
"edit_draft": "Editar borrador",
|
||||
"cancel_scheduled_send": "Cancelar envío",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "بازنشانی",
|
||||
"demo_tour": "تور",
|
||||
"tags": "برچسبها",
|
||||
"show_all_tags": "نمایش همه ({count})",
|
||||
"show_fewer_tags": "نمایش کمتر",
|
||||
"folders": "پوشهها",
|
||||
"shared": "اشتراکی",
|
||||
"mail": "ایمیل",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "مشاهده مخاطب",
|
||||
"message_details": "جزئیات پیام",
|
||||
"more_reply_options": "گزینههای بیشتر پاسخ",
|
||||
"set_color": "تنظیم برچسب",
|
||||
"set_tag": "تنظیم برچسب",
|
||||
"tag": "برچسب",
|
||||
"more_actions": "عملیات بیشتر",
|
||||
"previous": "قبلی",
|
||||
"next": "بعدی",
|
||||
"move_to": "انتقال به...",
|
||||
"remove_color": "حذف برچسب",
|
||||
"remove_tag": "حذف برچسب",
|
||||
"tag_filter_placeholder": "فیلتر برچسبها",
|
||||
"tag_no_matches": "برچسب مطابقی یافت نشد",
|
||||
"more_count": "+{count} بیشتر",
|
||||
"characters_count": "{count} کاراکتر",
|
||||
"quick_reply_placeholder": "پاسخ سریع بنویسید...",
|
||||
@@ -425,17 +429,6 @@
|
||||
"message_id": "شناسه پیام",
|
||||
"list_info": "اطلاعات لیست"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "برچسب رنگی",
|
||||
"red": "قرمز",
|
||||
"orange": "نارنجی",
|
||||
"yellow": "زرد",
|
||||
"green": "سبز",
|
||||
"blue": "آبی",
|
||||
"purple": "بنفش",
|
||||
"pink": "صورتی",
|
||||
"none": "هیچکدام"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "پاسخ (r)",
|
||||
"reply_all": "پاسخ به همه (a)",
|
||||
@@ -1019,7 +1012,6 @@
|
||||
"title": "برچسبهای ایمیل",
|
||||
"description": "برچسبها را برای سازماندهی ایمیلها تعریف کنید",
|
||||
"add_keyword": "افزودن برچسب",
|
||||
"reset_defaults": "بازنشانی به پیشفرض",
|
||||
"label_field": "نام نمایشی",
|
||||
"label_placeholder": "مثال: کاری، شخصی، فوری",
|
||||
"id_field": "شناسه برچسب",
|
||||
@@ -1032,7 +1024,22 @@
|
||||
"add": "افزودن",
|
||||
"cancel": "انصراف",
|
||||
"migrating": "در حال بهروزرسانی برچسب روی ایمیلهای موجود…",
|
||||
"migration_error": "بهروزرسانی برچسب ناموفق بود"
|
||||
"migration_error": "بهروزرسانی برچسب ناموفق بود",
|
||||
"nesting": {
|
||||
"label": "برچسبهای تودرتو",
|
||||
"description": "برچسبها را زیر برچسبهای دیگر قرار دهید و آنها را بهصورت درختی در نوار کناری نمایش دهید."
|
||||
},
|
||||
"parent_field": "برچسب والد",
|
||||
"no_parent": "بدون برچسب والد",
|
||||
"too_long": "این مسیر برچسب خیلی طولانی است (حداکثر {max} نویسه)",
|
||||
"has_children_locked": "برچسبهای دیگری زیر این برچسب قرار دارند، بنابراین نام و برچسب والد آن قفل است. ابتدا آنها را جابهجا یا حذف کنید.",
|
||||
"has_children_delete": "ابتدا برچسبهای زیرمجموعهٔ این برچسب را حذف کنید",
|
||||
"visibility_field": "نمایش در نوار کناری",
|
||||
"visibility": {
|
||||
"show": "نمایش",
|
||||
"unread": "نمایش در صورت وجود خواندهنشده",
|
||||
"hide": "پنهان کردن"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "تست صدای اعلان",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "حذف",
|
||||
"mark_as_spam": "گزارش هرزنامه",
|
||||
"not_spam": "هرزنامه نیست",
|
||||
"color_tag": "برچسب",
|
||||
"remove_color": "حذف برچسب",
|
||||
"tag": "برچسب",
|
||||
"items_selected": "{count} ایمیل انتخاب شده",
|
||||
"edit_draft": "ویرایش پیشنویس",
|
||||
"cancel_scheduled_send": "لغو ارسال",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Réinitialiser",
|
||||
"demo_tour": "Visite",
|
||||
"tags": "Étiquettes",
|
||||
"show_all_tags": "Tout afficher ({count})",
|
||||
"show_fewer_tags": "Afficher moins",
|
||||
"folders": "Dossiers",
|
||||
"mail": "Messagerie",
|
||||
"nav_label": "Navigation",
|
||||
@@ -325,11 +327,13 @@
|
||||
"view_contact": "Voir le contact",
|
||||
"message_details": "Détails du message",
|
||||
"more_reply_options": "Plus d'options de réponse",
|
||||
"set_color": "Définir l'étiquette",
|
||||
"set_tag": "Définir l'étiquette",
|
||||
"tag": "Étiquette",
|
||||
"more_actions": "Plus d'actions",
|
||||
"move_to": "Déplacer vers...",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
"remove_tag": "Retirer l'étiquette",
|
||||
"tag_filter_placeholder": "Filtrer les étiquettes",
|
||||
"tag_no_matches": "Aucune étiquette correspondante",
|
||||
"more_count": "+{count} de plus",
|
||||
"characters_count": "{count} caractères",
|
||||
"quick_reply_placeholder": "Écrivez une réponse rapide...",
|
||||
@@ -398,17 +402,6 @@
|
||||
"message_id": "ID du message",
|
||||
"list_info": "Information de liste"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Étiquette de couleur",
|
||||
"red": "Rouge",
|
||||
"orange": "Orange",
|
||||
"yellow": "Jaune",
|
||||
"green": "Vert",
|
||||
"blue": "Bleu",
|
||||
"purple": "Violet",
|
||||
"pink": "Rose",
|
||||
"none": "Aucune"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Répondre",
|
||||
"reply_all": "Répondre à tous (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "Étiquettes de messagerie",
|
||||
"description": "Définissez des étiquettes pour organiser vos e-mails avec des couleurs. Elles sont stockées sous forme de mots-clés JMAP sur le serveur.",
|
||||
"add_keyword": "Ajouter une étiquette",
|
||||
"reset_defaults": "Réinitialiser par défaut",
|
||||
"label_field": "Nom d'affichage",
|
||||
"label_placeholder": "ex. Travail, Personnel, Urgent",
|
||||
"id_field": "ID d'étiquette",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Ajouter",
|
||||
"cancel": "Annuler",
|
||||
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
|
||||
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants"
|
||||
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants",
|
||||
"nesting": {
|
||||
"label": "Étiquettes imbriquées",
|
||||
"description": "Imbriquez des étiquettes sous d'autres étiquettes et affichez-les sous forme d'arborescence dans la barre latérale."
|
||||
},
|
||||
"parent_field": "Étiquette parente",
|
||||
"no_parent": "Aucune étiquette parente",
|
||||
"too_long": "Ce chemin d'étiquette est trop long ({max} caractères au maximum)",
|
||||
"has_children_locked": "D'autres étiquettes sont imbriquées sous celle-ci, son nom et son étiquette parente sont donc verrouillés. Déplacez-les ou supprimez-les d'abord.",
|
||||
"has_children_delete": "Retirez d'abord les étiquettes imbriquées sous celle-ci",
|
||||
"visibility_field": "Visibilité dans la barre latérale",
|
||||
"visibility": {
|
||||
"show": "Afficher",
|
||||
"unread": "Afficher si non lus",
|
||||
"hide": "Masquer"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Tester le son de notification",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Supprimer",
|
||||
"mark_as_spam": "Signaler comme spam",
|
||||
"not_spam": "Pas un spam",
|
||||
"color_tag": "Étiquette",
|
||||
"remove_color": "Supprimer l'étiquette",
|
||||
"tag": "Étiquette",
|
||||
"items_selected": "{count} emails sélectionnés",
|
||||
"edit_draft": "Modifier le brouillon",
|
||||
"cancel_scheduled_send": "Annuler l’envoi",
|
||||
|
||||
+23
-17
@@ -122,6 +122,8 @@
|
||||
"demo_reset": "אִתחוּל",
|
||||
"demo_tour": "סִיוּר",
|
||||
"tags": "תגים",
|
||||
"show_all_tags": "הצג הכל ({count})",
|
||||
"show_fewer_tags": "הצג פחות",
|
||||
"folders": "תיקיות",
|
||||
"mail": "דוֹאַר",
|
||||
"nav_label": "ניווט",
|
||||
@@ -272,13 +274,15 @@
|
||||
"view_contact": "הצג איש קשר",
|
||||
"message_details": "פרטי הודעה",
|
||||
"more_reply_options": "אפשרויות תשובה נוספות",
|
||||
"set_color": "הגדר תג",
|
||||
"set_tag": "הגדר תג",
|
||||
"tag": "תג",
|
||||
"more_actions": "עוד פעולות",
|
||||
"previous": "הקודם",
|
||||
"next": "הבא",
|
||||
"move_to": "העבר ל...",
|
||||
"remove_color": "הסר תג",
|
||||
"remove_tag": "הסר תג",
|
||||
"tag_filter_placeholder": "סינון תגים",
|
||||
"tag_no_matches": "אין תגים תואמים",
|
||||
"more_count": "+{count}נוספים",
|
||||
"characters_count": "{count} תווים",
|
||||
"quick_reply_placeholder": "תשובה מהירה",
|
||||
@@ -347,17 +351,6 @@
|
||||
"message_id": "מזהה הודעה",
|
||||
"list_info": "רשימת מידע"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "תג צבע",
|
||||
"red": "אדום",
|
||||
"orange": "כתום",
|
||||
"yellow": "צהוב",
|
||||
"green": "ירוק",
|
||||
"blue": "כחול",
|
||||
"purple": "סגול",
|
||||
"pink": "ורוד",
|
||||
"none": "אין"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "תשובה (ר)",
|
||||
"reply_all": "השב לכולם (א)",
|
||||
@@ -981,7 +974,6 @@
|
||||
"title": "מילות מפתח בדוא\"ל",
|
||||
"description": "הגדר מילות מפתח (תוויות/תגים) כדי לארגן את המיילים שלך עם צבעים. אלו מאוחסנות כמילות מפתח JMAP בשרת.",
|
||||
"add_keyword": "הוסף מילת מפתח",
|
||||
"reset_defaults": "אפס לברירות מחדל",
|
||||
"label_field": "שם תצוגה",
|
||||
"label_placeholder": "למשל עבודה, אישי, דחוף",
|
||||
"id_field": "מזהה מילת מפתח",
|
||||
@@ -994,7 +986,22 @@
|
||||
"add": "לְהוֹסִיף",
|
||||
"cancel": "לְבַטֵל",
|
||||
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
|
||||
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות"
|
||||
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות",
|
||||
"nesting": {
|
||||
"label": "תגים מקוננים",
|
||||
"description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד."
|
||||
},
|
||||
"parent_field": "תג אב",
|
||||
"no_parent": "ללא תג אב",
|
||||
"too_long": "נתיב התג ארוך מדי (עד {max} תווים)",
|
||||
"has_children_locked": "תגים אחרים מקוננים תחת תג זה, ולכן שמו ותג האב שלו נעולים. העבר או הסר אותם תחילה.",
|
||||
"has_children_delete": "הסר תחילה את התגים המקוננים תחת תג זה",
|
||||
"visibility_field": "הצגה בסרגל הצד",
|
||||
"visibility": {
|
||||
"show": "הצג",
|
||||
"unread": "הצג כשיש שלא נקראו",
|
||||
"hide": "הסתר"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "צליל הודעת בדיקה",
|
||||
@@ -2000,8 +2007,7 @@
|
||||
"delete": "לִמְחוֹק",
|
||||
"mark_as_spam": "דווח על ספאם",
|
||||
"not_spam": "לא ספאם",
|
||||
"color_tag": "תווית",
|
||||
"remove_color": "הסר תווית",
|
||||
"tag": "תווית",
|
||||
"items_selected": "נבחרו הודעות דוא\"ל מסוג{count}",
|
||||
"edit_draft": "ערוך טיוטה",
|
||||
"cancel_scheduled_send": "ביטול שליחה",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Visszaállítás",
|
||||
"demo_tour": "Bemutató",
|
||||
"tags": "Címkék",
|
||||
"show_all_tags": "Összes megjelenítése ({count})",
|
||||
"show_fewer_tags": "Kevesebb megjelenítése",
|
||||
"folders": "Mappák",
|
||||
"shared": "Megosztott",
|
||||
"mail": "Levelek",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Névjegy megtekintése",
|
||||
"message_details": "Üzenet részletei",
|
||||
"more_reply_options": "További válasz opciók",
|
||||
"set_color": "Címke beállítása",
|
||||
"set_tag": "Címke beállítása",
|
||||
"tag": "Címke",
|
||||
"more_actions": "További műveletek",
|
||||
"previous": "Előző",
|
||||
"next": "Következő",
|
||||
"move_to": "Áthelyezés ide...",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
"remove_tag": "Címke eltávolítása",
|
||||
"tag_filter_placeholder": "Címkék szűrése",
|
||||
"tag_no_matches": "Nincs találat a címkék közt",
|
||||
"more_count": "+{count} további",
|
||||
"characters_count": "{count} karakter",
|
||||
"quick_reply_placeholder": "Gyors válasz írása...",
|
||||
@@ -425,17 +429,6 @@
|
||||
"message_id": "Üzenet azonosító",
|
||||
"list_info": "Lista információk"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Színes címke",
|
||||
"red": "Piros",
|
||||
"orange": "Narancs",
|
||||
"yellow": "Sárga",
|
||||
"green": "Zöld",
|
||||
"blue": "Kék",
|
||||
"purple": "Lila",
|
||||
"pink": "Rózsaszín",
|
||||
"none": "Nincs"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Válasz (r)",
|
||||
"reply_all": "Válasz mindenkinek (a)",
|
||||
@@ -1019,7 +1012,6 @@
|
||||
"title": "E-mail címkék",
|
||||
"description": "Címkék definiálása az e-mailek színekkel történő rendezéséhez. Ezek JMAP kulcsszavakként tárolódnak a szerveren.",
|
||||
"add_keyword": "Címke hozzáadása",
|
||||
"reset_defaults": "Alapértelmezettek visszaállítása",
|
||||
"label_field": "Megjelenített név",
|
||||
"label_placeholder": "pl. Munka, Személyes, Sürgős",
|
||||
"id_field": "Címke azonosító",
|
||||
@@ -1032,7 +1024,22 @@
|
||||
"add": "Hozzáadás",
|
||||
"cancel": "Mégse",
|
||||
"migrating": "Címke frissítése a meglévő e-maileken...",
|
||||
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken"
|
||||
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken",
|
||||
"nesting": {
|
||||
"label": "Beágyazott címkék",
|
||||
"description": "Ágyazzon címkéket más címkék alá, és jelenítse meg őket fastruktúraként az oldalsávon."
|
||||
},
|
||||
"parent_field": "Szülőcímke",
|
||||
"no_parent": "Nincs szülőcímke",
|
||||
"too_long": "Ez a címkeútvonal túl hosszú (legfeljebb {max} karakter)",
|
||||
"has_children_locked": "Más címkék vannak beágyazva ez alá, ezért a neve és a szülőcímkéje zárolva van. Előbb helyezze át vagy távolítsa el őket.",
|
||||
"has_children_delete": "Előbb távolítsa el az ez alá beágyazott címkéket",
|
||||
"visibility_field": "Láthatóság az oldalsávon",
|
||||
"visibility": {
|
||||
"show": "Megjelenítés",
|
||||
"unread": "Megjelenítés olvasatlanoknál",
|
||||
"hide": "Elrejtés"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Értesítési hang tesztelése",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Törlés",
|
||||
"mark_as_spam": "Spam jelentése",
|
||||
"not_spam": "Nem spam",
|
||||
"color_tag": "Címke",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
"tag": "Címke",
|
||||
"items_selected": "{count} e-mail kijelölve",
|
||||
"edit_draft": "Piszkozat szerkesztése",
|
||||
"cancel_scheduled_send": "Küldés megszakítása",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Reimposta",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Etichette",
|
||||
"show_all_tags": "Mostra tutto ({count})",
|
||||
"show_fewer_tags": "Mostra meno",
|
||||
"folders": "Cartelle",
|
||||
"mail": "Posta",
|
||||
"nav_label": "Navigazione",
|
||||
@@ -325,11 +327,13 @@
|
||||
"view_contact": "Visualizza contatto",
|
||||
"message_details": "Dettagli del messaggio",
|
||||
"more_reply_options": "Più opzioni di risposta",
|
||||
"set_color": "Imposta etichetta",
|
||||
"set_tag": "Imposta etichetta",
|
||||
"tag": "Etichetta",
|
||||
"more_actions": "Altre azioni",
|
||||
"move_to": "Sposta in...",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
"remove_tag": "Rimuovi etichetta",
|
||||
"tag_filter_placeholder": "Filtra etichette",
|
||||
"tag_no_matches": "Nessuna etichetta corrispondente",
|
||||
"more_count": "+{count} altri",
|
||||
"characters_count": "{count} caratteri",
|
||||
"quick_reply_placeholder": "Scrivi una risposta veloce...",
|
||||
@@ -398,17 +402,6 @@
|
||||
"message_id": "ID messaggio",
|
||||
"list_info": "Informazioni lista"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etichetta colore",
|
||||
"red": "Rosso",
|
||||
"orange": "Arancione",
|
||||
"yellow": "Giallo",
|
||||
"green": "Verde",
|
||||
"blue": "Blu",
|
||||
"purple": "Viola",
|
||||
"pink": "Rosa",
|
||||
"none": "Nessuno"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Rispondi",
|
||||
"reply_all": "Rispondi a tutti (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "Etichette e-mail",
|
||||
"description": "Definisci etichette per organizzare le tue e-mail con i colori. Vengono archiviate come parole chiave JMAP sul server.",
|
||||
"add_keyword": "Aggiungi etichetta",
|
||||
"reset_defaults": "Ripristina predefiniti",
|
||||
"label_field": "Nome visualizzato",
|
||||
"label_placeholder": "es. Lavoro, Personale, Urgente",
|
||||
"id_field": "ID etichetta",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Aggiungi",
|
||||
"cancel": "Annulla",
|
||||
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
|
||||
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti"
|
||||
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti",
|
||||
"nesting": {
|
||||
"label": "Etichette nidificate",
|
||||
"description": "Nidifica le etichette sotto altre etichette e mostrale come un albero nella barra laterale."
|
||||
},
|
||||
"parent_field": "Etichetta principale",
|
||||
"no_parent": "Nessuna etichetta principale",
|
||||
"too_long": "Questo percorso di etichetta è troppo lungo (al massimo {max} caratteri)",
|
||||
"has_children_locked": "Altre etichette sono nidificate sotto questa, quindi il suo nome e la sua etichetta principale sono bloccati. Spostale o rimuovile prima.",
|
||||
"has_children_delete": "Rimuovi prima le etichette nidificate sotto questa",
|
||||
"visibility_field": "Visibilità nella barra laterale",
|
||||
"visibility": {
|
||||
"show": "Mostra",
|
||||
"unread": "Mostra se non letti",
|
||||
"hide": "Nascondi"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Testa il suono di notifica",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Elimina",
|
||||
"mark_as_spam": "Segnala come spam",
|
||||
"not_spam": "Non spam",
|
||||
"color_tag": "Etichetta",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
"tag": "Etichetta",
|
||||
"items_selected": "{count} messaggi selezionati",
|
||||
"edit_draft": "Modifica bozza",
|
||||
"cancel_scheduled_send": "Annulla invio",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "リセット",
|
||||
"demo_tour": "ツアー",
|
||||
"tags": "タグ",
|
||||
"show_all_tags": "すべて表示({count})",
|
||||
"show_fewer_tags": "表示を減らす",
|
||||
"folders": "フォルダ",
|
||||
"mail": "メール",
|
||||
"nav_label": "ナビゲーション",
|
||||
@@ -325,11 +327,13 @@
|
||||
"view_contact": "連絡先を表示",
|
||||
"message_details": "メッセージの詳細",
|
||||
"more_reply_options": "その他の返信オプション",
|
||||
"set_color": "ラベルを設定",
|
||||
"set_tag": "ラベルを設定",
|
||||
"tag": "ラベル",
|
||||
"more_actions": "その他の操作",
|
||||
"move_to": "移動...",
|
||||
"remove_color": "ラベルを削除",
|
||||
"remove_tag": "ラベルを削除",
|
||||
"tag_filter_placeholder": "ラベルを絞り込む",
|
||||
"tag_no_matches": "一致するラベルがありません",
|
||||
"more_count": "他{count}件",
|
||||
"characters_count": "{count}文字",
|
||||
"quick_reply_placeholder": "クイック返信を入力...",
|
||||
@@ -398,17 +402,6 @@
|
||||
"message_id": "メッセージID",
|
||||
"list_info": "リスト情報"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "カラータグ",
|
||||
"red": "赤",
|
||||
"orange": "オレンジ",
|
||||
"yellow": "黄色",
|
||||
"green": "緑",
|
||||
"blue": "青",
|
||||
"purple": "紫",
|
||||
"pink": "ピンク",
|
||||
"none": "なし"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "返信",
|
||||
"reply_all": "全員に返信 (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "メールラベル",
|
||||
"description": "メールをカラーで整理するためのラベルを定義します。サーバーにJMAPキーワードとして保存されます。",
|
||||
"add_keyword": "ラベルを追加",
|
||||
"reset_defaults": "デフォルトに戻す",
|
||||
"label_field": "表示名",
|
||||
"label_placeholder": "例:仕事、個人、緊急",
|
||||
"id_field": "ラベルID",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "追加",
|
||||
"cancel": "キャンセル",
|
||||
"migrating": "既存のメールのラベルを更新中…",
|
||||
"migration_error": "既存のメールのラベルの更新に失敗しました"
|
||||
"migration_error": "既存のメールのラベルの更新に失敗しました",
|
||||
"nesting": {
|
||||
"label": "ネストされたラベル",
|
||||
"description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。"
|
||||
},
|
||||
"parent_field": "親ラベル",
|
||||
"no_parent": "親ラベルなし",
|
||||
"too_long": "このラベルのパスが長すぎます(最大{max}文字)",
|
||||
"has_children_locked": "このラベルの下に他のラベルがネストされているため、名前と親ラベルは変更できません。先に移動または削除してください。",
|
||||
"has_children_delete": "先にこのラベルの下にネストされたラベルを削除してください",
|
||||
"visibility_field": "サイドバーでの表示",
|
||||
"visibility": {
|
||||
"show": "表示する",
|
||||
"unread": "未読がある場合に表示",
|
||||
"hide": "表示しない"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "通知音をテスト",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "削除",
|
||||
"mark_as_spam": "迷惑メールを報告",
|
||||
"not_spam": "迷惑メールでない",
|
||||
"color_tag": "ラベル",
|
||||
"remove_color": "ラベルを削除",
|
||||
"tag": "ラベル",
|
||||
"items_selected": "{count}件のメールを選択",
|
||||
"edit_draft": "下書きを編集",
|
||||
"cancel_scheduled_send": "送信をキャンセル",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "초기화",
|
||||
"demo_tour": "둘러보기",
|
||||
"tags": "태그",
|
||||
"show_all_tags": "전체 보기({count})",
|
||||
"show_fewer_tags": "간략히 보기",
|
||||
"folders": "폴더",
|
||||
"mail": "메일",
|
||||
"nav_label": "내비게이션",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "연락처 보기",
|
||||
"message_details": "메시지 상세 정보",
|
||||
"more_reply_options": "답장 옵션 더보기",
|
||||
"set_color": "태그 설정",
|
||||
"set_tag": "태그 설정",
|
||||
"tag": "태그",
|
||||
"more_actions": "작업 더보기",
|
||||
"previous": "이전",
|
||||
"next": "다음",
|
||||
"move_to": "이동...",
|
||||
"remove_color": "태그 제거",
|
||||
"remove_tag": "태그 제거",
|
||||
"tag_filter_placeholder": "태그 검색",
|
||||
"tag_no_matches": "일치하는 태그 없음",
|
||||
"more_count": "+{count}개 더보기",
|
||||
"characters_count": "{count}자",
|
||||
"quick_reply_placeholder": "간단하게 답장을 작성해 보세요...",
|
||||
@@ -400,17 +404,6 @@
|
||||
"message_id": "메시지 ID",
|
||||
"list_info": "목록 정보"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "색상 태그",
|
||||
"red": "빨간색",
|
||||
"orange": "주황색",
|
||||
"yellow": "노란색",
|
||||
"green": "초록색",
|
||||
"blue": "파란색",
|
||||
"purple": "보라색",
|
||||
"pink": "분홍색",
|
||||
"none": "없음"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "답장 (r)",
|
||||
"reply_all": "전체 답장 (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "이메일 태그",
|
||||
"description": "색상으로 이메일을 정리하기 위한 태그를 정의합니다. 서버에 JMAP 키워드로 저장됩니다.",
|
||||
"add_keyword": "태그 추가",
|
||||
"reset_defaults": "기본값으로 초기화",
|
||||
"label_field": "표시 이름",
|
||||
"label_placeholder": "예: 업무, 개인, 긴급",
|
||||
"id_field": "태그 ID",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "추가",
|
||||
"cancel": "취소",
|
||||
"migrating": "기존 이메일의 태그 업데이트 중…",
|
||||
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다"
|
||||
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다",
|
||||
"nesting": {
|
||||
"label": "중첩 태그",
|
||||
"description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다."
|
||||
},
|
||||
"parent_field": "상위 태그",
|
||||
"no_parent": "상위 태그 없음",
|
||||
"too_long": "이 태그 경로가 너무 깁니다(최대 {max}자)",
|
||||
"has_children_locked": "이 태그 아래에 다른 태그가 중첩되어 있어 이름과 상위 태그가 잠겨 있습니다. 먼저 옮기거나 삭제하세요.",
|
||||
"has_children_delete": "이 태그 아래에 중첩된 태그를 먼저 삭제하세요",
|
||||
"visibility_field": "사이드바 표시",
|
||||
"visibility": {
|
||||
"show": "표시",
|
||||
"unread": "읽지 않음이 있을 때 표시",
|
||||
"hide": "숨기기"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "알림음 테스트",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "삭제",
|
||||
"mark_as_spam": "스팸 신고",
|
||||
"not_spam": "정상 메일",
|
||||
"color_tag": "태그",
|
||||
"remove_color": "태그 제거",
|
||||
"tag": "태그",
|
||||
"items_selected": "{count}개의 메일 선택됨",
|
||||
"edit_draft": "임시보관 메일 수정",
|
||||
"cancel_scheduled_send": "보내기 취소",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Atiestatīt",
|
||||
"demo_tour": "Ekskursija",
|
||||
"tags": "Tagi",
|
||||
"show_all_tags": "Rādīt visus ({count})",
|
||||
"show_fewer_tags": "Rādīt mazāk",
|
||||
"folders": "Mapes",
|
||||
"mail": "Pasts",
|
||||
"nav_label": "Navigācija",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Skatīt kontaktu",
|
||||
"message_details": "Informācija par ziņojumu",
|
||||
"more_reply_options": "Papildu atbildēšanas iespējas",
|
||||
"set_color": "Iestatīt tagu",
|
||||
"set_tag": "Iestatīt tagu",
|
||||
"tag": "Tags",
|
||||
"more_actions": "Citas darbības",
|
||||
"previous": "Iepr.",
|
||||
"next": "Nāk.",
|
||||
"move_to": "Pārvietot uz...",
|
||||
"remove_color": "Noņemt tagu",
|
||||
"remove_tag": "Noņemt tagu",
|
||||
"tag_filter_placeholder": "Filtrēt tagus",
|
||||
"tag_no_matches": "Nav atbilstošu tagu",
|
||||
"more_count": "+vairāk {count}",
|
||||
"characters_count": "{count} rakstzīmes",
|
||||
"quick_reply_placeholder": "Rakstīt ātru atbildi...",
|
||||
@@ -400,17 +404,6 @@
|
||||
"message_id": "Ziņojuma ID",
|
||||
"list_info": "Informācija par adresātu sarakstu"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Krāsu tags",
|
||||
"red": "Sarkans",
|
||||
"orange": "Oranžs",
|
||||
"yellow": "Dzeltens",
|
||||
"green": "Zaļš",
|
||||
"blue": "Zils",
|
||||
"purple": "Violets",
|
||||
"pink": "Rozā",
|
||||
"none": "Nav"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Atbildēt (r)",
|
||||
"reply_all": "Atbildēt visiem (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "E-pasta tagi",
|
||||
"description": "Definējiet tagus, lai organizētu e-pastus ar krāsām. Tie tiek saglabāti kā JMAP atslēgvārdi serverī.",
|
||||
"add_keyword": "Pievienot tagu",
|
||||
"reset_defaults": "Atiestatīt noklusējumu",
|
||||
"label_field": "Redzamais nosaukums",
|
||||
"label_placeholder": "piem., Darbs, Personīgi, Steidzami",
|
||||
"id_field": "Taga identifikators",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Pievienot",
|
||||
"cancel": "Atcelt",
|
||||
"migrating": "Taga atjaunināšana esošajos e-pastos…",
|
||||
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos"
|
||||
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos",
|
||||
"nesting": {
|
||||
"label": "Ligzdoti tagi",
|
||||
"description": "Ligzdojiet tagus zem citiem tagiem un rādiet tos sānjoslā kā koku."
|
||||
},
|
||||
"parent_field": "Vecāktags",
|
||||
"no_parent": "Nav vecāktaga",
|
||||
"too_long": "Šis taga ceļš ir pārāk garš (ne vairāk kā {max} rakstzīmes)",
|
||||
"has_children_locked": "Zem šī taga ir ligzdoti citi tagi, tāpēc tā nosaukums un vecāktags ir bloķēti. Vispirms pārvietojiet vai noņemiet tos.",
|
||||
"has_children_delete": "Vispirms noņemiet zem šī ligzdotos tagus",
|
||||
"visibility_field": "Redzamība sānjoslā",
|
||||
"visibility": {
|
||||
"show": "Rādīt",
|
||||
"unread": "Rādīt, ja ir nelasīti",
|
||||
"hide": "Slēpt"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Pārbaudīt paziņojuma skaņu",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Dzēst",
|
||||
"mark_as_spam": "Atzīmēt kā mēstuli",
|
||||
"not_spam": "Nav mēstule",
|
||||
"color_tag": "Tags",
|
||||
"remove_color": "Noņemt tagu",
|
||||
"tag": "Tags",
|
||||
"items_selected": "{count} vēstules atlasītas",
|
||||
"edit_draft": "Rediģēt melnrakstu",
|
||||
"cancel_scheduled_send": "Atcelt sūtīšanu",
|
||||
|
||||
+24
-18
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Resetten",
|
||||
"demo_tour": "Rondleiding",
|
||||
"tags": "Labels",
|
||||
"show_all_tags": "Alles tonen ({count})",
|
||||
"show_fewer_tags": "Minder tonen",
|
||||
"folders": "Mappen",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navigatie",
|
||||
@@ -325,11 +327,13 @@
|
||||
"view_contact": "Contact bekijken",
|
||||
"message_details": "Berichtdetails",
|
||||
"more_reply_options": "Meer antwoordopties",
|
||||
"set_color": "Label instellen",
|
||||
"set_tag": "Label instellen",
|
||||
"tag": "Label",
|
||||
"more_actions": "Meer acties",
|
||||
"move_to": "Verplaatsen naar...",
|
||||
"remove_color": "Label verwijderen",
|
||||
"remove_tag": "Label verwijderen",
|
||||
"tag_filter_placeholder": "Labels filteren",
|
||||
"tag_no_matches": "Geen overeenkomende labels",
|
||||
"more_count": "+{count} meer",
|
||||
"characters_count": "{count} tekens",
|
||||
"quick_reply_placeholder": "Schrijf een snel antwoord...",
|
||||
@@ -398,17 +402,6 @@
|
||||
"message_id": "Bericht-ID",
|
||||
"list_info": "Lijstinformatie"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Kleurtag",
|
||||
"red": "Rood",
|
||||
"orange": "Oranje",
|
||||
"yellow": "Geel",
|
||||
"green": "Groen",
|
||||
"blue": "Blauw",
|
||||
"purple": "Paars",
|
||||
"pink": "Roze",
|
||||
"none": "Geen"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Beantwoorden",
|
||||
"reply_all": "Allen beantwoorden (a)",
|
||||
@@ -1014,9 +1007,8 @@
|
||||
},
|
||||
"keywords": {
|
||||
"title": "E-maillabels",
|
||||
"description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
|
||||
"description": "Definieer labels om uw e-mails te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.",
|
||||
"add_keyword": "Label toevoegen",
|
||||
"reset_defaults": "Standaardwaarden herstellen",
|
||||
"label_field": "Weergavenaam",
|
||||
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
|
||||
"id_field": "Label-ID",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Toevoegen",
|
||||
"cancel": "Annuleren",
|
||||
"migrating": "Label bijwerken op bestaande e-mails…",
|
||||
"migration_error": "Label bijwerken op bestaande e-mails mislukt"
|
||||
"migration_error": "Label bijwerken op bestaande e-mails mislukt",
|
||||
"nesting": {
|
||||
"label": "Geneste labels",
|
||||
"description": "Nest labels onder andere labels en toon ze als een boomstructuur in de zijbalk."
|
||||
},
|
||||
"parent_field": "Bovenliggend label",
|
||||
"no_parent": "Geen bovenliggend label",
|
||||
"too_long": "Dit labelpad is te lang (maximaal {max} tekens)",
|
||||
"has_children_locked": "Er vallen andere labels onder dit label, dus de naam en het bovenliggende label liggen vast. Verplaats of verwijder ze eerst.",
|
||||
"has_children_delete": "Verwijder eerst de labels die hieronder vallen",
|
||||
"visibility_field": "Zichtbaarheid in de zijbalk",
|
||||
"visibility": {
|
||||
"show": "Tonen",
|
||||
"unread": "Tonen bij ongelezen",
|
||||
"hide": "Verbergen"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Meldingsgeluid testen",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Verwijderen",
|
||||
"mark_as_spam": "Spam melden",
|
||||
"not_spam": "Geen spam",
|
||||
"color_tag": "Label",
|
||||
"remove_color": "Label verwijderen",
|
||||
"tag": "Label",
|
||||
"items_selected": "{count} e-mails geselecteerd",
|
||||
"edit_draft": "Concept bewerken",
|
||||
"cancel_scheduled_send": "Verzenden annuleren",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Resetuj",
|
||||
"demo_tour": "Przewodnik",
|
||||
"tags": "Etykiety",
|
||||
"show_all_tags": "Pokaż wszystkie ({count})",
|
||||
"show_fewer_tags": "Pokaż mniej",
|
||||
"folders": "Foldery",
|
||||
"mail": "Poczta",
|
||||
"nav_label": "Nawigacja",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Pokaż kontakt",
|
||||
"message_details": "Szczegóły wiadomości",
|
||||
"more_reply_options": "Więcej opcji odpowiedzi",
|
||||
"set_color": "Ustaw etykietę",
|
||||
"set_tag": "Ustaw etykietę",
|
||||
"tag": "Etykieta",
|
||||
"more_actions": "Więcej działań",
|
||||
"previous": "Poprz.",
|
||||
"next": "Nast.",
|
||||
"move_to": "Przenieś do...",
|
||||
"remove_color": "Usuń etykietę",
|
||||
"remove_tag": "Usuń etykietę",
|
||||
"tag_filter_placeholder": "Filtruj etykiety",
|
||||
"tag_no_matches": "Brak pasujących etykiet",
|
||||
"more_count": "+{count} więcej",
|
||||
"characters_count": "{count} znaków",
|
||||
"quick_reply_placeholder": "Napisz szybką odpowiedź...",
|
||||
@@ -400,17 +404,6 @@
|
||||
"message_id": "ID wiadomości",
|
||||
"list_info": "Informacje o liście"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Kolorowa etykieta",
|
||||
"red": "Czerwony",
|
||||
"orange": "Pomarańczowy",
|
||||
"yellow": "Żółty",
|
||||
"green": "Zielony",
|
||||
"blue": "Niebieski",
|
||||
"purple": "Fioletowy",
|
||||
"pink": "Różowy",
|
||||
"none": "Brak"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Odpowiedz (r)",
|
||||
"reply_all": "Odpowiedz wszystkim (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "Etykiety e-mail",
|
||||
"description": "Zdefiniuj etykiety do organizowania e-maili za pomocą kolorów. Są one przechowywane jako słowa kluczowe JMAP na serwerze.",
|
||||
"add_keyword": "Dodaj etykietę",
|
||||
"reset_defaults": "Przywróć domyślne",
|
||||
"label_field": "Nazwa wyświetlana",
|
||||
"label_placeholder": "np. Praca, Osobiste, Pilne",
|
||||
"id_field": "ID etykiety",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Dodaj",
|
||||
"cancel": "Anuluj",
|
||||
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
|
||||
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach"
|
||||
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach",
|
||||
"nesting": {
|
||||
"label": "Zagnieżdżone etykiety",
|
||||
"description": "Zagnieżdżaj etykiety pod innymi etykietami i wyświetlaj je w panelu bocznym jako drzewo."
|
||||
},
|
||||
"parent_field": "Etykieta nadrzędna",
|
||||
"no_parent": "Brak etykiety nadrzędnej",
|
||||
"too_long": "Ta ścieżka etykiety jest za długa (maksymalnie {max} znaków)",
|
||||
"has_children_locked": "Pod tą etykietą zagnieżdżone są inne etykiety, więc jej nazwa i etykieta nadrzędna są zablokowane. Najpierw je przenieś lub usuń.",
|
||||
"has_children_delete": "Najpierw usuń etykiety zagnieżdżone pod tą",
|
||||
"visibility_field": "Widoczność w panelu bocznym",
|
||||
"visibility": {
|
||||
"show": "Pokaż",
|
||||
"unread": "Pokaż przy nieprzeczytanych",
|
||||
"hide": "Ukryj"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Przetestuj dźwięk powiadomienia",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Usuń",
|
||||
"mark_as_spam": "Zgłoś spam",
|
||||
"not_spam": "To nie spam",
|
||||
"color_tag": "Etykieta",
|
||||
"remove_color": "Usuń etykietę",
|
||||
"tag": "Etykieta",
|
||||
"items_selected": "{count} zaznaczonych wiadomości",
|
||||
"edit_draft": "Edytuj szkic",
|
||||
"cancel_scheduled_send": "Anuluj wysyłkę",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Repor",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Etiquetas",
|
||||
"show_all_tags": "Mostrar tudo ({count})",
|
||||
"show_fewer_tags": "Mostrar menos",
|
||||
"folders": "Pastas",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navegação",
|
||||
@@ -325,11 +327,13 @@
|
||||
"view_contact": "Ver contato",
|
||||
"message_details": "Detalhes da Mensagem",
|
||||
"more_reply_options": "Mais opções de resposta",
|
||||
"set_color": "Definir etiqueta",
|
||||
"set_tag": "Definir etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"more_actions": "Mais ações",
|
||||
"move_to": "Mover para...",
|
||||
"remove_color": "Remover etiqueta",
|
||||
"remove_tag": "Remover etiqueta",
|
||||
"tag_filter_placeholder": "Filtrar etiquetas",
|
||||
"tag_no_matches": "Nenhuma etiqueta correspondente",
|
||||
"more_count": "+{count} mais",
|
||||
"characters_count": "{count} caracteres",
|
||||
"quick_reply_placeholder": "Escreva uma resposta rápida...",
|
||||
@@ -398,17 +402,6 @@
|
||||
"message_id": "ID da Mensagem",
|
||||
"list_info": "Informações da Lista"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etiqueta de Cor",
|
||||
"red": "Vermelho",
|
||||
"orange": "Laranja",
|
||||
"yellow": "Amarelo",
|
||||
"green": "Verde",
|
||||
"blue": "Azul",
|
||||
"purple": "Roxo",
|
||||
"pink": "Rosa",
|
||||
"none": "Nenhuma"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Responder",
|
||||
"reply_all": "Responder a todos (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "Etiquetas de e-mail",
|
||||
"description": "Defina etiquetas para organizar os seus e-mails com cores. São armazenadas como palavras-chave JMAP no servidor.",
|
||||
"add_keyword": "Adicionar etiqueta",
|
||||
"reset_defaults": "Restaurar padrões",
|
||||
"label_field": "Nome de exibição",
|
||||
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
|
||||
"id_field": "ID da etiqueta",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Adicionar",
|
||||
"cancel": "Cancelar",
|
||||
"migrating": "A atualizar etiqueta nos e-mails existentes…",
|
||||
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
|
||||
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes",
|
||||
"nesting": {
|
||||
"label": "Etiquetas aninhadas",
|
||||
"description": "Aninhe etiquetas sob outras etiquetas e mostre-as como uma árvore na barra lateral."
|
||||
},
|
||||
"parent_field": "Etiqueta principal",
|
||||
"no_parent": "Sem etiqueta principal",
|
||||
"too_long": "Este caminho de etiqueta é demasiado longo (no máximo {max} caracteres)",
|
||||
"has_children_locked": "Existem outras etiquetas aninhadas sob esta, por isso o seu nome e a sua etiqueta principal estão bloqueados. Mova-as ou remova-as primeiro.",
|
||||
"has_children_delete": "Remova primeiro as etiquetas aninhadas sob esta",
|
||||
"visibility_field": "Visibilidade na barra lateral",
|
||||
"visibility": {
|
||||
"show": "Mostrar",
|
||||
"unread": "Mostrar se não lidas",
|
||||
"hide": "Ocultar"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Testar som de notificação",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Excluir",
|
||||
"mark_as_spam": "Reportar spam",
|
||||
"not_spam": "Não é spam",
|
||||
"color_tag": "Etiqueta",
|
||||
"remove_color": "Remover etiqueta",
|
||||
"tag": "Etiqueta",
|
||||
"items_selected": "{count} e-mails selecionados",
|
||||
"edit_draft": "Editar rascunho",
|
||||
"cancel_scheduled_send": "Cancelar envio",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Resetare",
|
||||
"demo_tour": "Tur de prezentare",
|
||||
"tags": "Etichete",
|
||||
"show_all_tags": "Afișează tot ({count})",
|
||||
"show_fewer_tags": "Afișează mai puține",
|
||||
"folders": "Dosare",
|
||||
"shared": "Partajat",
|
||||
"mail": "E-mail",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Vizualizare contact",
|
||||
"message_details": "Detalii mesaj",
|
||||
"more_reply_options": "Mai multe opțiuni de răspuns",
|
||||
"set_color": "Setați eticheta",
|
||||
"set_tag": "Setați eticheta",
|
||||
"tag": "Etichetă",
|
||||
"more_actions": "Alte acțiuni",
|
||||
"previous": "Anterior",
|
||||
"next": "Următorul",
|
||||
"move_to": "Mergi la...",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
"remove_tag": "Eliminați eticheta",
|
||||
"tag_filter_placeholder": "Filtrează etichetele",
|
||||
"tag_no_matches": "Nicio etichetă corespunzătoare",
|
||||
"more_count": "+{count} mai multe",
|
||||
"characters_count": "{count} caractere",
|
||||
"quick_reply_placeholder": "Scrie un răspuns rapid...",
|
||||
@@ -425,17 +429,6 @@
|
||||
"message_id": "IDul mesajelor",
|
||||
"list_info": "Informații despre listă"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Etichetă de culoare",
|
||||
"red": "Roșu",
|
||||
"orange": "Portocaliu",
|
||||
"yellow": "Galben",
|
||||
"green": "Verde",
|
||||
"blue": "Albastru",
|
||||
"purple": "Violet",
|
||||
"pink": "Roz",
|
||||
"none": "Niciunul"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Răspunde (r)",
|
||||
"reply_all": "Răspunde tuturor (a)",
|
||||
@@ -1019,7 +1012,6 @@
|
||||
"title": "Etichete de e-mail",
|
||||
"description": "Definiți etichete pentru a vă organiza e-mailurile pe culori. Acestea sunt stocate pe server sub formă de cuvinte-cheie dJMAP.",
|
||||
"add_keyword": "Adăugați etichetă",
|
||||
"reset_defaults": "Resezare la setările implicite",
|
||||
"label_field": "Nume afișat",
|
||||
"label_placeholder": "de ex. Serviciu, Personal, Urgent",
|
||||
"id_field": "EtichetăID",
|
||||
@@ -1032,7 +1024,22 @@
|
||||
"add": "Adăugați",
|
||||
"cancel": "Anulează",
|
||||
"migrating": "Actualizarea etichetei pentru e-mailurile existente…",
|
||||
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente"
|
||||
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente",
|
||||
"nesting": {
|
||||
"label": "Etichete imbricate",
|
||||
"description": "Imbricați etichete sub alte etichete și afișați-le ca un arbore în bara laterală."
|
||||
},
|
||||
"parent_field": "Etichetă părinte",
|
||||
"no_parent": "Fără etichetă părinte",
|
||||
"too_long": "Această cale de etichetă este prea lungă (cel mult {max} caractere)",
|
||||
"has_children_locked": "Alte etichete sunt imbricate sub aceasta, așa că numele și eticheta părinte sunt blocate. Mutați-le sau eliminați-le mai întâi.",
|
||||
"has_children_delete": "Eliminați mai întâi etichetele imbricate sub aceasta",
|
||||
"visibility_field": "Vizibilitate în bara laterală",
|
||||
"visibility": {
|
||||
"show": "Afișează",
|
||||
"unread": "Afișează dacă sunt necitite",
|
||||
"hide": "Ascunde"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Testați sunetul de notificare",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Șterge",
|
||||
"mark_as_spam": "Raportează spamul",
|
||||
"not_spam": "Nu este spam",
|
||||
"color_tag": "Etichetă",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
"tag": "Etichetă",
|
||||
"items_selected": "{count} e-mailuri selectate",
|
||||
"edit_draft": "Editează schița",
|
||||
"cancel_scheduled_send": "Anulează trimiterea",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Сбросить",
|
||||
"demo_tour": "Тур",
|
||||
"tags": "Теги",
|
||||
"show_all_tags": "Показать все ({count})",
|
||||
"show_fewer_tags": "Показать меньше",
|
||||
"folders": "Папки",
|
||||
"mail": "Почта",
|
||||
"nav_label": "Навигация",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Просмотреть контакт",
|
||||
"message_details": "Детали сообщения",
|
||||
"more_reply_options": "Дополнительные параметры ответа",
|
||||
"set_color": "Установить тег",
|
||||
"set_tag": "Установить тег",
|
||||
"tag": "Тег",
|
||||
"more_actions": "Другие действия",
|
||||
"previous": "Пред.",
|
||||
"next": "След.",
|
||||
"move_to": "Переместить в...",
|
||||
"remove_color": "Удалить тег",
|
||||
"remove_tag": "Удалить тег",
|
||||
"tag_filter_placeholder": "Фильтр тегов",
|
||||
"tag_no_matches": "Подходящих тегов нет",
|
||||
"more_count": "+{count} ещё",
|
||||
"characters_count": "{count} символов",
|
||||
"quick_reply_placeholder": "Написать быстрый ответ...",
|
||||
@@ -400,17 +404,6 @@
|
||||
"message_id": "Идентификатор сообщения",
|
||||
"list_info": "Информация о рассылке"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Цветной тег",
|
||||
"red": "Красный",
|
||||
"orange": "Оранжевый",
|
||||
"yellow": "Жёлтый",
|
||||
"green": "Зелёный",
|
||||
"blue": "Синий",
|
||||
"purple": "Фиолетовый",
|
||||
"pink": "Розовый",
|
||||
"none": "Нет"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Ответить (r)",
|
||||
"reply_all": "Ответить всем (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "Теги электронной почты",
|
||||
"description": "Определите теги для организации электронных писем с помощью цветов. Они хранятся как ключевые слова JMAP на сервере.",
|
||||
"add_keyword": "Добавить тег",
|
||||
"reset_defaults": "Сбросить по умолчанию",
|
||||
"label_field": "Отображаемое название",
|
||||
"label_placeholder": "напр., Работа, Личное, Срочно",
|
||||
"id_field": "Идентификатор тега",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Добавить",
|
||||
"cancel": "Отмена",
|
||||
"migrating": "Обновление тега в существующих письмах…",
|
||||
"migration_error": "Не удалось обновить тег в существующих письмах"
|
||||
"migration_error": "Не удалось обновить тег в существующих письмах",
|
||||
"nesting": {
|
||||
"label": "Вложенные теги",
|
||||
"description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева."
|
||||
},
|
||||
"parent_field": "Родительский тег",
|
||||
"no_parent": "Без родительского тега",
|
||||
"too_long": "Этот путь тега слишком длинный (не более {max} символов)",
|
||||
"has_children_locked": "В этот тег вложены другие теги, поэтому его имя и родительский тег заблокированы. Сначала переместите или удалите их.",
|
||||
"has_children_delete": "Сначала удалите теги, вложенные в этот",
|
||||
"visibility_field": "Видимость в боковой панели",
|
||||
"visibility": {
|
||||
"show": "Показывать",
|
||||
"unread": "Показывать при непрочитанных",
|
||||
"hide": "Скрывать"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Проверить звук уведомления",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Удалить",
|
||||
"mark_as_spam": "Отметить как спам",
|
||||
"not_spam": "Не спам",
|
||||
"color_tag": "Тег",
|
||||
"remove_color": "Удалить тег",
|
||||
"tag": "Тег",
|
||||
"items_selected": "{count} писем выбрано",
|
||||
"edit_draft": "Редактировать черновик",
|
||||
"cancel_scheduled_send": "Отменить отправку",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Resetovať",
|
||||
"demo_tour": "Sprievodca",
|
||||
"tags": "Štítky",
|
||||
"show_all_tags": "Zobraziť všetko ({count})",
|
||||
"show_fewer_tags": "Zobraziť menej",
|
||||
"folders": "Priečinky",
|
||||
"shared": "Zdieľané",
|
||||
"mail": "Pošta",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Zobraziť kontakt",
|
||||
"message_details": "Podrobnosti správy",
|
||||
"more_reply_options": "Viac možností odpovede",
|
||||
"set_color": "Nastaviť štítok",
|
||||
"set_tag": "Nastaviť štítok",
|
||||
"tag": "Štítok",
|
||||
"more_actions": "Viac akcií",
|
||||
"previous": "Predchádzajúci",
|
||||
"next": "Ďalší",
|
||||
"move_to": "Presunúť do...",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
"remove_tag": "Odstrániť štítok",
|
||||
"tag_filter_placeholder": "Filtrovať štítky",
|
||||
"tag_no_matches": "Žiadne zodpovedajúce štítky",
|
||||
"more_count": "+{count} ďalších",
|
||||
"characters_count": "{count} znakov",
|
||||
"quick_reply_placeholder": "Napísať rýchlu odpoveď...",
|
||||
@@ -425,17 +429,6 @@
|
||||
"message_id": "ID správy",
|
||||
"list_info": "Informácie o zozname"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Farebný štítok",
|
||||
"red": "Červený",
|
||||
"orange": "Oranžový",
|
||||
"yellow": "Žltý",
|
||||
"green": "Zelený",
|
||||
"blue": "Modrý",
|
||||
"purple": "Fialový",
|
||||
"pink": "RŪžový",
|
||||
"none": "Žiadny"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Odpovedať (r)",
|
||||
"reply_all": "Odpovedať všetkým (a)",
|
||||
@@ -1019,7 +1012,6 @@
|
||||
"title": "E-mailové štítky",
|
||||
"description": "Definujte štítky na organizáciu e-mailov s farbami.",
|
||||
"add_keyword": "Pridať štítok",
|
||||
"reset_defaults": "Obnoviť predvolené",
|
||||
"label_field": "Zobrazovaný názov",
|
||||
"label_placeholder": "napr. Práca, Osobné, Naliehavé",
|
||||
"id_field": "ID štítku",
|
||||
@@ -1032,7 +1024,22 @@
|
||||
"add": "Pridať",
|
||||
"cancel": "Zrušiť",
|
||||
"migrating": "Aktualizácia štítku v existujúcich e-mailoch…",
|
||||
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch"
|
||||
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch",
|
||||
"nesting": {
|
||||
"label": "Vnorené štítky",
|
||||
"description": "Vnorujte štítky pod iné štítky a zobrazujte ich v bočnom paneli ako strom."
|
||||
},
|
||||
"parent_field": "Nadradený štítok",
|
||||
"no_parent": "Bez nadradeného štítku",
|
||||
"too_long": "Táto cesta štítku je príliš dlhá (najviac {max} znakov)",
|
||||
"has_children_locked": "Pod týmto štítkom sú vnorené ďalšie štítky, preto sú jeho názov a nadradený štítok uzamknuté. Najprv ich presuňte alebo odstráňte.",
|
||||
"has_children_delete": "Najprv odstráňte štítky vnorené pod týmto",
|
||||
"visibility_field": "Viditeľnosť v bočnom paneli",
|
||||
"visibility": {
|
||||
"show": "Zobraziť",
|
||||
"unread": "Zobraziť pri neprečítaných",
|
||||
"hide": "Skryť"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Otestovať zvuk oznámenia",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Odstrániť",
|
||||
"mark_as_spam": "Nahlásiť spam",
|
||||
"not_spam": "Nie je spam",
|
||||
"color_tag": "Štítok",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
"tag": "Štítok",
|
||||
"items_selected": "{count} vybraných e-mailov",
|
||||
"edit_draft": "Upraviť koncept",
|
||||
"cancel_scheduled_send": "Zrušiť odoslanie",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Sıfırla",
|
||||
"demo_tour": "Tur",
|
||||
"tags": "Etiketler",
|
||||
"show_all_tags": "Tümünü göster ({count})",
|
||||
"show_fewer_tags": "Daha az göster",
|
||||
"folders": "Klasörler",
|
||||
"shared": "Paylaşılan",
|
||||
"mail": "Posta",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Kişiyi görüntüle",
|
||||
"message_details": "İleti Ayrıntıları",
|
||||
"more_reply_options": "Daha fazla yanıt seçeneği",
|
||||
"set_color": "Etiket ayarla",
|
||||
"set_tag": "Etiket ayarla",
|
||||
"tag": "Etiket",
|
||||
"more_actions": "Diğer işlemler",
|
||||
"previous": "Önceki",
|
||||
"next": "Sonraki",
|
||||
"move_to": "Şuraya taşı...",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
"remove_tag": "Etiketi kaldır",
|
||||
"tag_filter_placeholder": "Etiketleri filtrele",
|
||||
"tag_no_matches": "Eşleşen etiket yok",
|
||||
"more_count": "+{count} daha",
|
||||
"characters_count": "{count} karakter",
|
||||
"quick_reply_placeholder": "Hızlı yanıt yazın...",
|
||||
@@ -400,17 +404,6 @@
|
||||
"message_id": "İleti Kimliği",
|
||||
"list_info": "Liste Bilgisi"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Renk Etiketi",
|
||||
"red": "Kırmızı",
|
||||
"orange": "Turuncu",
|
||||
"yellow": "Sarı",
|
||||
"green": "Yeşil",
|
||||
"blue": "Mavi",
|
||||
"purple": "Mor",
|
||||
"pink": "Pembe",
|
||||
"none": "Yok"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Yanıtla (r)",
|
||||
"reply_all": "Tümünü Yanıtla (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "E-posta Etiketleri",
|
||||
"description": "E-postalarınızı renklerle düzenlemek için etiketler tanımlayın. Bunlar sunucuda JMAP anahtar sözcükleri olarak saklanır.",
|
||||
"add_keyword": "Etiket Ekle",
|
||||
"reset_defaults": "Varsayılanlara Sıfırla",
|
||||
"label_field": "Görünen Ad",
|
||||
"label_placeholder": "ör. İş, Kişisel, Acil",
|
||||
"id_field": "Etiket Kimliği",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "Ekle",
|
||||
"cancel": "İptal",
|
||||
"migrating": "Mevcut e-postalardaki etiket güncelleniyor…",
|
||||
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi"
|
||||
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi",
|
||||
"nesting": {
|
||||
"label": "İç içe etiketler",
|
||||
"description": "Etiketleri başka etiketlerin altına yerleştirin ve kenar çubuğunda ağaç olarak gösterin."
|
||||
},
|
||||
"parent_field": "Üst etiket",
|
||||
"no_parent": "Üst etiket yok",
|
||||
"too_long": "Bu etiket yolu çok uzun (en fazla {max} karakter)",
|
||||
"has_children_locked": "Bunun altında başka etiketler var, bu nedenle adı ve üst etiketi kilitli. Önce onları taşıyın veya kaldırın.",
|
||||
"has_children_delete": "Önce bunun altındaki etiketleri kaldırın",
|
||||
"visibility_field": "Kenar çubuğunda görünürlük",
|
||||
"visibility": {
|
||||
"show": "Göster",
|
||||
"unread": "Okunmamış varsa göster",
|
||||
"hide": "Gizle"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Bildirim sesini test et",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Sil",
|
||||
"mark_as_spam": "Spam bildir",
|
||||
"not_spam": "Spam değil",
|
||||
"color_tag": "Etiket",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
"tag": "Etiket",
|
||||
"items_selected": "{count} e-posta seçildi",
|
||||
"edit_draft": "Taslağı Düzenle",
|
||||
"cancel_scheduled_send": "Göndermeyi iptal et",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Скинути",
|
||||
"demo_tour": "Тур",
|
||||
"tags": "Теги",
|
||||
"show_all_tags": "Показати всі ({count})",
|
||||
"show_fewer_tags": "Показати менше",
|
||||
"folders": "Папки",
|
||||
"mail": "Пошта",
|
||||
"nav_label": "Навігація",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "Переглянути контакт",
|
||||
"message_details": "Деталі повідомлення",
|
||||
"more_reply_options": "Більше варіантів відповіді",
|
||||
"set_color": "Встановити тег",
|
||||
"set_tag": "Встановити тег",
|
||||
"tag": "Тег",
|
||||
"more_actions": "Більше дій",
|
||||
"previous": "попередня",
|
||||
"next": "Далі",
|
||||
"move_to": "Перейти до...",
|
||||
"remove_color": "Видалити тег",
|
||||
"remove_tag": "Видалити тег",
|
||||
"tag_filter_placeholder": "Фільтр тегів",
|
||||
"tag_no_matches": "Немає відповідних тегів",
|
||||
"more_count": "+ ще {count}",
|
||||
"characters_count": "{count} символів",
|
||||
"quick_reply_placeholder": "Напишіть швидку відповідь...",
|
||||
@@ -400,17 +404,6 @@
|
||||
"message_id": "ID повідомлення",
|
||||
"list_info": "Інформація про список"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "Кольоровий тег",
|
||||
"red": "Червоний",
|
||||
"orange": "Помаранчевий",
|
||||
"yellow": "Жовтий",
|
||||
"green": "Зелений",
|
||||
"blue": "Синій",
|
||||
"purple": "Фіолетовий",
|
||||
"pink": "Рожевий",
|
||||
"none": "Жодного"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "Відповісти (р)",
|
||||
"reply_all": "Відповісти всім (а)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "Ключові слова електронної пошти",
|
||||
"description": "Визначте ключові слова (мітки/теги), щоб упорядкувати свої листи за кольорами. Вони зберігаються як ключові слова JMAP на сервері.",
|
||||
"add_keyword": "Додати ключове слово",
|
||||
"reset_defaults": "Скинути до значень за замовчуванням",
|
||||
"label_field": "Відображуване ім'я",
|
||||
"label_placeholder": "напр. Робота, Особиста, Терміново",
|
||||
"id_field": "ID ключового слова",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "додати",
|
||||
"cancel": "Скасувати",
|
||||
"migrating": "Оновлення ключового слова в наявних електронних листах…",
|
||||
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах"
|
||||
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах",
|
||||
"nesting": {
|
||||
"label": "Вкладені теги",
|
||||
"description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева."
|
||||
},
|
||||
"parent_field": "Батьківський тег",
|
||||
"no_parent": "Без батьківського тега",
|
||||
"too_long": "Цей шлях тега задовгий (щонайбільше {max} символів)",
|
||||
"has_children_locked": "У цей тег вкладено інші теги, тому його назву та батьківський тег заблоковано. Спочатку перемістіть або видаліть їх.",
|
||||
"has_children_delete": "Спочатку видаліть теги, вкладені в цей",
|
||||
"visibility_field": "Видимість на бічній панелі",
|
||||
"visibility": {
|
||||
"show": "Показувати",
|
||||
"unread": "Показувати за непрочитаних",
|
||||
"hide": "Приховувати"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "Тестовий звук сповіщення",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "Видалити",
|
||||
"mark_as_spam": "Повідомити про спам",
|
||||
"not_spam": "Не спам",
|
||||
"color_tag": "Мітка",
|
||||
"remove_color": "Видалити мітку",
|
||||
"tag": "Мітка",
|
||||
"items_selected": "Вибрано електронних листів: {count}",
|
||||
"edit_draft": "Редагувати чернетку",
|
||||
"cancel_scheduled_send": "Скасувати надсилання",
|
||||
|
||||
+23
-17
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "重置",
|
||||
"demo_tour": "引导",
|
||||
"tags": "标签",
|
||||
"show_all_tags": "显示全部({count})",
|
||||
"show_fewer_tags": "收起",
|
||||
"folders": "文件夹",
|
||||
"mail": "邮件",
|
||||
"nav_label": "导航",
|
||||
@@ -325,13 +327,15 @@
|
||||
"view_contact": "查看联系人",
|
||||
"message_details": "邮件详情",
|
||||
"more_reply_options": "更多回复选项",
|
||||
"set_color": "设置颜色标签",
|
||||
"set_tag": "设置颜色标签",
|
||||
"tag": "标签",
|
||||
"more_actions": "更多操作",
|
||||
"previous": "上一封",
|
||||
"next": "下一封",
|
||||
"move_to": "移动到…",
|
||||
"remove_color": "删除标签",
|
||||
"remove_tag": "删除标签",
|
||||
"tag_filter_placeholder": "筛选标签",
|
||||
"tag_no_matches": "没有匹配的标签",
|
||||
"more_count": "+{count} 更多",
|
||||
"characters_count": "{count} 个字符",
|
||||
"quick_reply_placeholder": "快速回复...",
|
||||
@@ -400,17 +404,6 @@
|
||||
"message_id": "消息 ID",
|
||||
"list_info": "邮件列表信息"
|
||||
},
|
||||
"color_tag": {
|
||||
"title": "颜色标签",
|
||||
"red": "红色",
|
||||
"orange": "橙色",
|
||||
"yellow": "黄色",
|
||||
"green": "绿色",
|
||||
"blue": "蓝色",
|
||||
"purple": "紫色",
|
||||
"pink": "粉色",
|
||||
"none": "无"
|
||||
},
|
||||
"tooltips": {
|
||||
"reply": "回复 (r)",
|
||||
"reply_all": "全部回复 (a)",
|
||||
@@ -1016,7 +1009,6 @@
|
||||
"title": "电子邮件标签",
|
||||
"description": "定义标签以使用颜色组织您的电子邮件。这些标签作为JMAP关键词存储在服务器上。",
|
||||
"add_keyword": "添加标签",
|
||||
"reset_defaults": "重置为默认值",
|
||||
"label_field": "显示名称",
|
||||
"label_placeholder": "例如工作、个人、紧急",
|
||||
"id_field": "标签ID",
|
||||
@@ -1029,7 +1021,22 @@
|
||||
"add": "添加",
|
||||
"cancel": "取消",
|
||||
"migrating": "正在更新现有邮件的标签…",
|
||||
"migration_error": "更新现有邮件的标签失败"
|
||||
"migration_error": "更新现有邮件的标签失败",
|
||||
"nesting": {
|
||||
"label": "嵌套标签",
|
||||
"description": "将标签嵌套在其他标签之下,并在侧边栏中以树形显示。"
|
||||
},
|
||||
"parent_field": "上级标签",
|
||||
"no_parent": "无上级标签",
|
||||
"too_long": "此标签路径过长(最多 {max} 个字符)",
|
||||
"has_children_locked": "此标签下嵌套了其他标签,因此其名称和上级标签已锁定。请先移动或删除它们。",
|
||||
"has_children_delete": "请先删除嵌套在此标签下的标签",
|
||||
"visibility_field": "侧边栏显示",
|
||||
"visibility": {
|
||||
"show": "显示",
|
||||
"unread": "有未读时显示",
|
||||
"hide": "隐藏"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"test_sound": "测试通知声音",
|
||||
@@ -2034,8 +2041,7 @@
|
||||
"delete": "删除",
|
||||
"mark_as_spam": "举报垃圾邮件",
|
||||
"not_spam": "不是垃圾邮件",
|
||||
"color_tag": "标签",
|
||||
"remove_color": "删除标签",
|
||||
"tag": "标签",
|
||||
"items_selected": "已选择 {count} 封邮件",
|
||||
"edit_draft": "编辑草稿",
|
||||
"cancel_scheduled_send": "取消发送",
|
||||
|
||||
@@ -59,6 +59,7 @@ const nextConfig: NextConfig = {
|
||||
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
||||
NEXT_PUBLIC_APP_VERSION: appVersion,
|
||||
NEXT_PUBLIC_BASE_PATH: basePath,
|
||||
NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE } from '../settings-store';
|
||||
import { useSettingsStore, DEFAULT_KEYWORDS, DEV_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store';
|
||||
import type { KeywordDefinition } from '../settings-store';
|
||||
|
||||
describe('settings-store keywords', () => {
|
||||
@@ -16,7 +16,7 @@ describe('settings-store keywords', () => {
|
||||
DEFAULT_KEYWORDS.forEach((kw) => {
|
||||
expect(KEYWORD_PALETTE[kw.color]).toBeDefined();
|
||||
expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy();
|
||||
expect(KEYWORD_PALETTE[kw.color].bg).toBeTruthy();
|
||||
expect(KEYWORD_PALETTE[kw.color].fill).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,17 +24,67 @@ describe('settings-store keywords', () => {
|
||||
const ids = DEFAULT_KEYWORDS.map((k) => k.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('ships no nested tag, which is opt-in', () => {
|
||||
DEFAULT_KEYWORDS.forEach((kw) => expect(kw.id).not.toContain('/'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEV_KEYWORDS', () => {
|
||||
it('every nested tag has its parent defined, so the tree has no gaps', () => {
|
||||
const ids = new Set(DEV_KEYWORDS.map((k) => k.id));
|
||||
DEV_KEYWORDS.forEach((kw) => {
|
||||
const cut = kw.id.lastIndexOf('/');
|
||||
if (cut > 0) expect(ids, `orphan: ${kw.id}`).toContain(kw.id.slice(0, cut));
|
||||
});
|
||||
});
|
||||
|
||||
it('nests deeply enough to exercise the tree', () => {
|
||||
const depths = DEV_KEYWORDS.map((k) => k.id.split('/').length);
|
||||
expect(Math.max(...depths)).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('each dev keyword has a valid palette color and a unique id', () => {
|
||||
const ids = DEV_KEYWORDS.map((k) => k.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
DEV_KEYWORDS.forEach((kw) => expect(KEYWORD_PALETTE[kw.color]).toBeDefined());
|
||||
});
|
||||
});
|
||||
|
||||
describe('KEYWORD_PALETTE', () => {
|
||||
it('has 13 colors', () => {
|
||||
expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(13);
|
||||
it('has a lighter, base and darker shade of every hue', () => {
|
||||
expect(KEYWORD_PALETTE_ROWS).toHaveLength(3);
|
||||
KEYWORD_PALETTE_ROWS.forEach((row) => expect(row).toHaveLength(13));
|
||||
expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(39);
|
||||
});
|
||||
|
||||
it('each color has dot and bg classes', () => {
|
||||
it('lays every row out in the same hue order', () => {
|
||||
const [light, base, dark] = KEYWORD_PALETTE_ROWS;
|
||||
expect(light).toEqual(base.map((key) => `${key}-light`));
|
||||
expect(dark).toEqual(base.map((key) => `${key}-dark`));
|
||||
});
|
||||
|
||||
it('keeps the bare hue name on the base row, so saved tags still resolve', () => {
|
||||
// A tag stored as `red` predates the lighter and darker rows.
|
||||
expect(KEYWORD_PALETTE_ROWS[1]).toContain('red');
|
||||
expect(KEYWORD_PALETTE.red).toBeDefined();
|
||||
});
|
||||
|
||||
it('spells every class out so Tailwind can find it', () => {
|
||||
// A composed class name would compile to nothing, so none may be built
|
||||
// at runtime and each has to carry its own utility prefix.
|
||||
Object.values(KEYWORD_PALETTE).forEach((entry) => {
|
||||
expect(entry.dot).toMatch(/^bg-/);
|
||||
expect(entry.bg).toMatch(/^bg-/);
|
||||
expect(entry.fill).toMatch(/^bg-/);
|
||||
expect(entry.border).toMatch(/^border-/);
|
||||
expect(entry.text).toMatch(/^text-.* dark:text-/);
|
||||
expect(entry.rowTint).toMatch(/^bg-.* dark:bg-/);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves every row key', () => {
|
||||
KEYWORD_PALETTE_ROWS.flat().forEach((key) => {
|
||||
expect(KEYWORD_PALETTE[key]).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -156,4 +206,30 @@ describe('settings-store keywords', () => {
|
||||
expect(kw?.label).toBe('Scarlet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKeywordVisibility', () => {
|
||||
it('treats a tag stored before visibility was configurable as always shown', () => {
|
||||
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red' })).toBe('show');
|
||||
});
|
||||
|
||||
it('returns the stored choice when there is one', () => {
|
||||
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red', visibility: 'unread' })).toBe('unread');
|
||||
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red', visibility: 'hide' })).toBe('hide');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nestedTags', () => {
|
||||
it('is off by default', () => {
|
||||
useSettingsStore.getState().resetToDefaults();
|
||||
expect(useSettingsStore.getState().nestedTags).toBe(false);
|
||||
});
|
||||
|
||||
it('is included in exported settings', () => {
|
||||
useSettingsStore.getState().updateSetting('nestedTags', true);
|
||||
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
|
||||
nestedTags?: boolean;
|
||||
};
|
||||
expect(exported.nestedTags).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+102
-16
@@ -101,10 +101,19 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
|
||||
{ id: 'contacts', labelKey: 'contacts' },
|
||||
];
|
||||
|
||||
/** Whether a tag shows in the sidebar always, only when it has unread mail, or never. */
|
||||
export type KeywordVisibility = 'show' | 'hide' | 'unread';
|
||||
|
||||
export interface KeywordDefinition {
|
||||
id: string; // Used as JMAP keyword suffix: $label:<id>
|
||||
label: string; // Display name
|
||||
color: string; // Key from KEYWORD_PALETTE
|
||||
visibility?: KeywordVisibility; // Absent on tags stored before this was configurable
|
||||
}
|
||||
|
||||
/** Resolves the sidebar visibility of a tag, defaulting to always shown. */
|
||||
export function getKeywordVisibility(keyword: KeywordDefinition): KeywordVisibility {
|
||||
return keyword.visibility ?? 'show';
|
||||
}
|
||||
|
||||
export interface SidebarApp {
|
||||
@@ -116,23 +125,86 @@ export interface SidebarApp {
|
||||
showOnMobile: boolean;
|
||||
}
|
||||
|
||||
// Available color palette for keywords
|
||||
export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = {
|
||||
red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' },
|
||||
orange: { dot: 'bg-orange-500', bg: 'bg-orange-50 dark:bg-orange-950/30' },
|
||||
yellow: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 dark:bg-yellow-950/30' },
|
||||
green: { dot: 'bg-green-500', bg: 'bg-green-50 dark:bg-green-950/30' },
|
||||
blue: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-950/30' },
|
||||
purple: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-950/30' },
|
||||
pink: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-950/30' },
|
||||
teal: { dot: 'bg-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' },
|
||||
cyan: { dot: 'bg-cyan-500', bg: 'bg-cyan-50 dark:bg-cyan-950/30' },
|
||||
indigo: { dot: 'bg-indigo-500', bg: 'bg-indigo-50 dark:bg-indigo-950/30' },
|
||||
amber: { dot: 'bg-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' },
|
||||
lime: { dot: 'bg-lime-500', bg: 'bg-lime-50 dark:bg-lime-950/30' },
|
||||
gray: { dot: 'bg-gray-500', bg: 'bg-gray-50 dark:bg-gray-950/30' },
|
||||
export interface KeywordColor {
|
||||
/** Solid swatch: the dot form and the settings swatches. */
|
||||
dot: string;
|
||||
/** The same solid colour as `dot`, for glyphs that take a text colour. */
|
||||
icon: string;
|
||||
/** Lozenge background. */
|
||||
fill: string;
|
||||
/** Lozenge border. */
|
||||
border: string;
|
||||
/** Lozenge text. */
|
||||
text: string;
|
||||
/** Full-row wash when `tintListRowsByTag` is on. */
|
||||
rowTint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag colours, written out literally.
|
||||
*
|
||||
* Tailwind v4 scans this file, but only for classes that appear verbatim -
|
||||
* a composed `bg-${hue}-500` would compile to nothing. Every shade a tag can
|
||||
* take therefore has to be spelled out, which is why this map is long.
|
||||
*
|
||||
* Three shades per hue: the middle one keeps the bare hue name, so a tag
|
||||
* saved before the lighter and darker rows existed still resolves.
|
||||
*/
|
||||
export const KEYWORD_PALETTE: Record<string, KeywordColor> = {
|
||||
// light
|
||||
'red-light': { dot: 'bg-red-300', icon: 'text-red-300', fill: 'bg-red-300/10', border: 'border-red-300/30', text: 'text-red-600 dark:text-red-200', rowTint: 'bg-red-50/60 dark:bg-red-950/20' },
|
||||
'orange-light': { dot: 'bg-orange-300', icon: 'text-orange-300', fill: 'bg-orange-300/10', border: 'border-orange-300/30', text: 'text-orange-600 dark:text-orange-200', rowTint: 'bg-orange-50/60 dark:bg-orange-950/20' },
|
||||
'amber-light': { dot: 'bg-amber-300', icon: 'text-amber-300', fill: 'bg-amber-300/10', border: 'border-amber-300/30', text: 'text-amber-600 dark:text-amber-200', rowTint: 'bg-amber-50/60 dark:bg-amber-950/20' },
|
||||
'yellow-light': { dot: 'bg-yellow-300', icon: 'text-yellow-300', fill: 'bg-yellow-300/10', border: 'border-yellow-300/30', text: 'text-yellow-600 dark:text-yellow-200', rowTint: 'bg-yellow-50/60 dark:bg-yellow-950/20' },
|
||||
'lime-light': { dot: 'bg-lime-300', icon: 'text-lime-300', fill: 'bg-lime-300/10', border: 'border-lime-300/30', text: 'text-lime-600 dark:text-lime-200', rowTint: 'bg-lime-50/60 dark:bg-lime-950/20' },
|
||||
'green-light': { dot: 'bg-green-300', icon: 'text-green-300', fill: 'bg-green-300/10', border: 'border-green-300/30', text: 'text-green-600 dark:text-green-200', rowTint: 'bg-green-50/60 dark:bg-green-950/20' },
|
||||
'teal-light': { dot: 'bg-teal-300', icon: 'text-teal-300', fill: 'bg-teal-300/10', border: 'border-teal-300/30', text: 'text-teal-600 dark:text-teal-200', rowTint: 'bg-teal-50/60 dark:bg-teal-950/20' },
|
||||
'cyan-light': { dot: 'bg-cyan-300', icon: 'text-cyan-300', fill: 'bg-cyan-300/10', border: 'border-cyan-300/30', text: 'text-cyan-600 dark:text-cyan-200', rowTint: 'bg-cyan-50/60 dark:bg-cyan-950/20' },
|
||||
'blue-light': { dot: 'bg-blue-300', icon: 'text-blue-300', fill: 'bg-blue-300/10', border: 'border-blue-300/30', text: 'text-blue-600 dark:text-blue-200', rowTint: 'bg-blue-50/60 dark:bg-blue-950/20' },
|
||||
'indigo-light': { dot: 'bg-indigo-300', icon: 'text-indigo-300', fill: 'bg-indigo-300/10', border: 'border-indigo-300/30', text: 'text-indigo-600 dark:text-indigo-200', rowTint: 'bg-indigo-50/60 dark:bg-indigo-950/20' },
|
||||
'purple-light': { dot: 'bg-purple-300', icon: 'text-purple-300', fill: 'bg-purple-300/10', border: 'border-purple-300/30', text: 'text-purple-600 dark:text-purple-200', rowTint: 'bg-purple-50/60 dark:bg-purple-950/20' },
|
||||
'pink-light': { dot: 'bg-pink-300', icon: 'text-pink-300', fill: 'bg-pink-300/10', border: 'border-pink-300/30', text: 'text-pink-600 dark:text-pink-200', rowTint: 'bg-pink-50/60 dark:bg-pink-950/20' },
|
||||
'gray-light': { dot: 'bg-gray-300', icon: 'text-gray-300', fill: 'bg-gray-300/10', border: 'border-gray-300/30', text: 'text-gray-600 dark:text-gray-200', rowTint: 'bg-gray-50/60 dark:bg-gray-950/20' },
|
||||
// base
|
||||
red: { dot: 'bg-red-500', icon: 'text-red-500', fill: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-700 dark:text-red-300', rowTint: 'bg-red-50 dark:bg-red-950/30' },
|
||||
orange: { dot: 'bg-orange-500', icon: 'text-orange-500', fill: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-700 dark:text-orange-300', rowTint: 'bg-orange-50 dark:bg-orange-950/30' },
|
||||
amber: { dot: 'bg-amber-500', icon: 'text-amber-500', fill: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-700 dark:text-amber-300', rowTint: 'bg-amber-50 dark:bg-amber-950/30' },
|
||||
yellow: { dot: 'bg-yellow-500', icon: 'text-yellow-500', fill: 'bg-yellow-500/10', border: 'border-yellow-500/30', text: 'text-yellow-700 dark:text-yellow-300', rowTint: 'bg-yellow-50 dark:bg-yellow-950/30' },
|
||||
lime: { dot: 'bg-lime-500', icon: 'text-lime-500', fill: 'bg-lime-500/10', border: 'border-lime-500/30', text: 'text-lime-700 dark:text-lime-300', rowTint: 'bg-lime-50 dark:bg-lime-950/30' },
|
||||
green: { dot: 'bg-green-500', icon: 'text-green-500', fill: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-700 dark:text-green-300', rowTint: 'bg-green-50 dark:bg-green-950/30' },
|
||||
teal: { dot: 'bg-teal-500', icon: 'text-teal-500', fill: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-700 dark:text-teal-300', rowTint: 'bg-teal-50 dark:bg-teal-950/30' },
|
||||
cyan: { dot: 'bg-cyan-500', icon: 'text-cyan-500', fill: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-700 dark:text-cyan-300', rowTint: 'bg-cyan-50 dark:bg-cyan-950/30' },
|
||||
blue: { dot: 'bg-blue-500', icon: 'text-blue-500', fill: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-700 dark:text-blue-300', rowTint: 'bg-blue-50 dark:bg-blue-950/30' },
|
||||
indigo: { dot: 'bg-indigo-500', icon: 'text-indigo-500', fill: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-700 dark:text-indigo-300', rowTint: 'bg-indigo-50 dark:bg-indigo-950/30' },
|
||||
purple: { dot: 'bg-purple-500', icon: 'text-purple-500', fill: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-700 dark:text-purple-300', rowTint: 'bg-purple-50 dark:bg-purple-950/30' },
|
||||
pink: { dot: 'bg-pink-500', icon: 'text-pink-500', fill: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-700 dark:text-pink-300', rowTint: 'bg-pink-50 dark:bg-pink-950/30' },
|
||||
gray: { dot: 'bg-gray-500', icon: 'text-gray-500', fill: 'bg-gray-500/10', border: 'border-gray-500/30', text: 'text-gray-700 dark:text-gray-300', rowTint: 'bg-gray-50 dark:bg-gray-950/30' },
|
||||
// dark
|
||||
'red-dark': { dot: 'bg-red-700', icon: 'text-red-700', fill: 'bg-red-700/10', border: 'border-red-700/30', text: 'text-red-800 dark:text-red-400', rowTint: 'bg-red-100 dark:bg-red-950/50' },
|
||||
'orange-dark': { dot: 'bg-orange-700', icon: 'text-orange-700', fill: 'bg-orange-700/10', border: 'border-orange-700/30', text: 'text-orange-800 dark:text-orange-400', rowTint: 'bg-orange-100 dark:bg-orange-950/50' },
|
||||
'amber-dark': { dot: 'bg-amber-700', icon: 'text-amber-700', fill: 'bg-amber-700/10', border: 'border-amber-700/30', text: 'text-amber-800 dark:text-amber-400', rowTint: 'bg-amber-100 dark:bg-amber-950/50' },
|
||||
'yellow-dark': { dot: 'bg-yellow-700', icon: 'text-yellow-700', fill: 'bg-yellow-700/10', border: 'border-yellow-700/30', text: 'text-yellow-800 dark:text-yellow-400', rowTint: 'bg-yellow-100 dark:bg-yellow-950/50' },
|
||||
'lime-dark': { dot: 'bg-lime-700', icon: 'text-lime-700', fill: 'bg-lime-700/10', border: 'border-lime-700/30', text: 'text-lime-800 dark:text-lime-400', rowTint: 'bg-lime-100 dark:bg-lime-950/50' },
|
||||
'green-dark': { dot: 'bg-green-700', icon: 'text-green-700', fill: 'bg-green-700/10', border: 'border-green-700/30', text: 'text-green-800 dark:text-green-400', rowTint: 'bg-green-100 dark:bg-green-950/50' },
|
||||
'teal-dark': { dot: 'bg-teal-700', icon: 'text-teal-700', fill: 'bg-teal-700/10', border: 'border-teal-700/30', text: 'text-teal-800 dark:text-teal-400', rowTint: 'bg-teal-100 dark:bg-teal-950/50' },
|
||||
'cyan-dark': { dot: 'bg-cyan-700', icon: 'text-cyan-700', fill: 'bg-cyan-700/10', border: 'border-cyan-700/30', text: 'text-cyan-800 dark:text-cyan-400', rowTint: 'bg-cyan-100 dark:bg-cyan-950/50' },
|
||||
'blue-dark': { dot: 'bg-blue-700', icon: 'text-blue-700', fill: 'bg-blue-700/10', border: 'border-blue-700/30', text: 'text-blue-800 dark:text-blue-400', rowTint: 'bg-blue-100 dark:bg-blue-950/50' },
|
||||
'indigo-dark': { dot: 'bg-indigo-700', icon: 'text-indigo-700', fill: 'bg-indigo-700/10', border: 'border-indigo-700/30', text: 'text-indigo-800 dark:text-indigo-400', rowTint: 'bg-indigo-100 dark:bg-indigo-950/50' },
|
||||
'purple-dark': { dot: 'bg-purple-700', icon: 'text-purple-700', fill: 'bg-purple-700/10', border: 'border-purple-700/30', text: 'text-purple-800 dark:text-purple-400', rowTint: 'bg-purple-100 dark:bg-purple-950/50' },
|
||||
'pink-dark': { dot: 'bg-pink-700', icon: 'text-pink-700', fill: 'bg-pink-700/10', border: 'border-pink-700/30', text: 'text-pink-800 dark:text-pink-400', rowTint: 'bg-pink-100 dark:bg-pink-950/50' },
|
||||
'gray-dark': { dot: 'bg-gray-700', icon: 'text-gray-700', fill: 'bg-gray-700/10', border: 'border-gray-700/30', text: 'text-gray-800 dark:text-gray-400', rowTint: 'bg-gray-100 dark:bg-gray-950/50' },
|
||||
} as const;
|
||||
|
||||
/** Palette laid out as the settings picker shows it: lighter, base, darker. */
|
||||
export const KEYWORD_PALETTE_ROWS: string[][] = [
|
||||
['red-light', 'orange-light', 'amber-light', 'yellow-light', 'lime-light', 'green-light', 'teal-light', 'cyan-light', 'blue-light', 'indigo-light', 'purple-light', 'pink-light', 'gray-light'],
|
||||
['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray'],
|
||||
['red-dark', 'orange-dark', 'amber-dark', 'yellow-dark', 'lime-dark', 'green-dark', 'teal-dark', 'cyan-dark', 'blue-dark', 'indigo-dark', 'purple-dark', 'pink-dark', 'gray-dark'],
|
||||
];
|
||||
|
||||
/** The colour a tag falls back to when its definition is gone. */
|
||||
export const FALLBACK_KEYWORD_COLOR = 'gray';
|
||||
|
||||
export const DEFAULT_KEYWORDS: KeywordDefinition[] = [
|
||||
{ id: 'red', label: 'Red', color: 'red' },
|
||||
{ id: 'orange', label: 'Orange', color: 'orange' },
|
||||
@@ -143,6 +215,17 @@ export const DEFAULT_KEYWORDS: KeywordDefinition[] = [
|
||||
{ id: 'pink', label: 'Pink', color: 'pink' },
|
||||
];
|
||||
|
||||
export const DEV_KEYWORDS: KeywordDefinition[] = [
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'work/clients', label: 'Clients', color: 'teal' },
|
||||
{ id: 'work/clients/acme', label: 'Acme', color: 'green' },
|
||||
{ id: 'personal', label: 'Personal', color: 'purple' },
|
||||
{ id: 'personal/finance', label: 'Finance', color: 'amber' },
|
||||
{ id: 'receipts', label: 'Receipts', color: 'gray' },
|
||||
];
|
||||
|
||||
const USING_MOCK_SERVER = process.env.NEXT_PUBLIC_DEV_MOCK_JMAP === 'true';
|
||||
|
||||
interface SettingsState {
|
||||
// Appearance
|
||||
fontSize: FontSize;
|
||||
@@ -287,6 +370,7 @@ interface SettingsState {
|
||||
|
||||
// Keywords (labels/tags)
|
||||
emailKeywords: KeywordDefinition[];
|
||||
nestedTags: boolean; // Treat "/" in a tag id as a parent/child separator
|
||||
|
||||
// Attachment Reminder
|
||||
attachmentReminderEnabled: boolean;
|
||||
@@ -484,7 +568,8 @@ const DEFAULT_SETTINGS = {
|
||||
folderIcons: {} as Record<string, string>,
|
||||
|
||||
// Keywords
|
||||
emailKeywords: DEFAULT_KEYWORDS,
|
||||
emailKeywords: USING_MOCK_SERVER ? DEV_KEYWORDS : DEFAULT_KEYWORDS,
|
||||
nestedTags: USING_MOCK_SERVER,
|
||||
|
||||
// Attachment Reminder
|
||||
attachmentReminderEnabled: true,
|
||||
@@ -661,6 +746,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
showFolderTotalCount: state.showFolderTotalCount,
|
||||
folderIcons: state.folderIcons,
|
||||
emailKeywords: state.emailKeywords,
|
||||
nestedTags: state.nestedTags,
|
||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
||||
|
||||
Reference in New Issue
Block a user