feat: improve visualization of tags

Previously tags where very much focused on color coding email and less about
adding additional information. They were also visualized in different ways in
different locations.

This commit gets rid of all "Color-coding" references, aligns visualization of
the tags across the whole project and tries to improve user experience of using
tags in general.

A search box is shown in the tagging control so the user can quickly search for
a tag if they have a huge (more than 10) amount of tags.
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:42 +02:00
parent 013ef7d557
commit 108406a885
46 changed files with 1102 additions and 832 deletions
+10 -10
View File
@@ -1833,7 +1833,7 @@ export default function Home() {
keywords['$pinned'] = true; 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) // account via the login it is reachable through. (#281)
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
@@ -1857,25 +1857,25 @@ export default function Home() {
} }
}; };
const handleSetColorTag = async (emailId: string, color: string | null) => { const handleSetTag = async (emailId: string, tagId: string | null) => {
if (!client) return; if (!client) return;
try { try {
// Remove any existing label/color tags // Remove any existing tag keywords
const email = emails.find(e => e.id === emailId); const email = emails.find(e => e.id === emailId);
if (!email) return; if (!email) return;
const keywords = { ...email.keywords }; const keywords = { ...email.keywords };
if (color === null) { if (tagId === null) {
// Remove all label/color tags // Remove all tag keywords
Object.keys(keywords).forEach(key => { Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) { if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false; keywords[key] = false;
} }
}); });
} else { } else {
const jmapKey = `$label:${color}`; const jmapKey = `$label:${tagId}`;
if (keywords[jmapKey]) { if (keywords[jmapKey]) {
// Toggle off if already active // Toggle off if already active
keywords[jmapKey] = false; keywords[jmapKey] = false;
@@ -1907,7 +1907,7 @@ export default function Home() {
// Refresh tag counts // Refresh tag counts
fetchTagCounts(client); fetchTagCounts(client);
} catch (error) { } catch (error) {
console.error("Failed to set color tag:", error); console.error("Failed to set tag:", error);
} }
}; };
@@ -3309,8 +3309,8 @@ export default function Home() {
onArchive={async (email) => { onArchive={async (email) => {
await handleArchive(email); await handleArchive(email);
}} }}
onSetColorTag={(emailId, color) => { onSetTag={(emailId, color) => {
handleSetColorTag(emailId, color); handleSetTag(emailId, color);
}} }}
onMoveToMailbox={async (emailId, mailboxId) => { onMoveToMailbox={async (emailId, mailboxId) => {
if (client) { if (client) {
@@ -3534,7 +3534,7 @@ export default function Home() {
}} }}
onArchive={() => handleArchive()} onArchive={() => handleArchive()}
onToggleStar={handleToggleStar} onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag} onSetTag={handleSetTag}
onMarkAsSpam={() => handleMarkAsSpam()} onMarkAsSpam={() => handleMarkAsSpam()}
onUndoSpam={() => handleUndoSpam()} onUndoSpam={() => handleUndoSpam()}
onMarkAsRead={async (emailId, read) => { onMarkAsRead={async (emailId, read) => {
@@ -0,0 +1,101 @@
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('offers the clear-all row only while something is applied', () => {
const { rerender } = render(<TagPicker selectedIds={[]} onToggle={() => {}} onClearAll={() => {}} />);
expect(screen.queryByText('remove_tag')).not.toBeInTheDocument();
rerender(<TagPicker selectedIds={['work']} onToggle={() => {}} onClearAll={() => {}} />);
expect(screen.getByText('remove_tag')).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();
});
});
@@ -118,6 +118,67 @@ describe('ThreadListItem tag badge', () => {
}); });
}); });
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', () => { describe('ThreadListItem row content', () => {
beforeEach(() => { beforeEach(() => {
useSettingsStore.setState({ useSettingsStore.setState({
+14 -53
View File
@@ -23,8 +23,6 @@ import {
Archive, Archive,
FolderInput, FolderInput,
Tag, Tag,
X,
Check,
Inbox, Inbox,
Send, Send,
File, File,
@@ -36,11 +34,9 @@ import {
XCircle, XCircle,
Paperclip, Paperclip,
} from "lucide-react"; } from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label"; import { localizeMailboxName } from "@/lib/mailbox-label";
import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { TagPicker } from "./tag-picker";
import { TagOptionLabel } from "./tag-option-label";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
interface Position { interface Position {
x: number; x: number;
@@ -68,7 +64,7 @@ interface EmailContextMenuProps {
onTogglePinned?: () => void; onTogglePinned?: () => void;
onDelete?: () => void; onDelete?: () => void;
onArchive?: () => void; onArchive?: () => void;
onSetColorTag?: (color: string | null) => void; onSetTag?: (tagId: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void; onMoveToMailbox?: (mailboxId: string) => void;
onMarkAsSpam?: () => void; onMarkAsSpam?: () => void;
onUndoSpam?: () => void; onUndoSpam?: () => void;
@@ -103,8 +99,8 @@ const getMailboxIcon = (role?: string) => {
} }
}; };
// Get all active label/color tag IDs from email keywords /** Every tag id set on a message, reading the current prefix and the legacy one. */
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => { const getCurrentTagIds = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return []; if (!keywords) return [];
const tags: string[] = []; const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
@@ -137,7 +133,7 @@ export function EmailContextMenu({
onTogglePinned, onTogglePinned,
onDelete, onDelete,
onArchive, onArchive,
onSetColorTag, onSetTag,
onMoveToMailbox, onMoveToMailbox,
onMarkAsSpam, onMarkAsSpam,
onUndoSpam, onUndoSpam,
@@ -154,15 +150,12 @@ export function EmailContextMenu({
}: EmailContextMenuProps) { }: EmailContextMenuProps) {
const t = useTranslations("context_menu"); const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar"); const tSidebar = useTranslations("sidebar");
const _tColor = useTranslations("email_viewer.color_tag");
const tEmailViewer = useTranslations("email_viewer"); const tEmailViewer = useTranslations("email_viewer");
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagNameCandidates } = useKeywordFormat();
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true; const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true; const isDraft = email.keywords?.['$draft'] === true;
const currentColors = getCurrentColors(email.keywords); const currentTagIds = getCurrentTagIds(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action // Marking your own outgoing mail as spam makes no sense - hide the action
@@ -171,13 +164,6 @@ export function EmailContextMenu({
const isScheduled = email.isScheduled === true; const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending'; const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({
candidates: tagNameCandidates(kw.id),
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
}));
// Build mailbox tree for move-to submenu with proper hierarchy // Build mailbox tree for move-to submenu with proper hierarchy
const moveTargetIds = new Set( const moveTargetIds = new Set(
mailboxes mailboxes
@@ -383,39 +369,14 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */} {/* Set tag submenu - only for single email */}
{!showBatchActions && ( {!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}> <ContextMenuSubMenu icon={Tag} label={t("tag")}>
<div className="max-w-[18rem]"> <div className="w-56 max-w-[18rem]">
{colorOptions.map((option) => { <TagPicker
const isActive = currentColors.includes(option.value); selectedIds={currentTagIds}
return ( onToggle={(tagId) => handleAction(() => onSetTag?.(tagId))}
<button onClearAll={() => handleAction(() => onSetTag?.(null))}
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)} />
<TagOptionLabel candidates={option.candidates} />
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
</div> </div>
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={X}
label={t("remove_color")}
onClick={() => handleAction(() => onSetColorTag?.(null))}
/>
</>
)}
</ContextMenuSubMenu> </ContextMenuSubMenu>
)} )}
+3 -3
View File
@@ -15,7 +15,7 @@ interface EmailHoverActionsProps {
onMarkAsRead?: (read: boolean) => void; onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void; onDelete?: () => void;
onArchive?: () => void; onArchive?: () => void;
onSetColorTag?: (color: string | null) => void; onSetTag?: (tagId: string | null) => void;
onMarkAsSpam?: () => void; onMarkAsSpam?: () => void;
// When the email lives in a junk folder (incl. the aggregate "All Junk" view) // When the email lives in a junk folder (incl. the aggregate "All Junk" view)
// the spam quick-action flips to "not spam". // the spam quick-action flips to "not spam".
@@ -76,7 +76,7 @@ export function EmailHoverActions({
onMarkAsRead, onMarkAsRead,
onDelete, onDelete,
onArchive, onArchive,
onSetColorTag, onSetTag,
onMarkAsSpam, onMarkAsSpam,
isInJunk = false, isInJunk = false,
onUndoSpam, onUndoSpam,
@@ -112,7 +112,7 @@ export function EmailHoverActions({
onArchive?.(); onArchive?.();
break; break;
case "tag": case "tag":
onSetColorTag?.(null); onSetTag?.(null);
break; break;
case "spam": case "spam":
if (isInJunk) onUndoSpam?.(); if (isInJunk) onUndoSpam?.();
+9 -4
View File
@@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { SearchChips } from "@/components/search/search-chips"; import { SearchChips } from "@/components/search/search-chips";
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
@@ -39,7 +40,7 @@ interface EmailListProps {
onTogglePinned?: (email: Email) => void; onTogglePinned?: (email: Email) => void;
onDelete?: (email: Email) => void; onDelete?: (email: Email) => void;
onArchive?: (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; onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
onMarkAsSpam?: (email: Email) => void; onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void;
@@ -70,7 +71,7 @@ export function EmailList({
onTogglePinned, onTogglePinned,
onDelete, onDelete,
onArchive, onArchive,
onSetColorTag, onSetTag,
onMarkAsSpam, onMarkAsSpam,
onUndoSpam, onUndoSpam,
onMoveToMailbox, onMoveToMailbox,
@@ -136,6 +137,8 @@ export function EmailList({
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const parentRef = useRef<HTMLDivElement>(null); 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 density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -332,6 +335,7 @@ export function EmailList({
}, [density, isFocusedMailLayout, showPreview]); }, [density, isFocusedMailLayout, showPreview]);
return ( return (
<TagDisplayContext.Provider value={tagDisplay}>
<div className={cn("flex flex-col min-h-0", className)}> <div className={cn("flex flex-col min-h-0", className)}>
{/* Batch Actions Toolbar */} {/* Batch Actions Toolbar */}
<div <div
@@ -543,7 +547,7 @@ export function EmailList({
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined} onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
onDelete={onDelete ? (email) => onDelete(email) : undefined} onDelete={onDelete ? (email) => onDelete(email) : undefined}
onArchive={onArchive ? (email) => onArchive(email) : undefined} onArchive={onArchive ? (email) => onArchive(email) : undefined}
onSetColorTag={onSetColorTag} onSetTag={onSetTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined} onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
/> />
@@ -591,7 +595,7 @@ export function EmailList({
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
onDelete={() => onDelete?.(contextMenu.data!)} onDelete={() => onDelete?.(contextMenu.data!)}
onArchive={() => onArchive?.(contextMenu.data!)} onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)} onSetTag={(color) => onSetTag?.(contextMenu.data!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
@@ -645,5 +649,6 @@ export function EmailList({
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div> </div>
</TagDisplayContext.Provider>
); );
} }
+55 -153
View File
@@ -12,7 +12,9 @@ import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
import { TagOptionLabel } from "./tag-option-label"; 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 { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection"; import { emailToReadView } from "@/lib/plugin-projection";
@@ -76,7 +78,7 @@ import {
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime'; 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 { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
@@ -117,7 +119,7 @@ interface EmailViewerProps {
onArchive?: () => void; onArchive?: () => void;
onToggleStar?: () => void; onToggleStar?: () => void;
onMarkAsRead?: (emailId: string, read: boolean) => 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; onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void;
onQuickReply?: (body: string) => Promise<void>; onQuickReply?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void; onMarkAsSpam?: () => void;
@@ -202,7 +204,7 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment'; return 'Attachment';
}; };
const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => { const getCurrentTagIds = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return []; if (!keywords) return [];
const tags: string[] = []; const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
@@ -630,7 +632,7 @@ export function EmailViewer({
onArchive, onArchive,
onToggleStar, onToggleStar,
onMarkAsRead, onMarkAsRead,
onSetColorTag, onSetTag,
onDownloadAttachment, onDownloadAttachment,
onQuickReply, onQuickReply,
onMarkAsSpam, onMarkAsSpam,
@@ -669,7 +671,7 @@ export function EmailViewer({
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagName, tagNameCandidates } = useKeywordFormat(); const { sortTagIds, tagColor } = useKeywordFormat();
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -712,12 +714,6 @@ export function EmailViewer({
const isScheduled = email?.isScheduled === true; const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending'; const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({
candidates: tagNameCandidates(kw.id),
value: kw.id,
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
}));
// Tablet list visibility // Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection(); const { isTablet, isMobile } = useDeviceDetection();
@@ -822,8 +818,13 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null); const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set()); const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColors = getCurrentColors(email?.keywords); const currentTagIds = getCurrentTagIds(email?.keywords);
const currentColor = currentColors[0] ?? null; 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 // Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic
// onRenderEmailBody hook. Verification/decryption status UI is provided by the // onRenderEmailBody hook. Verification/decryption status UI is provided by the
@@ -1021,7 +1022,7 @@ export function EmailViewer({
showToolbarLabels, showToolbarLabels,
isLoading, isLoading,
moveTree.length, moveTree.length,
colorOptions.length, emailKeywords.length,
currentColor, currentColor,
isInJunkFolder, isInJunkFolder,
isTablet, isTablet,
@@ -3000,65 +3001,19 @@ export function EmailViewer({
<div ref={tagMenuRef} className="relative"> <div ref={tagMenuRef} className="relative">
<button <button
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }} onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn( className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2", title={t('set_tag')}
currentColors.length > 0 && "bg-muted/50"
)}
title={t('set_color')}
> >
{currentColors.length > 0 ? ( <Tag className="w-4 h-4" />
<> {showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
<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 && (
<TagOptionLabel
candidates={tagNameCandidates(currentColors[0])}
className="max-w-40 text-xs font-medium text-foreground"
/>
)}
</>
) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
</button> </button>
{tagMenuOpen && ( {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"> <div className="absolute end-0 top-full mt-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => { <TagPicker
const isActive = currentColors.includes(option.value); selectedIds={currentTagIds}
return ( onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setTagMenuOpen(false); }}
<button onClearAll={() => { if (email) onSetTag?.(email.id, null); setTagMenuOpen(false); }}
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)} />
<TagOptionLabel candidates={option.candidates} />
{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> </div>
)} )}
</div> </div>
@@ -3250,7 +3205,7 @@ export function EmailViewer({
</div> </div>
)} )}
{/* Overflow: tag - submenu */} {/* Overflow: tag - submenu */}
{colorOptions.length > 0 && ( {emailKeywords.length > 0 && (
<div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")} <div className={cn("relative", hiddenPriorities.has(6) ? "" : "sm:hidden")}
onMouseEnter={() => setMoreMenuSub('tag')} onMouseEnter={() => setMoreMenuSub('tag')}
onMouseLeave={() => setMoreMenuSub(null)} onMouseLeave={() => setMoreMenuSub(null)}
@@ -3264,36 +3219,12 @@ export function EmailViewer({
<ChevronRight className="w-3 h-3 text-muted-foreground" /> <ChevronRight className="w-3 h-3 text-muted-foreground" />
</button> </button>
{moreMenuSub === 'tag' && ( {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"> <div className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => { <TagPicker
const isActive = currentColors.includes(option.value); selectedIds={currentTagIds}
return ( onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
<button onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
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)} />
<TagOptionLabel candidates={option.candidates} />
{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> </div>
)} )}
</div> </div>
@@ -3437,19 +3368,21 @@ export function EmailViewer({
{isStarred ? t('tooltips.unstar') : t('tooltips.star')} {isStarred ? t('tooltips.unstar') : t('tooltips.star')}
</button> </button>
{/* Tag (opens sub-view) */} {/* Tag (opens sub-view) */}
{colorOptions.length > 0 && ( {emailKeywords.length > 0 && (
<button <button
onClick={() => setMoreMenuSub('tag')} 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" 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" /> <Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span> <span className="flex-1">{t('tag')}</span>
{currentColors.length > 0 && ( {currentTagIds.length > 0 && (
<div className="flex -space-x-1 me-1"> <div className="flex -space-x-1 me-1">
{currentColors.slice(0, 3).map((c) => { {sortedTagIds.slice(0, 3).map((tagId) => (
const opt = colorOptions.find((o) => o.value === c); <span
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null; key={tagId}
})} className={cn("w-3 h-3 rounded-full border border-background", tagColor(tagId).dot)}
/>
))}
</div> </div>
)} )}
<ChevronRight className="w-4 h-4 text-muted-foreground" /> <ChevronRight className="w-4 h-4 text-muted-foreground" />
@@ -3545,35 +3478,13 @@ export function EmailViewer({
}; };
return renderMobileNodes(moveTree); return renderMobileNodes(moveTree);
})()} })()}
{moreMenuSub === 'tag' && colorOptions.length > 0 && ( {moreMenuSub === 'tag' && (
<> <TagPicker
{colorOptions.map((option) => { touch
const isActive = currentColors.includes(option.value); selectedIds={currentTagIds}
return ( onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
<button onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
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)} />
<TagOptionLabel candidates={option.candidates} />
{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>
)}
</>
)} )}
</div> </div>
</div> </div>
@@ -3631,28 +3542,19 @@ export function EmailViewer({
)} /> )} />
</button> </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 shrink-0", dotClass)}
title={tagName(tagId)}
/>
);
})}
</span>
)}
{isImportant && ( {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"> <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')} {t('important')}
</span> </span>
)} )}
</div> </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} />
))}
</div>
)}
</div> </div>
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */} {/* 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"> <div className="hidden sm:block flex-shrink-0 text-end">
+78
View File
@@ -0,0 +1,78 @@
"use client";
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,
className,
}: {
tagId: string;
variant: TagBadgeVariant;
className?: string;
}) {
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
ref={labelRef}
className={cn(
TAG_LOZENGE_CLASS,
"max-w-[12rem] truncate border",
color.fill,
color.border,
color.text,
className,
)}
title={name}
>
{shortenedName}
</span>
);
}
-29
View File
@@ -1,29 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { useShortenedText } from "@/hooks/use-shortened-text";
/**
* A tag name inside one of the tag pickers, shortened to what that picker has
* room for.
*
* The pickers differ in width - a narrow popover, a context submenu, a
* full-width mobile sheet - so each row measures itself instead of sharing one
* cap. `candidates` runs longest first (see `keywordRenderings`); the full name
* stays reachable through the tooltip.
*/
export function TagOptionLabel({
candidates,
className,
}: {
candidates: string[];
className?: string;
}) {
const [labelRef, shortenedLabel] = useShortenedText(candidates);
return (
<span ref={labelRef} className={cn("min-w-0 truncate", className)} title={candidates[0]}>
{shortenedLabel}
</span>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Check, Search, X } 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,
onClearAll,
touch = false,
}: {
selectedIds: string[];
onToggle: (tagId: string) => void;
onClearAll?: () => 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();
const showSearch = keywords.length >= SEARCH_THRESHOLD;
const matches = useMemo(
() =>
trimmedQuery
? keywords.filter((keyword) => tagName(keyword.id).toLowerCase().includes(trimmedQuery))
: [],
// `tagName` is rebuilt whenever the definitions or the nesting setting change.
[keywords, 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((keyword) => renderRow(keyword.id, tagName(keyword.id)))
) : (
<p className="px-3 py-2 text-sm text-muted-foreground">{t("tag_no_matches")}</p>
)
) : (
renderBranch(tree)
)}
</div>
{onClearAll && selectedIds.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<button
type="button"
onClick={onClearAll}
className={cn(rowClass, "text-muted-foreground")}
>
<X className={cn("flex-shrink-0", touch ? "w-4 h-4" : "w-3 h-3")} />
<span>{t("remove_tag")}</span>
</button>
</>
)}
</>
);
}
+12
View File
@@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-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 { interface ThreadEmailItemProps {
email: Email; email: Email;
@@ -35,6 +39,11 @@ export function ThreadEmailItem({
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; 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 sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
@@ -178,6 +187,9 @@ export function ThreadEmailItem({
{email.hasAttachment && ( {email.hasAttachment && (
<Paperclip className="w-3 h-3 text-muted-foreground" /> <Paperclip className="w-3 h-3 text-muted-foreground" />
)} )}
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</div> </div>
{/* Preview snippet */} {/* Preview snippet */}
+129 -109
View File
@@ -6,12 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SelectableAvatar } from "@/components/email/selectable-avatar"; 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 { 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 { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useAccountStore } from "@/stores/account-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 { 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 { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item"; import { ThreadEmailItem } from "./thread-email-item";
@@ -35,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 { interface ThreadListItemProps {
thread: ThreadGroup; thread: ThreadGroup;
isExpanded: boolean; isExpanded: boolean;
@@ -51,7 +75,7 @@ interface ThreadListItemProps {
onMarkAsRead?: (email: Email, read: boolean) => void; onMarkAsRead?: (email: Email, read: boolean) => void;
onDelete?: (email: Email) => void; onDelete?: (email: Email) => void;
onArchive?: (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; onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void;
} }
@@ -63,18 +87,18 @@ interface SingleEmailItemProps {
onDoubleClick?: () => void; onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean; showPreview: boolean;
colorTag: string | null; rowTint: string | null;
onToggleStar?: () => void; onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void; onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void; onDelete?: () => void;
onArchive?: () => void; onArchive?: () => void;
onSetColorTag?: (color: string | null) => void; onSetTag?: (tagId: string | null) => void;
onMarkAsSpam?: () => void; onMarkAsSpam?: () => void;
onUndoSpam?: () => void; onUndoSpam?: () => void;
} }
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>( 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 t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions'); const tBatch = useTranslations('email_list.batch_actions');
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
@@ -90,9 +114,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const { sortTagIds, tagColor } = useKeywordFormat();
const { tagName } = useKeywordFormat(); const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout); const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
@@ -112,14 +136,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
? formatDateTime(email.scheduledSendAt, timeFormat) ? formatDateTime(email.scheduledSendAt, timeFormat)
: null; : null;
// Resolve color tags using keyword definitions; unknown tags fall back to gray const tagIds = sortTagIds(getEmailTagIds(email.keywords));
const tagIds = getEmailColorTags(email.keywords); const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null));
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 { dragHandlers, isDragging } = useEmailDrag({ const { dragHandlers, isDragging } = useEmailDrag({
email, email,
@@ -174,16 +192,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
data-unread={isUnread ? 'true' : 'false'} data-unread={isUnread ? 'true' : 'false'}
className={cn( className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
resolvedColorTag ? resolvedColorTag : ( resolvedRowTint ? resolvedRowTint : (
selected selected
? "bg-accent" ? "bg-accent"
: "bg-background" : "bg-background"
), ),
selected && !resolvedColorTag && "shadow-sm", selected && !resolvedRowTint && "shadow-sm",
!resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", !resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", !resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110", resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedColorTag && "bg-accent/30", isUnread && !resolvedRowTint && "bg-accent/30",
isChecked && "ring-2 ring-primary/20 bg-accent/40", isChecked && "ring-2 ring-primary/20 bg-accent/40",
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30" isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
@@ -260,6 +278,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{sender?.name || sender?.email || 'Unknown'} {sender?.name || sender?.email || 'Unknown'}
</span> </span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm"> <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( <span className={cn(
'min-w-0 truncate', 'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90' isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
@@ -283,13 +308,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</> </>
)} )}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {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')}
title={tagName(kd.id)}
/>
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />} {showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? ( {scheduledSendLabel ? (
<span <span
@@ -328,6 +346,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}> )}>
{sender?.name || sender?.email || "Unknown"} {sender?.name || sender?.email || "Unknown"}
</span> </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"> <div className="flex items-center gap-1.5">
{isPinned && ( {isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" /> <Pin className="w-3.5 h-3.5 text-primary" />
@@ -353,15 +378,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <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"
)} title={tagName(kd.id)}>
<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!} />} {showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? ( {scheduledSendLabel ? (
<span <span
@@ -384,13 +400,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
</div> </div>
<div className={cn( <div className="mb-1 flex min-w-0 items-center gap-1.5">
"mb-1 line-clamp-1 text-sm", {tagPlacement === 'subject' && tagIds.length > 0 && (
isUnread <span className={TAG_GROUP_CLASS}>
? "font-semibold text-foreground" {tagIds.map((id) => (
: "font-normal text-foreground/90" <TagBadge key={id} tagId={id} variant={tagVariant} />
)}> ))}
{email.subject || "(no subject)"} </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> </div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( {showPreview && density !== 'extra-compact' && density !== 'compact' && (
@@ -412,12 +437,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{!email.isScheduled && ( {!email.isScheduled && (
<EmailHoverActions <EmailHoverActions
email={email} email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")} backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar} onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead} onMarkAsRead={onMarkAsRead}
onDelete={onDelete} onDelete={onDelete}
onArchive={onArchive} onArchive={onArchive}
onSetColorTag={onSetColorTag} onSetTag={onSetTag}
onMarkAsSpam={onMarkAsSpam} onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam} onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'} isInJunk={currentMailboxRole === 'junk'}
@@ -446,7 +471,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsRead, onMarkAsRead,
onDelete, onDelete,
onArchive, onArchive,
onSetColorTag, onSetTag,
onMarkAsSpam, onMarkAsSpam,
onUndoSpam, onUndoSpam,
}, ref) { }, ref) {
@@ -503,12 +528,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
); );
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel }; const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
const threadColor = getThreadColorTag(thread.emails); const { sortTagIds, tagColor } = useKeywordFormat();
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords); const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const { tagName } = useKeywordFormat(); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); // A collapsed row speaks for every message under it, so it carries their tags too.
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; const tagIds = sortTagIds(getThreadTagIds(thread.emails));
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null;
const isSelected = selectedEmailId === latestEmail.id || const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId); thread.emails.some(e => e.id === selectedEmailId);
@@ -525,12 +550,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined} onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
showPreview={showPreview} showPreview={showPreview}
colorTag={colorTag} rowTint={rowTint}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(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} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
/> />
@@ -604,16 +629,16 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{...threadLongPressHandlers} {...threadLongPressHandlers}
className={cn( className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden", "relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
colorTag ? colorTag : ( rowTint ? rowTint : (
isSelected isSelected
? "bg-accent" ? "bg-accent"
: "bg-background" : "bg-background"
), ),
isSelected && !colorTag && "shadow-sm", isSelected && !rowTint && "shadow-sm",
!colorTag && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm", !rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!colorTag && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm", !rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110", rowTint && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !colorTag && !isSelected && "bg-accent/30", hasUnread && !rowTint && !isSelected && "bg-accent/30",
isExpanded && "border-b border-border/50", isExpanded && "border-b border-border/50",
isChecked && "ring-2 ring-primary/20 bg-accent/40", isChecked && "ring-2 ring-primary/20 bg-accent/40",
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30" isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
@@ -713,22 +738,25 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/> />
)} )}
<span className={cn( <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' hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}> )}>
{displayNames.join(', ')} {displayNames.join(', ')}
</span> </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"> <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( <span className={cn(
'min-w-0 truncate', 'min-w-0 truncate',
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90' hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
@@ -752,12 +780,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</> </>
)} )}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {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')}
title={tagName(keywordDef.id)}
/>
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />} {showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? ( {scheduledSendLabel ? (
<span <span
@@ -796,17 +818,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}> )}>
{displayNames.join(", ")} {displayNames.join(", ")}
</span> </span>
<span <span className={TAG_GROUP_CLASS}>
className={cn( <ThreadCountPill
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium", count={emailCount}
hasUnread hasUnread={hasUnread}
? "bg-primary text-primary-foreground" title={t('messages_tooltip', { count: emailCount })}
: "bg-muted text-muted-foreground" />
)} {tagPlacement === 'sender' && tagIds.map((id) => (
title={t('messages_tooltip', { count: emailCount })} <TagBadge key={id} tagId={id} variant={tagVariant} />
> ))}
<MessageSquare className="w-3 h-3" />
{emailCount}
</span> </span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{hasPinned && ( {hasPinned && (
@@ -833,15 +853,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <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"
)} title={tagName(keywordDef.id)}>
<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!} />} {showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? ( {scheduledSendLabel ? (
<span <span
@@ -864,13 +875,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div> </div>
</div> </div>
<div className={cn( <div className="mb-1 flex min-w-0 items-center gap-1.5">
"mb-1 line-clamp-1 text-sm", {tagPlacement === 'subject' && tagIds.length > 0 && (
hasUnread <span className={TAG_GROUP_CLASS}>
? "font-semibold text-foreground" {tagIds.map((id) => (
: "font-normal text-foreground/90" <TagBadge key={id} tagId={id} variant={tagVariant} />
)}> ))}
{latestEmail.subject || "(no subject)"} </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> </div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( {showPreview && density !== 'extra-compact' && density !== 'compact' && (
@@ -892,12 +912,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{!latestEmail.isScheduled && ( {!latestEmail.isScheduled && (
<EmailHoverActions <EmailHoverActions
email={latestEmail} 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} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(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} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'} isInJunk={currentMailboxRole === 'junk'}
+5 -24
View File
@@ -61,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useVacationStore } from "@/stores/vacation-store"; import { useVacationStore } from "@/stores/vacation-store";
import { useSettingsStore, KEYWORD_PALETTE, getKeywordVisibility } from "@/stores/settings-store"; import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
@@ -557,22 +557,6 @@ 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({ function ShowAllTagsRow({
hiddenCount, hiddenCount,
showAll, showAll,
@@ -617,8 +601,8 @@ function TagItem({
colorful: boolean; colorful: boolean;
}) { }) {
const t = useTranslations('notifications'); const t = useTranslations('notifications');
const { tagNameCandidates } = useKeywordFormat(); const { tagNameCandidates, tagColor } = useKeywordFormat();
const palette = KEYWORD_PALETTE[node.color]; const palette = tagColor(node.id);
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = expandedTags.has(node.id); const isExpanded = expandedTags.has(node.id);
const isSelected = selectedKeyword === node.id; const isSelected = selectedKeyword === node.id;
@@ -644,12 +628,9 @@ function TagItem({
}); });
const tagIcon = colorful ? ( const tagIcon = colorful ? (
<Tag <Tag className={cn("w-4 h-4 flex-shrink-0", palette.icon)} fill="currentColor" />
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[node.color] || "text-muted-foreground")}
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 ( return (
+4 -5
View File
@@ -236,7 +236,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
} }
}, [client, markAsRead]); }, [client, markAsRead]);
const handleSetColorTag = useCallback((emailId: string, color: string | null) => { const handleSetTag = useCallback((emailId: string, tagId: string | null) => {
if (!email || email.id !== emailId) return; if (!email || email.id !== emailId) return;
// Drop existing color keywords, optionally add the new one. Matches the // Drop existing color keywords, optionally add the new one. Matches the
// mail page's local optimistic update. // mail page's local optimistic update.
@@ -244,9 +244,8 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
for (const kw of settingsKeywords) { for (const kw of settingsKeywords) {
delete keywords[`$label:${kw.id}`]; delete keywords[`$label:${kw.id}`];
} }
if (color) { if (tagId) {
const def = settingsKeywords.find((k) => k.color === color); keywords[`$label:${tagId}`] = true;
if (def) keywords[`$label:${def.id}`] = true;
} }
setEmailKeywordsLocal(emailId, keywords); setEmailKeywordsLocal(emailId, keywords);
setEmail({ ...email, keywords }); setEmail({ ...email, keywords });
@@ -333,7 +332,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
onArchive={handleArchive} onArchive={handleArchive}
onToggleStar={handleToggleStar} onToggleStar={handleToggleStar}
onMarkAsRead={handleMarkAsRead} onMarkAsRead={handleMarkAsRead}
onSetColorTag={handleSetColorTag} onSetTag={handleSetTag}
onDownloadAttachment={handleDownloadAttachment} onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply} onQuickReply={handleQuickReply}
onEditDraft={handleEditDraft} onEditDraft={handleEditDraft}
+24 -27
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { import {
useSettingsStore, useSettingsStore,
KEYWORD_PALETTE, KEYWORD_PALETTE,
KEYWORD_PALETTE_ROWS,
getKeywordVisibility, getKeywordVisibility,
type KeywordDefinition, type KeywordDefinition,
type KeywordVisibility, type KeywordVisibility,
@@ -25,11 +26,11 @@ import {
type KeywordNode, type KeywordNode,
MAX_KEYWORD_ID_LENGTH, MAX_KEYWORD_ID_LENGTH,
} from "@/lib/keyword-nesting"; } from "@/lib/keyword-nesting";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; import { formatKeyword, keywordRenderings } from "@/lib/keyword-format";
import { useShortenedText } from "@/hooks/use-shortened-text"; 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({ function KeywordColorPicker({
value, value,
onChange, onChange,
@@ -38,19 +39,23 @@ function KeywordColorPicker({
onChange: (color: string) => void; onChange: (color: string) => void;
}) { }) {
return ( return (
<div className="flex flex-wrap gap-1.5"> <div className="space-y-1.5">
{PALETTE_KEYS.map((colorKey) => ( {KEYWORD_PALETTE_ROWS.map((row, index) => (
<button <div key={index} className="flex flex-wrap gap-1.5">
key={colorKey} {row.map((colorKey) => (
type="button" <button
onClick={() => onChange(colorKey)} key={colorKey}
className={cn( type="button"
"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", onClick={() => onChange(colorKey)}
KEYWORD_PALETTE[colorKey].dot, className={cn(
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground" "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,
aria-label={colorKey} value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
/> )}
aria-label={colorKey}
/>
))}
</div>
))} ))}
</div> </div>
); );
@@ -84,10 +89,7 @@ function KeywordRow({
isDragging: boolean; isDragging: boolean;
}) { }) {
const t = useTranslations("settings.keywords"); const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color];
const hasChildren = hasChildKeywords(keyword.id, keywords); const hasChildren = hasChildKeywords(keyword.id, keywords);
const nameCandidates = keywordRenderings(formatKeywordLabels(keyword.id, keywords, nestedTags));
const [nameRef, shortenedName] = useShortenedText(nameCandidates);
// Measured with the prefix attached, since that is what occupies the column. // Measured with the prefix attached, since that is what occupies the column.
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
.map((rendering) => KEYWORD_PREFIX + rendering); .map((rendering) => KEYWORD_PREFIX + rendering);
@@ -112,14 +114,9 @@ function KeywordRow({
)} )}
> >
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" /> <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")} /> <div className="flex min-w-0 flex-1">
<span <TagBadge tagId={keyword.id} variant="badge" className="text-xs" />
ref={nameRef} </div>
className="flex-1 min-w-0 text-sm font-medium truncate"
title={formatKeyword(keyword.id, keywords, nestedTags)}
>
{shortenedName}
</span>
<span <span
ref={keywordRef} ref={keywordRef}
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono" className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
@@ -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']);
});
});
});
+40 -6
View File
@@ -1,18 +1,22 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { useSettingsStore } from "@/stores/settings-store"; import {
useSettingsStore,
KEYWORD_PALETTE,
FALLBACK_KEYWORD_COLOR,
type KeywordColor,
} from "@/stores/settings-store";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
/** /**
* Names tags for the screen, bound to the user's tag settings. * 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 * 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 * call site means no caller can forget the setting and render a nested name to
* someone who never asked for nesting. Subscribing to it also keeps names in * someone who never asked for nesting. Subscribing to them also keeps tags in
* step the moment it is toggled: reading it straight from the store inside the * step the moment either changes: reading the store inside the formatter would
* formatter would leave every list showing stale names until something else * leave every list stale until something else happened to re-render it.
* happened to re-render them.
*/ */
export function useKeywordFormat() { export function useKeywordFormat() {
const keywords = useSettingsStore((state) => state.emailKeywords); const keywords = useSettingsStore((state) => state.emailKeywords);
@@ -22,8 +26,38 @@ export function useKeywordFormat() {
() => ({ () => ({
/** The tag's display name. */ /** The tag's display name. */
tagName: (id: string) => formatKeyword(id, keywords, nested), tagName: (id: string) => formatKeyword(id, keywords, nested),
/** Its progressively shorter forms, longest first, for `useShortenedText`. */ /** Its progressively shorter forms, longest first, for `useShortenedText`. */
tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)), 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], [keywords, nested],
); );
+70
View File
@@ -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]);
}
+49 -17
View File
@@ -4,8 +4,9 @@ import {
sortThreadGroups, sortThreadGroups,
getThreadParticipants, getThreadParticipants,
mergeThreadEmails, mergeThreadEmails,
getEmailColorTag, getEmailTagId,
getThreadColorTag, getThreadTagId,
getThreadTagIds,
} from '../thread-utils'; } from '../thread-utils';
import type { Email, ThreadGroup } from '../jmap/types'; import type { Email, ThreadGroup } from '../jmap/types';
@@ -245,47 +246,47 @@ describe('mergeThreadEmails', () => {
}); });
}); });
describe('getEmailColorTag', () => { describe('getEmailTagId', () => {
it('returns label from $label: keyword', () => { it('returns label from $label: keyword', () => {
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red'); expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
}); });
it('returns label from legacy $color: keyword', () => { it('returns label from legacy $color: keyword', () => {
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red'); expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
}); });
it('returns null when no color keyword', () => { it('returns null when no color keyword', () => {
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull(); expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
}); });
it('returns null for undefined keywords', () => { it('returns null for undefined keywords', () => {
expect(getEmailColorTag(undefined)).toBeNull(); expect(getEmailTagId(undefined)).toBeNull();
}); });
it('ignores keywords set to false', () => { it('ignores keywords set to false', () => {
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull(); expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
}); });
it('prefers $label: over $color: when both exist', () => { it('prefers $label: over $color: when both exist', () => {
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue'); expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
}); });
it('handles custom keyword ids', () => { it('handles custom keyword ids', () => {
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag'); expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
}); });
it('returns null for empty keywords object', () => { it('returns null for empty keywords object', () => {
expect(getEmailColorTag({})).toBeNull(); expect(getEmailTagId({})).toBeNull();
}); });
}); });
describe('getThreadColorTag', () => { describe('getThreadTagId', () => {
it('returns first color found across thread emails', () => { it('returns first color found across thread emails', () => {
const emails = [ const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': 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', () => { it('returns null when no emails have color tags', () => {
@@ -293,7 +294,7 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { $flagged: true } }), makeEmail({ id: 'e2', keywords: { $flagged: true } }),
]; ];
expect(getThreadColorTag(emails)).toBeNull(); expect(getThreadTagId(emails)).toBeNull();
}); });
it('returns first tag from earliest tagged email', () => { it('returns first tag from earliest tagged email', () => {
@@ -301,7 +302,7 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { '$label:red': true } }), makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
makeEmail({ id: 'e2', keywords: { '$label:blue': 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', () => { it('returns legacy tag from thread emails', () => {
@@ -309,10 +310,41 @@ describe('getThreadColorTag', () => {
makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$color:green': 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', () => { 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([]);
}); });
}); });
+26 -9
View File
@@ -168,10 +168,10 @@ export const KEYWORD_PREFIX = "$label:";
export const KEYWORD_PREFIX_LEGACY = "$color:"; 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. * Reads both the current $label: prefix and the legacy $color: prefix.
*/ */
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] { export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return []; if (!keywords) return [];
const tags: string[] = []; const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
@@ -187,22 +187,39 @@ export function getEmailColorTags(keywords: Record<string, boolean> | undefined)
} }
/** /**
* 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. * 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 { export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
const tags = getEmailColorTags(keywords); const tags = getEmailTagIds(keywords);
return tags.length > 0 ? tags[0] : null; 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) { for (const email of emails) {
const color = getEmailColorTag(email.keywords); const color = getEmailTagId(email.keywords);
if (color) return color; if (color) return color;
} }
return null; 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];
}
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "عرض جهة الاتصال", "view_contact": "عرض جهة الاتصال",
"message_details": "تفاصيل الرسالة", "message_details": "تفاصيل الرسالة",
"more_reply_options": "خيارات رد إضافية", "more_reply_options": "خيارات رد إضافية",
"set_color": "تعيين وسم", "set_tag": "تعيين وسم",
"tag": "وسم", "tag": "وسم",
"more_actions": "المزيد من الإجراءات", "more_actions": "المزيد من الإجراءات",
"previous": "السابق", "previous": "السابق",
"next": "التالي", "next": "التالي",
"move_to": "نقل إلى...", "move_to": "نقل إلى...",
"remove_color": "إزالة الوسم", "remove_tag": "إزالة الوسم",
"more_count": "+{count} أخرى", "more_count": "+{count} أخرى",
"characters_count": "{count} حرفًا", "characters_count": "{count} حرفًا",
"quick_reply_placeholder": "اكتب ردًا سريعًا...", "quick_reply_placeholder": "اكتب ردًا سريعًا...",
@@ -425,17 +425,6 @@
"message_id": "معرّف الرسالة", "message_id": "معرّف الرسالة",
"list_info": "معلومات القائمة" "list_info": "معلومات القائمة"
}, },
"color_tag": {
"title": "وسم لوني",
"red": "أحمر",
"orange": "برتقالي",
"yellow": "أصفر",
"green": "أخضر",
"blue": "أزرق",
"purple": "بنفسجي",
"pink": "وردي",
"none": "بلا"
},
"tooltips": { "tooltips": {
"reply": "رد (r)", "reply": "رد (r)",
"reply_all": "الرد على الجميع (a)", "reply_all": "الرد على الجميع (a)",
@@ -2033,8 +2022,7 @@
"delete": "حذف", "delete": "حذف",
"mark_as_spam": "الإبلاغ عن بريد مزعج", "mark_as_spam": "الإبلاغ عن بريد مزعج",
"not_spam": "ليس مزعجًا", "not_spam": "ليس مزعجًا",
"color_tag": "وسم", "tag": "وسم",
"remove_color": "إزالة الوسم",
"items_selected": "{count} رسالة محددة", "items_selected": "{count} رسالة محددة",
"edit_draft": "تعديل المسودة", "edit_draft": "تعديل المسودة",
"cancel_scheduled_send": "إلغاء الإرسال", "cancel_scheduled_send": "إلغاء الإرسال",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Mostra el contacte", "view_contact": "Mostra el contacte",
"message_details": "Detalls del missatge", "message_details": "Detalls del missatge",
"more_reply_options": "Més opcions de resposta", "more_reply_options": "Més opcions de resposta",
"set_color": "Estableix l'etiqueta", "set_tag": "Estableix l'etiqueta",
"tag": "Etiqueta", "tag": "Etiqueta",
"more_actions": "Més accions", "more_actions": "Més accions",
"previous": "Anterior", "previous": "Anterior",
"next": "Següent", "next": "Següent",
"move_to": "Mou a...", "move_to": "Mou a...",
"remove_color": "Elimina l'etiqueta", "remove_tag": "Elimina l'etiqueta",
"more_count": "+{count} més", "more_count": "+{count} més",
"characters_count": "{count} caràcters", "characters_count": "{count} caràcters",
"quick_reply_placeholder": "Escriviu una resposta ràpida...", "quick_reply_placeholder": "Escriviu una resposta ràpida...",
@@ -425,17 +425,6 @@
"message_id": "ID del missatge", "message_id": "ID del missatge",
"list_info": "Informació de la llista" "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": { "tooltips": {
"reply": "Respon (r)", "reply": "Respon (r)",
"reply_all": "Respon a tots (a)", "reply_all": "Respon a tots (a)",
@@ -2001,8 +1990,7 @@
"delete": "Suprimeix", "delete": "Suprimeix",
"mark_as_spam": "Denuncia com a brossa", "mark_as_spam": "Denuncia com a brossa",
"not_spam": "No és brossa", "not_spam": "No és brossa",
"color_tag": "Etiqueta", "tag": "Etiqueta",
"remove_color": "Elimina l'etiqueta",
"items_selected": "{count} correus seleccionats", "items_selected": "{count} correus seleccionats",
"edit_draft": "Edita l'esborrany", "edit_draft": "Edita l'esborrany",
"cancel_scheduled_send": "Cancel·la l'enviament", "cancel_scheduled_send": "Cancel·la l'enviament",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Zobrazit kontakt", "view_contact": "Zobrazit kontakt",
"message_details": "Podrobnosti zprávy", "message_details": "Podrobnosti zprávy",
"more_reply_options": "Další možnosti odpovědi", "more_reply_options": "Další možnosti odpovědi",
"set_color": "Nastavit štítek", "set_tag": "Nastavit štítek",
"tag": "Štítek", "tag": "Štítek",
"more_actions": "Další akce", "more_actions": "Další akce",
"previous": "Předchozí", "previous": "Předchozí",
"next": "Další", "next": "Další",
"move_to": "Přesunout do...", "move_to": "Přesunout do...",
"remove_color": "Odebrat štítek", "remove_tag": "Odebrat štítek",
"more_count": "+{count} dalších", "more_count": "+{count} dalších",
"characters_count": "{count} znaků", "characters_count": "{count} znaků",
"quick_reply_placeholder": "Napsat rychlou odpověď...", "quick_reply_placeholder": "Napsat rychlou odpověď...",
@@ -400,17 +400,6 @@
"message_id": "ID zprávy", "message_id": "ID zprávy",
"list_info": "Informace o konferenci" "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": { "tooltips": {
"reply": "Odpovědět (r)", "reply": "Odpovědět (r)",
"reply_all": "Odpovědět všem (a)", "reply_all": "Odpovědět všem (a)",
@@ -2033,8 +2022,7 @@
"delete": "Odstranit", "delete": "Odstranit",
"mark_as_spam": "Nahlásit spam", "mark_as_spam": "Nahlásit spam",
"not_spam": "Není spam", "not_spam": "Není spam",
"color_tag": "Štítek", "tag": "Štítek",
"remove_color": "Odebrat štítek",
"items_selected": "{count} vybraných zpráv", "items_selected": "{count} vybraných zpráv",
"edit_draft": "Upravit koncept", "edit_draft": "Upravit koncept",
"cancel_scheduled_send": "Zrušit odeslání", "cancel_scheduled_send": "Zrušit odeslání",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Vis kontakt", "view_contact": "Vis kontakt",
"message_details": "Beskeddetaljer", "message_details": "Beskeddetaljer",
"more_reply_options": "Flere svar-muligheder", "more_reply_options": "Flere svar-muligheder",
"set_color": "Sæt tag", "set_tag": "Sæt tag",
"tag": "Tag", "tag": "Tag",
"more_actions": "Flere handlinger", "more_actions": "Flere handlinger",
"previous": "Forrige", "previous": "Forrige",
"next": "Næste", "next": "Næste",
"move_to": "Flyt til...", "move_to": "Flyt til...",
"remove_color": "Fjern tag", "remove_tag": "Fjern tag",
"more_count": "+{count} mere", "more_count": "+{count} mere",
"characters_count": "{count} tegn", "characters_count": "{count} tegn",
"quick_reply_placeholder": "Skriv et hurtigt svar...", "quick_reply_placeholder": "Skriv et hurtigt svar...",
@@ -425,17 +425,6 @@
"message_id": "Besked-ID", "message_id": "Besked-ID",
"list_info": "Listeinformation" "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": { "tooltips": {
"reply": "Svar (r)", "reply": "Svar (r)",
"reply_all": "Svar alle (a)", "reply_all": "Svar alle (a)",
@@ -2033,8 +2022,7 @@
"delete": "Slet", "delete": "Slet",
"mark_as_spam": "Rapportér spam", "mark_as_spam": "Rapportér spam",
"not_spam": "Ikke spam", "not_spam": "Ikke spam",
"color_tag": "Tag", "tag": "Tag",
"remove_color": "Fjern tag",
"items_selected": "{count} e-mails valgt", "items_selected": "{count} e-mails valgt",
"edit_draft": "Redigér kladde", "edit_draft": "Redigér kladde",
"cancel_scheduled_send": "Annuller afsendelse", "cancel_scheduled_send": "Annuller afsendelse",
+3 -15
View File
@@ -325,11 +325,11 @@
"view_contact": "Kontakt anzeigen", "view_contact": "Kontakt anzeigen",
"message_details": "Nachrichtendetails", "message_details": "Nachrichtendetails",
"more_reply_options": "Weitere Antwortoptionen", "more_reply_options": "Weitere Antwortoptionen",
"set_color": "Label setzen", "set_tag": "Label setzen",
"tag": "Label", "tag": "Label",
"more_actions": "Weitere Aktionen", "more_actions": "Weitere Aktionen",
"move_to": "Verschieben nach...", "move_to": "Verschieben nach...",
"remove_color": "Label entfernen", "remove_tag": "Label entfernen",
"more_count": "+{count} weitere", "more_count": "+{count} weitere",
"characters_count": "{count} Zeichen", "characters_count": "{count} Zeichen",
"quick_reply_placeholder": "Eine kurze Antwort schreiben...", "quick_reply_placeholder": "Eine kurze Antwort schreiben...",
@@ -398,17 +398,6 @@
"message_id": "Nachrichten-ID", "message_id": "Nachrichten-ID",
"list_info": "Listeninformationen" "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": { "tooltips": {
"reply": "Antworten", "reply": "Antworten",
"reply_all": "Allen antworten (a)", "reply_all": "Allen antworten (a)",
@@ -2033,8 +2022,7 @@
"delete": "Löschen", "delete": "Löschen",
"mark_as_spam": "Spam melden", "mark_as_spam": "Spam melden",
"not_spam": "Kein Spam", "not_spam": "Kein Spam",
"color_tag": "Label", "tag": "Label",
"remove_color": "Label entfernen",
"items_selected": "{count} E-Mails ausgewählt", "items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten", "edit_draft": "Entwurf bearbeiten",
"cancel_scheduled_send": "Senden abbrechen", "cancel_scheduled_send": "Senden abbrechen",
+6 -16
View File
@@ -327,13 +327,15 @@
"view_contact": "View contact", "view_contact": "View contact",
"message_details": "Message Details", "message_details": "Message Details",
"more_reply_options": "More reply options", "more_reply_options": "More reply options",
"set_color": "Set tag", "set_tag": "Set tag",
"tag": "Tag", "tag": "Tag",
"more_actions": "More actions", "more_actions": "More actions",
"previous": "Prev", "previous": "Prev",
"next": "Next", "next": "Next",
"move_to": "Move to...", "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", "more_count": "+{count} more",
"characters_count": "{count} characters", "characters_count": "{count} characters",
"quick_reply_placeholder": "Write a quick reply...", "quick_reply_placeholder": "Write a quick reply...",
@@ -427,17 +429,6 @@
"message_id": "Message ID", "message_id": "Message ID",
"list_info": "List Information" "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": { "tooltips": {
"reply": "Reply (r)", "reply": "Reply (r)",
"reply_all": "Reply All (a)", "reply_all": "Reply All (a)",
@@ -1019,7 +1010,7 @@
}, },
"keywords": { "keywords": {
"title": "Email Tags", "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", "add_keyword": "Add Tag",
"label_field": "Display Name", "label_field": "Display Name",
"label_placeholder": "e.g. Work, Personal, Urgent", "label_placeholder": "e.g. Work, Personal, Urgent",
@@ -2050,8 +2041,7 @@
"delete": "Delete", "delete": "Delete",
"mark_as_spam": "Report spam", "mark_as_spam": "Report spam",
"not_spam": "Not spam", "not_spam": "Not spam",
"color_tag": "Tag", "tag": "Tag",
"remove_color": "Remove tag",
"items_selected": "{count} emails selected", "items_selected": "{count} emails selected",
"edit_draft": "Edit Draft", "edit_draft": "Edit Draft",
"cancel_scheduled_send": "Cancel send", "cancel_scheduled_send": "Cancel send",
+3 -15
View File
@@ -325,11 +325,11 @@
"view_contact": "Ver contacto", "view_contact": "Ver contacto",
"message_details": "Detalles del Mensaje", "message_details": "Detalles del Mensaje",
"more_reply_options": "Más opciones de respuesta", "more_reply_options": "Más opciones de respuesta",
"set_color": "Establecer etiqueta", "set_tag": "Establecer etiqueta",
"tag": "Etiqueta", "tag": "Etiqueta",
"more_actions": "Más acciones", "more_actions": "Más acciones",
"move_to": "Mover a...", "move_to": "Mover a...",
"remove_color": "Eliminar etiqueta", "remove_tag": "Eliminar etiqueta",
"more_count": "+{count} más", "more_count": "+{count} más",
"characters_count": "{count} caracteres", "characters_count": "{count} caracteres",
"quick_reply_placeholder": "Escriba una respuesta rápida...", "quick_reply_placeholder": "Escriba una respuesta rápida...",
@@ -398,17 +398,6 @@
"message_id": "ID del Mensaje", "message_id": "ID del Mensaje",
"list_info": "Información de Lista" "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": { "tooltips": {
"reply": "Responder", "reply": "Responder",
"reply_all": "Responder a todos (a)", "reply_all": "Responder a todos (a)",
@@ -2033,8 +2022,7 @@
"delete": "Eliminar", "delete": "Eliminar",
"mark_as_spam": "Reportar spam", "mark_as_spam": "Reportar spam",
"not_spam": "No es spam", "not_spam": "No es spam",
"color_tag": "Etiqueta", "tag": "Etiqueta",
"remove_color": "Eliminar etiqueta",
"items_selected": "{count} correos seleccionados", "items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador", "edit_draft": "Editar borrador",
"cancel_scheduled_send": "Cancelar envío", "cancel_scheduled_send": "Cancelar envío",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "مشاهده مخاطب", "view_contact": "مشاهده مخاطب",
"message_details": "جزئیات پیام", "message_details": "جزئیات پیام",
"more_reply_options": "گزینه‌های بیشتر پاسخ", "more_reply_options": "گزینه‌های بیشتر پاسخ",
"set_color": "تنظیم برچسب", "set_tag": "تنظیم برچسب",
"tag": "برچسب", "tag": "برچسب",
"more_actions": "عملیات بیشتر", "more_actions": "عملیات بیشتر",
"previous": "قبلی", "previous": "قبلی",
"next": "بعدی", "next": "بعدی",
"move_to": "انتقال به...", "move_to": "انتقال به...",
"remove_color": "حذف برچسب", "remove_tag": "حذف برچسب",
"more_count": "+{count} بیشتر", "more_count": "+{count} بیشتر",
"characters_count": "{count} کاراکتر", "characters_count": "{count} کاراکتر",
"quick_reply_placeholder": "پاسخ سریع بنویسید...", "quick_reply_placeholder": "پاسخ سریع بنویسید...",
@@ -425,17 +425,6 @@
"message_id": "شناسه پیام", "message_id": "شناسه پیام",
"list_info": "اطلاعات لیست" "list_info": "اطلاعات لیست"
}, },
"color_tag": {
"title": "برچسب رنگی",
"red": "قرمز",
"orange": "نارنجی",
"yellow": "زرد",
"green": "سبز",
"blue": "آبی",
"purple": "بنفش",
"pink": "صورتی",
"none": "هیچکدام"
},
"tooltips": { "tooltips": {
"reply": "پاسخ (r)", "reply": "پاسخ (r)",
"reply_all": "پاسخ به همه (a)", "reply_all": "پاسخ به همه (a)",
@@ -2033,8 +2022,7 @@
"delete": "حذف", "delete": "حذف",
"mark_as_spam": "گزارش هرزنامه", "mark_as_spam": "گزارش هرزنامه",
"not_spam": "هرزنامه نیست", "not_spam": "هرزنامه نیست",
"color_tag": "برچسب", "tag": "برچسب",
"remove_color": "حذف برچسب",
"items_selected": "{count} ایمیل انتخاب شده", "items_selected": "{count} ایمیل انتخاب شده",
"edit_draft": "ویرایش پیش‌نویس", "edit_draft": "ویرایش پیش‌نویس",
"cancel_scheduled_send": "لغو ارسال", "cancel_scheduled_send": "لغو ارسال",
+3 -15
View File
@@ -325,11 +325,11 @@
"view_contact": "Voir le contact", "view_contact": "Voir le contact",
"message_details": "Détails du message", "message_details": "Détails du message",
"more_reply_options": "Plus d'options de réponse", "more_reply_options": "Plus d'options de réponse",
"set_color": "Définir l'étiquette", "set_tag": "Définir l'étiquette",
"tag": "Étiquette", "tag": "Étiquette",
"more_actions": "Plus d'actions", "more_actions": "Plus d'actions",
"move_to": "Déplacer vers...", "move_to": "Déplacer vers...",
"remove_color": "Retirer l'étiquette", "remove_tag": "Retirer l'étiquette",
"more_count": "+{count} de plus", "more_count": "+{count} de plus",
"characters_count": "{count} caractères", "characters_count": "{count} caractères",
"quick_reply_placeholder": "Écrivez une réponse rapide...", "quick_reply_placeholder": "Écrivez une réponse rapide...",
@@ -398,17 +398,6 @@
"message_id": "ID du message", "message_id": "ID du message",
"list_info": "Information de liste" "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": { "tooltips": {
"reply": "Répondre", "reply": "Répondre",
"reply_all": "Répondre à tous (a)", "reply_all": "Répondre à tous (a)",
@@ -2033,8 +2022,7 @@
"delete": "Supprimer", "delete": "Supprimer",
"mark_as_spam": "Signaler comme spam", "mark_as_spam": "Signaler comme spam",
"not_spam": "Pas un spam", "not_spam": "Pas un spam",
"color_tag": "Étiquette", "tag": "Étiquette",
"remove_color": "Supprimer l'étiquette",
"items_selected": "{count} emails sélectionnés", "items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon", "edit_draft": "Modifier le brouillon",
"cancel_scheduled_send": "Annuler lenvoi", "cancel_scheduled_send": "Annuler lenvoi",
+3 -15
View File
@@ -272,13 +272,13 @@
"view_contact": "הצג איש קשר", "view_contact": "הצג איש קשר",
"message_details": "פרטי הודעה", "message_details": "פרטי הודעה",
"more_reply_options": "אפשרויות תשובה נוספות", "more_reply_options": "אפשרויות תשובה נוספות",
"set_color": "הגדר תג", "set_tag": "הגדר תג",
"tag": "תג", "tag": "תג",
"more_actions": "עוד פעולות", "more_actions": "עוד פעולות",
"previous": "הקודם", "previous": "הקודם",
"next": "הבא", "next": "הבא",
"move_to": "העבר ל...", "move_to": "העבר ל...",
"remove_color": "הסר תג", "remove_tag": "הסר תג",
"more_count": "+{count}נוספים", "more_count": "+{count}נוספים",
"characters_count": "{count} תווים", "characters_count": "{count} תווים",
"quick_reply_placeholder": "תשובה מהירה", "quick_reply_placeholder": "תשובה מהירה",
@@ -347,17 +347,6 @@
"message_id": "מזהה הודעה", "message_id": "מזהה הודעה",
"list_info": "רשימת מידע" "list_info": "רשימת מידע"
}, },
"color_tag": {
"title": "תג צבע",
"red": "אדום",
"orange": "כתום",
"yellow": "צהוב",
"green": "ירוק",
"blue": "כחול",
"purple": "סגול",
"pink": "ורוד",
"none": "אין"
},
"tooltips": { "tooltips": {
"reply": "תשובה (ר)", "reply": "תשובה (ר)",
"reply_all": "השב לכולם (א)", "reply_all": "השב לכולם (א)",
@@ -1999,8 +1988,7 @@
"delete": "לִמְחוֹק", "delete": "לִמְחוֹק",
"mark_as_spam": "דווח על ספאם", "mark_as_spam": "דווח על ספאם",
"not_spam": "לא ספאם", "not_spam": "לא ספאם",
"color_tag": "תווית", "tag": "תווית",
"remove_color": "הסר תווית",
"items_selected": "נבחרו הודעות דוא\"ל מסוג{count}", "items_selected": "נבחרו הודעות דוא\"ל מסוג{count}",
"edit_draft": "ערוך טיוטה", "edit_draft": "ערוך טיוטה",
"cancel_scheduled_send": "ביטול שליחה", "cancel_scheduled_send": "ביטול שליחה",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Névjegy megtekintése", "view_contact": "Névjegy megtekintése",
"message_details": "Üzenet részletei", "message_details": "Üzenet részletei",
"more_reply_options": "További válasz opciók", "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", "tag": "Címke",
"more_actions": "További műveletek", "more_actions": "További műveletek",
"previous": "Előző", "previous": "Előző",
"next": "Következő", "next": "Következő",
"move_to": "Áthelyezés ide...", "move_to": "Áthelyezés ide...",
"remove_color": "Címke eltávolítása", "remove_tag": "Címke eltávolítása",
"more_count": "+{count} további", "more_count": "+{count} további",
"characters_count": "{count} karakter", "characters_count": "{count} karakter",
"quick_reply_placeholder": "Gyors válasz írása...", "quick_reply_placeholder": "Gyors válasz írása...",
@@ -425,17 +425,6 @@
"message_id": "Üzenet azonosító", "message_id": "Üzenet azonosító",
"list_info": "Lista információk" "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": { "tooltips": {
"reply": "Válasz (r)", "reply": "Válasz (r)",
"reply_all": "Válasz mindenkinek (a)", "reply_all": "Válasz mindenkinek (a)",
@@ -2033,8 +2022,7 @@
"delete": "Törlés", "delete": "Törlés",
"mark_as_spam": "Spam jelentése", "mark_as_spam": "Spam jelentése",
"not_spam": "Nem spam", "not_spam": "Nem spam",
"color_tag": "Címke", "tag": "Címke",
"remove_color": "Címke eltávolítása",
"items_selected": "{count} e-mail kijelölve", "items_selected": "{count} e-mail kijelölve",
"edit_draft": "Piszkozat szerkesztése", "edit_draft": "Piszkozat szerkesztése",
"cancel_scheduled_send": "Küldés megszakítása", "cancel_scheduled_send": "Küldés megszakítása",
+3 -15
View File
@@ -325,11 +325,11 @@
"view_contact": "Visualizza contatto", "view_contact": "Visualizza contatto",
"message_details": "Dettagli del messaggio", "message_details": "Dettagli del messaggio",
"more_reply_options": "Più opzioni di risposta", "more_reply_options": "Più opzioni di risposta",
"set_color": "Imposta etichetta", "set_tag": "Imposta etichetta",
"tag": "Etichetta", "tag": "Etichetta",
"more_actions": "Altre azioni", "more_actions": "Altre azioni",
"move_to": "Sposta in...", "move_to": "Sposta in...",
"remove_color": "Rimuovi etichetta", "remove_tag": "Rimuovi etichetta",
"more_count": "+{count} altri", "more_count": "+{count} altri",
"characters_count": "{count} caratteri", "characters_count": "{count} caratteri",
"quick_reply_placeholder": "Scrivi una risposta veloce...", "quick_reply_placeholder": "Scrivi una risposta veloce...",
@@ -398,17 +398,6 @@
"message_id": "ID messaggio", "message_id": "ID messaggio",
"list_info": "Informazioni lista" "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": { "tooltips": {
"reply": "Rispondi", "reply": "Rispondi",
"reply_all": "Rispondi a tutti (a)", "reply_all": "Rispondi a tutti (a)",
@@ -2033,8 +2022,7 @@
"delete": "Elimina", "delete": "Elimina",
"mark_as_spam": "Segnala come spam", "mark_as_spam": "Segnala come spam",
"not_spam": "Non spam", "not_spam": "Non spam",
"color_tag": "Etichetta", "tag": "Etichetta",
"remove_color": "Rimuovi etichetta",
"items_selected": "{count} messaggi selezionati", "items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza", "edit_draft": "Modifica bozza",
"cancel_scheduled_send": "Annulla invio", "cancel_scheduled_send": "Annulla invio",
+3 -15
View File
@@ -325,11 +325,11 @@
"view_contact": "連絡先を表示", "view_contact": "連絡先を表示",
"message_details": "メッセージの詳細", "message_details": "メッセージの詳細",
"more_reply_options": "その他の返信オプション", "more_reply_options": "その他の返信オプション",
"set_color": "ラベルを設定", "set_tag": "ラベルを設定",
"tag": "ラベル", "tag": "ラベル",
"more_actions": "その他の操作", "more_actions": "その他の操作",
"move_to": "移動...", "move_to": "移動...",
"remove_color": "ラベルを削除", "remove_tag": "ラベルを削除",
"more_count": "他{count}件", "more_count": "他{count}件",
"characters_count": "{count}文字", "characters_count": "{count}文字",
"quick_reply_placeholder": "クイック返信を入力...", "quick_reply_placeholder": "クイック返信を入力...",
@@ -398,17 +398,6 @@
"message_id": "メッセージID", "message_id": "メッセージID",
"list_info": "リスト情報" "list_info": "リスト情報"
}, },
"color_tag": {
"title": "カラータグ",
"red": "赤",
"orange": "オレンジ",
"yellow": "黄色",
"green": "緑",
"blue": "青",
"purple": "紫",
"pink": "ピンク",
"none": "なし"
},
"tooltips": { "tooltips": {
"reply": "返信", "reply": "返信",
"reply_all": "全員に返信 (a)", "reply_all": "全員に返信 (a)",
@@ -2033,8 +2022,7 @@
"delete": "削除", "delete": "削除",
"mark_as_spam": "迷惑メールを報告", "mark_as_spam": "迷惑メールを報告",
"not_spam": "迷惑メールでない", "not_spam": "迷惑メールでない",
"color_tag": "ラベル", "tag": "ラベル",
"remove_color": "ラベルを削除",
"items_selected": "{count}件のメールを選択", "items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集", "edit_draft": "下書きを編集",
"cancel_scheduled_send": "送信をキャンセル", "cancel_scheduled_send": "送信をキャンセル",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "연락처 보기", "view_contact": "연락처 보기",
"message_details": "메시지 상세 정보", "message_details": "메시지 상세 정보",
"more_reply_options": "답장 옵션 더보기", "more_reply_options": "답장 옵션 더보기",
"set_color": "태그 설정", "set_tag": "태그 설정",
"tag": "태그", "tag": "태그",
"more_actions": "작업 더보기", "more_actions": "작업 더보기",
"previous": "이전", "previous": "이전",
"next": "다음", "next": "다음",
"move_to": "이동...", "move_to": "이동...",
"remove_color": "태그 제거", "remove_tag": "태그 제거",
"more_count": "+{count}개 더보기", "more_count": "+{count}개 더보기",
"characters_count": "{count}자", "characters_count": "{count}자",
"quick_reply_placeholder": "간단하게 답장을 작성해 보세요...", "quick_reply_placeholder": "간단하게 답장을 작성해 보세요...",
@@ -400,17 +400,6 @@
"message_id": "메시지 ID", "message_id": "메시지 ID",
"list_info": "목록 정보" "list_info": "목록 정보"
}, },
"color_tag": {
"title": "색상 태그",
"red": "빨간색",
"orange": "주황색",
"yellow": "노란색",
"green": "초록색",
"blue": "파란색",
"purple": "보라색",
"pink": "분홍색",
"none": "없음"
},
"tooltips": { "tooltips": {
"reply": "답장 (r)", "reply": "답장 (r)",
"reply_all": "전체 답장 (a)", "reply_all": "전체 답장 (a)",
@@ -2033,8 +2022,7 @@
"delete": "삭제", "delete": "삭제",
"mark_as_spam": "스팸 신고", "mark_as_spam": "스팸 신고",
"not_spam": "정상 메일", "not_spam": "정상 메일",
"color_tag": "태그", "tag": "태그",
"remove_color": "태그 제거",
"items_selected": "{count}개의 메일 선택됨", "items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정", "edit_draft": "임시보관 메일 수정",
"cancel_scheduled_send": "보내기 취소", "cancel_scheduled_send": "보내기 취소",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Skatīt kontaktu", "view_contact": "Skatīt kontaktu",
"message_details": "Informācija par ziņojumu", "message_details": "Informācija par ziņojumu",
"more_reply_options": "Papildu atbildēšanas iespējas", "more_reply_options": "Papildu atbildēšanas iespējas",
"set_color": "Iestatīt tagu", "set_tag": "Iestatīt tagu",
"tag": "Tags", "tag": "Tags",
"more_actions": "Citas darbības", "more_actions": "Citas darbības",
"previous": "Iepr.", "previous": "Iepr.",
"next": "Nāk.", "next": "Nāk.",
"move_to": "Pārvietot uz...", "move_to": "Pārvietot uz...",
"remove_color": "Noņemt tagu", "remove_tag": "Noņemt tagu",
"more_count": "+vairāk {count}", "more_count": "+vairāk {count}",
"characters_count": "{count} rakstzīmes", "characters_count": "{count} rakstzīmes",
"quick_reply_placeholder": "Rakstīt ātru atbildi...", "quick_reply_placeholder": "Rakstīt ātru atbildi...",
@@ -400,17 +400,6 @@
"message_id": "Ziņojuma ID", "message_id": "Ziņojuma ID",
"list_info": "Informācija par adresātu sarakstu" "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": { "tooltips": {
"reply": "Atbildēt (r)", "reply": "Atbildēt (r)",
"reply_all": "Atbildēt visiem (a)", "reply_all": "Atbildēt visiem (a)",
@@ -2033,8 +2022,7 @@
"delete": "Dzēst", "delete": "Dzēst",
"mark_as_spam": "Atzīmēt kā mēstuli", "mark_as_spam": "Atzīmēt kā mēstuli",
"not_spam": "Nav mēstule", "not_spam": "Nav mēstule",
"color_tag": "Tags", "tag": "Tags",
"remove_color": "Noņemt tagu",
"items_selected": "{count} vēstules atlasītas", "items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu", "edit_draft": "Rediģēt melnrakstu",
"cancel_scheduled_send": "Atcelt sūtīšanu", "cancel_scheduled_send": "Atcelt sūtīšanu",
+6 -16
View File
@@ -327,11 +327,13 @@
"view_contact": "Contact bekijken", "view_contact": "Contact bekijken",
"message_details": "Berichtdetails", "message_details": "Berichtdetails",
"more_reply_options": "Meer antwoordopties", "more_reply_options": "Meer antwoordopties",
"set_color": "Label instellen", "set_tag": "Label instellen",
"tag": "Label", "tag": "Label",
"more_actions": "Meer acties", "more_actions": "Meer acties",
"move_to": "Verplaatsen naar...", "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", "more_count": "+{count} meer",
"characters_count": "{count} tekens", "characters_count": "{count} tekens",
"quick_reply_placeholder": "Schrijf een snel antwoord...", "quick_reply_placeholder": "Schrijf een snel antwoord...",
@@ -400,17 +402,6 @@
"message_id": "Bericht-ID", "message_id": "Bericht-ID",
"list_info": "Lijstinformatie" "list_info": "Lijstinformatie"
}, },
"color_tag": {
"title": "Kleurtag",
"red": "Rood",
"orange": "Oranje",
"yellow": "Geel",
"green": "Groen",
"blue": "Blauw",
"purple": "Paars",
"pink": "Roze",
"none": "Geen"
},
"tooltips": { "tooltips": {
"reply": "Beantwoorden", "reply": "Beantwoorden",
"reply_all": "Allen beantwoorden (a)", "reply_all": "Allen beantwoorden (a)",
@@ -1016,7 +1007,7 @@
}, },
"keywords": { "keywords": {
"title": "E-maillabels", "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", "add_keyword": "Label toevoegen",
"label_field": "Weergavenaam", "label_field": "Weergavenaam",
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent", "label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
@@ -2050,8 +2041,7 @@
"delete": "Verwijderen", "delete": "Verwijderen",
"mark_as_spam": "Spam melden", "mark_as_spam": "Spam melden",
"not_spam": "Geen spam", "not_spam": "Geen spam",
"color_tag": "Label", "tag": "Label",
"remove_color": "Label verwijderen",
"items_selected": "{count} e-mails geselecteerd", "items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken", "edit_draft": "Concept bewerken",
"cancel_scheduled_send": "Verzenden annuleren", "cancel_scheduled_send": "Verzenden annuleren",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Pokaż kontakt", "view_contact": "Pokaż kontakt",
"message_details": "Szczegóły wiadomości", "message_details": "Szczegóły wiadomości",
"more_reply_options": "Więcej opcji odpowiedzi", "more_reply_options": "Więcej opcji odpowiedzi",
"set_color": "Ustaw etykietę", "set_tag": "Ustaw etykietę",
"tag": "Etykieta", "tag": "Etykieta",
"more_actions": "Więcej działań", "more_actions": "Więcej działań",
"previous": "Poprz.", "previous": "Poprz.",
"next": "Nast.", "next": "Nast.",
"move_to": "Przenieś do...", "move_to": "Przenieś do...",
"remove_color": "Usuń etykietę", "remove_tag": "Usuń etykietę",
"more_count": "+{count} więcej", "more_count": "+{count} więcej",
"characters_count": "{count} znaków", "characters_count": "{count} znaków",
"quick_reply_placeholder": "Napisz szybką odpowiedź...", "quick_reply_placeholder": "Napisz szybką odpowiedź...",
@@ -400,17 +400,6 @@
"message_id": "ID wiadomości", "message_id": "ID wiadomości",
"list_info": "Informacje o liście" "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": { "tooltips": {
"reply": "Odpowiedz (r)", "reply": "Odpowiedz (r)",
"reply_all": "Odpowiedz wszystkim (a)", "reply_all": "Odpowiedz wszystkim (a)",
@@ -2033,8 +2022,7 @@
"delete": "Usuń", "delete": "Usuń",
"mark_as_spam": "Zgłoś spam", "mark_as_spam": "Zgłoś spam",
"not_spam": "To nie spam", "not_spam": "To nie spam",
"color_tag": "Etykieta", "tag": "Etykieta",
"remove_color": "Usuń etykietę",
"items_selected": "{count} zaznaczonych wiadomości", "items_selected": "{count} zaznaczonych wiadomości",
"edit_draft": "Edytuj szkic", "edit_draft": "Edytuj szkic",
"cancel_scheduled_send": "Anuluj wysyłkę", "cancel_scheduled_send": "Anuluj wysyłkę",
+3 -15
View File
@@ -325,11 +325,11 @@
"view_contact": "Ver contato", "view_contact": "Ver contato",
"message_details": "Detalhes da Mensagem", "message_details": "Detalhes da Mensagem",
"more_reply_options": "Mais opções de resposta", "more_reply_options": "Mais opções de resposta",
"set_color": "Definir etiqueta", "set_tag": "Definir etiqueta",
"tag": "Etiqueta", "tag": "Etiqueta",
"more_actions": "Mais ações", "more_actions": "Mais ações",
"move_to": "Mover para...", "move_to": "Mover para...",
"remove_color": "Remover etiqueta", "remove_tag": "Remover etiqueta",
"more_count": "+{count} mais", "more_count": "+{count} mais",
"characters_count": "{count} caracteres", "characters_count": "{count} caracteres",
"quick_reply_placeholder": "Escreva uma resposta rápida...", "quick_reply_placeholder": "Escreva uma resposta rápida...",
@@ -398,17 +398,6 @@
"message_id": "ID da Mensagem", "message_id": "ID da Mensagem",
"list_info": "Informações da Lista" "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": { "tooltips": {
"reply": "Responder", "reply": "Responder",
"reply_all": "Responder a todos (a)", "reply_all": "Responder a todos (a)",
@@ -2033,8 +2022,7 @@
"delete": "Excluir", "delete": "Excluir",
"mark_as_spam": "Reportar spam", "mark_as_spam": "Reportar spam",
"not_spam": "Não é spam", "not_spam": "Não é spam",
"color_tag": "Etiqueta", "tag": "Etiqueta",
"remove_color": "Remover etiqueta",
"items_selected": "{count} e-mails selecionados", "items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho", "edit_draft": "Editar rascunho",
"cancel_scheduled_send": "Cancelar envio", "cancel_scheduled_send": "Cancelar envio",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Vizualizare contact", "view_contact": "Vizualizare contact",
"message_details": "Detalii mesaj", "message_details": "Detalii mesaj",
"more_reply_options": "Mai multe opțiuni de răspuns", "more_reply_options": "Mai multe opțiuni de răspuns",
"set_color": "Setați eticheta", "set_tag": "Setați eticheta",
"tag": "Etichetă", "tag": "Etichetă",
"more_actions": "Alte acțiuni", "more_actions": "Alte acțiuni",
"previous": "Anterior", "previous": "Anterior",
"next": "Următorul", "next": "Următorul",
"move_to": "Mergi la...", "move_to": "Mergi la...",
"remove_color": "Eliminați eticheta", "remove_tag": "Eliminați eticheta",
"more_count": "+{count} mai multe", "more_count": "+{count} mai multe",
"characters_count": "{count} caractere", "characters_count": "{count} caractere",
"quick_reply_placeholder": "Scrie un răspuns rapid...", "quick_reply_placeholder": "Scrie un răspuns rapid...",
@@ -425,17 +425,6 @@
"message_id": "IDul mesajelor", "message_id": "IDul mesajelor",
"list_info": "Informații despre listă" "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": { "tooltips": {
"reply": "Răspunde (r)", "reply": "Răspunde (r)",
"reply_all": "Răspunde tuturor (a)", "reply_all": "Răspunde tuturor (a)",
@@ -2033,8 +2022,7 @@
"delete": "Șterge", "delete": "Șterge",
"mark_as_spam": "Raportează spamul", "mark_as_spam": "Raportează spamul",
"not_spam": "Nu este spam", "not_spam": "Nu este spam",
"color_tag": "Etichetă", "tag": "Etichetă",
"remove_color": "Eliminați eticheta",
"items_selected": "{count} e-mailuri selectate", "items_selected": "{count} e-mailuri selectate",
"edit_draft": "Editează schița", "edit_draft": "Editează schița",
"cancel_scheduled_send": "Anulează trimiterea", "cancel_scheduled_send": "Anulează trimiterea",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Просмотреть контакт", "view_contact": "Просмотреть контакт",
"message_details": "Детали сообщения", "message_details": "Детали сообщения",
"more_reply_options": "Дополнительные параметры ответа", "more_reply_options": "Дополнительные параметры ответа",
"set_color": "Установить тег", "set_tag": "Установить тег",
"tag": "Тег", "tag": "Тег",
"more_actions": "Другие действия", "more_actions": "Другие действия",
"previous": "Пред.", "previous": "Пред.",
"next": "След.", "next": "След.",
"move_to": "Переместить в...", "move_to": "Переместить в...",
"remove_color": "Удалить тег", "remove_tag": "Удалить тег",
"more_count": "+{count} ещё", "more_count": "+{count} ещё",
"characters_count": "{count} символов", "characters_count": "{count} символов",
"quick_reply_placeholder": "Написать быстрый ответ...", "quick_reply_placeholder": "Написать быстрый ответ...",
@@ -400,17 +400,6 @@
"message_id": "Идентификатор сообщения", "message_id": "Идентификатор сообщения",
"list_info": "Информация о рассылке" "list_info": "Информация о рассылке"
}, },
"color_tag": {
"title": "Цветной тег",
"red": "Красный",
"orange": "Оранжевый",
"yellow": "Жёлтый",
"green": "Зелёный",
"blue": "Синий",
"purple": "Фиолетовый",
"pink": "Розовый",
"none": "Нет"
},
"tooltips": { "tooltips": {
"reply": "Ответить (r)", "reply": "Ответить (r)",
"reply_all": "Ответить всем (a)", "reply_all": "Ответить всем (a)",
@@ -2033,8 +2022,7 @@
"delete": "Удалить", "delete": "Удалить",
"mark_as_spam": "Отметить как спам", "mark_as_spam": "Отметить как спам",
"not_spam": "Не спам", "not_spam": "Не спам",
"color_tag": "Тег", "tag": "Тег",
"remove_color": "Удалить тег",
"items_selected": "{count} писем выбрано", "items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик", "edit_draft": "Редактировать черновик",
"cancel_scheduled_send": "Отменить отправку", "cancel_scheduled_send": "Отменить отправку",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Zobraziť kontakt", "view_contact": "Zobraziť kontakt",
"message_details": "Podrobnosti správy", "message_details": "Podrobnosti správy",
"more_reply_options": "Viac možností odpovede", "more_reply_options": "Viac možností odpovede",
"set_color": "Nastaviť štítok", "set_tag": "Nastaviť štítok",
"tag": "Štítok", "tag": "Štítok",
"more_actions": "Viac akcií", "more_actions": "Viac akcií",
"previous": "Predchádzajúci", "previous": "Predchádzajúci",
"next": "Ďalší", "next": "Ďalší",
"move_to": "Presunúť do...", "move_to": "Presunúť do...",
"remove_color": "Odstrániť štítok", "remove_tag": "Odstrániť štítok",
"more_count": "+{count} ďalších", "more_count": "+{count} ďalších",
"characters_count": "{count} znakov", "characters_count": "{count} znakov",
"quick_reply_placeholder": "Napísať rýchlu odpoveď...", "quick_reply_placeholder": "Napísať rýchlu odpoveď...",
@@ -425,17 +425,6 @@
"message_id": "ID správy", "message_id": "ID správy",
"list_info": "Informácie o zozname" "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": { "tooltips": {
"reply": "Odpovedať (r)", "reply": "Odpovedať (r)",
"reply_all": "Odpovedať všetkým (a)", "reply_all": "Odpovedať všetkým (a)",
@@ -2033,8 +2022,7 @@
"delete": "Odstrániť", "delete": "Odstrániť",
"mark_as_spam": "Nahlásiť spam", "mark_as_spam": "Nahlásiť spam",
"not_spam": "Nie je spam", "not_spam": "Nie je spam",
"color_tag": "Štítok", "tag": "Štítok",
"remove_color": "Odstrániť štítok",
"items_selected": "{count} vybraných e-mailov", "items_selected": "{count} vybraných e-mailov",
"edit_draft": "Upraviť koncept", "edit_draft": "Upraviť koncept",
"cancel_scheduled_send": "Zrušiť odoslanie", "cancel_scheduled_send": "Zrušiť odoslanie",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Kişiyi görüntüle", "view_contact": "Kişiyi görüntüle",
"message_details": "İleti Ayrıntıları", "message_details": "İleti Ayrıntıları",
"more_reply_options": "Daha fazla yanıt seçeneği", "more_reply_options": "Daha fazla yanıt seçeneği",
"set_color": "Etiket ayarla", "set_tag": "Etiket ayarla",
"tag": "Etiket", "tag": "Etiket",
"more_actions": "Diğer işlemler", "more_actions": "Diğer işlemler",
"previous": "Önceki", "previous": "Önceki",
"next": "Sonraki", "next": "Sonraki",
"move_to": "Şuraya taşı...", "move_to": "Şuraya taşı...",
"remove_color": "Etiketi kaldır", "remove_tag": "Etiketi kaldır",
"more_count": "+{count} daha", "more_count": "+{count} daha",
"characters_count": "{count} karakter", "characters_count": "{count} karakter",
"quick_reply_placeholder": "Hızlı yanıt yazın...", "quick_reply_placeholder": "Hızlı yanıt yazın...",
@@ -400,17 +400,6 @@
"message_id": "İleti Kimliği", "message_id": "İleti Kimliği",
"list_info": "Liste Bilgisi" "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": { "tooltips": {
"reply": "Yanıtla (r)", "reply": "Yanıtla (r)",
"reply_all": "Tümünü Yanıtla (a)", "reply_all": "Tümünü Yanıtla (a)",
@@ -2033,8 +2022,7 @@
"delete": "Sil", "delete": "Sil",
"mark_as_spam": "Spam bildir", "mark_as_spam": "Spam bildir",
"not_spam": "Spam değil", "not_spam": "Spam değil",
"color_tag": "Etiket", "tag": "Etiket",
"remove_color": "Etiketi kaldır",
"items_selected": "{count} e-posta seçildi", "items_selected": "{count} e-posta seçildi",
"edit_draft": "Taslağı Düzenle", "edit_draft": "Taslağı Düzenle",
"cancel_scheduled_send": "Göndermeyi iptal et", "cancel_scheduled_send": "Göndermeyi iptal et",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "Переглянути контакт", "view_contact": "Переглянути контакт",
"message_details": "Деталі повідомлення", "message_details": "Деталі повідомлення",
"more_reply_options": "Більше варіантів відповіді", "more_reply_options": "Більше варіантів відповіді",
"set_color": "Встановити тег", "set_tag": "Встановити тег",
"tag": "Тег", "tag": "Тег",
"more_actions": "Більше дій", "more_actions": "Більше дій",
"previous": "попередня", "previous": "попередня",
"next": "Далі", "next": "Далі",
"move_to": "Перейти до...", "move_to": "Перейти до...",
"remove_color": "Видалити тег", "remove_tag": "Видалити тег",
"more_count": "+ ще {count}", "more_count": "+ ще {count}",
"characters_count": "{count} символів", "characters_count": "{count} символів",
"quick_reply_placeholder": "Напишіть швидку відповідь...", "quick_reply_placeholder": "Напишіть швидку відповідь...",
@@ -400,17 +400,6 @@
"message_id": "ID повідомлення", "message_id": "ID повідомлення",
"list_info": "Інформація про список" "list_info": "Інформація про список"
}, },
"color_tag": {
"title": "Кольоровий тег",
"red": "Червоний",
"orange": "Помаранчевий",
"yellow": "Жовтий",
"green": "Зелений",
"blue": "Синій",
"purple": "Фіолетовий",
"pink": "Рожевий",
"none": "Жодного"
},
"tooltips": { "tooltips": {
"reply": "Відповісти (р)", "reply": "Відповісти (р)",
"reply_all": "Відповісти всім (а)", "reply_all": "Відповісти всім (а)",
@@ -2033,8 +2022,7 @@
"delete": "Видалити", "delete": "Видалити",
"mark_as_spam": "Повідомити про спам", "mark_as_spam": "Повідомити про спам",
"not_spam": "Не спам", "not_spam": "Не спам",
"color_tag": "Мітка", "tag": "Мітка",
"remove_color": "Видалити мітку",
"items_selected": "Вибрано електронних листів: {count}", "items_selected": "Вибрано електронних листів: {count}",
"edit_draft": "Редагувати чернетку", "edit_draft": "Редагувати чернетку",
"cancel_scheduled_send": "Скасувати надсилання", "cancel_scheduled_send": "Скасувати надсилання",
+3 -15
View File
@@ -325,13 +325,13 @@
"view_contact": "查看联系人", "view_contact": "查看联系人",
"message_details": "邮件详情", "message_details": "邮件详情",
"more_reply_options": "更多回复选项", "more_reply_options": "更多回复选项",
"set_color": "设置颜色标签", "set_tag": "设置颜色标签",
"tag": "标签", "tag": "标签",
"more_actions": "更多操作", "more_actions": "更多操作",
"previous": "上一封", "previous": "上一封",
"next": "下一封", "next": "下一封",
"move_to": "移动到…", "move_to": "移动到…",
"remove_color": "删除标签", "remove_tag": "删除标签",
"more_count": "+{count} 更多", "more_count": "+{count} 更多",
"characters_count": "{count} 个字符", "characters_count": "{count} 个字符",
"quick_reply_placeholder": "快速回复...", "quick_reply_placeholder": "快速回复...",
@@ -400,17 +400,6 @@
"message_id": "消息 ID", "message_id": "消息 ID",
"list_info": "邮件列表信息" "list_info": "邮件列表信息"
}, },
"color_tag": {
"title": "颜色标签",
"red": "红色",
"orange": "橙色",
"yellow": "黄色",
"green": "绿色",
"blue": "蓝色",
"purple": "紫色",
"pink": "粉色",
"none": "无"
},
"tooltips": { "tooltips": {
"reply": "回复 (r)", "reply": "回复 (r)",
"reply_all": "全部回复 (a)", "reply_all": "全部回复 (a)",
@@ -2033,8 +2022,7 @@
"delete": "删除", "delete": "删除",
"mark_as_spam": "举报垃圾邮件", "mark_as_spam": "举报垃圾邮件",
"not_spam": "不是垃圾邮件", "not_spam": "不是垃圾邮件",
"color_tag": "标签", "tag": "标签",
"remove_color": "删除标签",
"items_selected": "已选择 {count} 封邮件", "items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿", "edit_draft": "编辑草稿",
"cancel_scheduled_send": "取消发送", "cancel_scheduled_send": "取消发送",
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, getKeywordVisibility } from '../settings-store'; import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store';
import type { KeywordDefinition } from '../settings-store'; import type { KeywordDefinition } from '../settings-store';
describe('settings-store keywords', () => { describe('settings-store keywords', () => {
@@ -16,7 +16,7 @@ describe('settings-store keywords', () => {
DEFAULT_KEYWORDS.forEach((kw) => { DEFAULT_KEYWORDS.forEach((kw) => {
expect(KEYWORD_PALETTE[kw.color]).toBeDefined(); expect(KEYWORD_PALETTE[kw.color]).toBeDefined();
expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy(); expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy();
expect(KEYWORD_PALETTE[kw.color].bg).toBeTruthy(); expect(KEYWORD_PALETTE[kw.color].fill).toBeTruthy();
}); });
}); });
@@ -27,14 +27,39 @@ describe('settings-store keywords', () => {
}); });
describe('KEYWORD_PALETTE', () => { describe('KEYWORD_PALETTE', () => {
it('has 13 colors', () => { it('has a lighter, base and darker shade of every hue', () => {
expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(13); 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) => { Object.values(KEYWORD_PALETTE).forEach((entry) => {
expect(entry.dot).toMatch(/^bg-/); 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();
}); });
}); });
}); });
+78 -15
View File
@@ -125,23 +125,86 @@ export interface SidebarApp {
showOnMobile: boolean; showOnMobile: boolean;
} }
// Available color palette for keywords export interface KeywordColor {
export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = { /** Solid swatch: the dot form and the settings swatches. */
red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' }, dot: string;
orange: { dot: 'bg-orange-500', bg: 'bg-orange-50 dark:bg-orange-950/30' }, /** The same solid colour as `dot`, for glyphs that take a text colour. */
yellow: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 dark:bg-yellow-950/30' }, icon: string;
green: { dot: 'bg-green-500', bg: 'bg-green-50 dark:bg-green-950/30' }, /** Lozenge background. */
blue: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-950/30' }, fill: string;
purple: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-950/30' }, /** Lozenge border. */
pink: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-950/30' }, border: string;
teal: { dot: 'bg-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' }, /** Lozenge text. */
cyan: { dot: 'bg-cyan-500', bg: 'bg-cyan-50 dark:bg-cyan-950/30' }, text: string;
indigo: { dot: 'bg-indigo-500', bg: 'bg-indigo-50 dark:bg-indigo-950/30' }, /** Full-row wash when `tintListRowsByTag` is on. */
amber: { dot: 'bg-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' }, rowTint: string;
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' },
/**
* 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; } 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[] = [ export const DEFAULT_KEYWORDS: KeywordDefinition[] = [
{ id: 'red', label: 'Red', color: 'red' }, { id: 'red', label: 'Red', color: 'red' },
{ id: 'orange', label: 'Orange', color: 'orange' }, { id: 'orange', label: 'Orange', color: 'orange' },