From ca0ba818b744222d42eaa723971dd780e63fb893 Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 01:00:27 +0200 Subject: [PATCH 01/11] feat: Add nesting of tags in a tree - levels are joined by forward slashes in the keywords - behaviour is opt-in for now - long paths are shortened if there is not enough display room Closes #687. --- FEATURES.md | 1 + components/email/email-context-menu.tsx | 47 +++--- components/email/email-list-item.tsx | 10 +- components/email/email-viewer.tsx | 24 ++- components/email/tag-option-label.tsx | 29 ++++ components/email/thread-list-item.tsx | 22 ++- components/filters/filter-rule-modal.tsx | 4 +- components/layout/sidebar.tsx | 151 ++++++++++++++---- .../__tests__/keyword-settings.test.tsx | 77 ++++++++- components/settings/keyword-settings.tsx | 136 ++++++++++++++-- components/settings/settings-section.tsx | 13 +- hooks/__tests__/use-shortened-text.test.tsx | 70 ++++++++ hooks/use-keyword-format.ts | 30 ++++ hooks/use-shortened-text.ts | 76 +++++++++ lib/__tests__/keyword-format.test.ts | 115 +++++++++++++ lib/__tests__/keyword-nesting.test.ts | 138 ++++++++++++++++ lib/keyword-format.ts | 83 ++++++++++ lib/keyword-nesting.ts | 113 +++++++++++++ locales/en/common.json | 11 +- locales/nl/common.json | 11 +- .../__tests__/settings-store-keywords.test.ts | 15 ++ stores/settings-store.ts | 3 + 22 files changed, 1084 insertions(+), 95 deletions(-) create mode 100644 components/email/tag-option-label.tsx create mode 100644 hooks/__tests__/use-shortened-text.test.tsx create mode 100644 hooks/use-keyword-format.ts create mode 100644 hooks/use-shortened-text.ts create mode 100644 lib/__tests__/keyword-format.test.ts create mode 100644 lib/__tests__/keyword-nesting.test.ts create mode 100644 lib/keyword-format.ts create mode 100644 lib/keyword-nesting.ts diff --git a/FEATURES.md b/FEATURES.md index f561981a..3f04221b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -17,6 +17,7 @@ - Multi-select for batch archive, delete, move, and tag - Archive directly, by year, or by month - Tags carry color labels, reorder by drag, and can be assigned by dropping a message onto them +- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree - Star or unstar, with a configurable mark-as-read delay - Large mailboxes scroll virtually, and the first page of mail prefetches at login - Quick reply, hover actions, favicon-based sender avatars, recipient popovers diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index d74ed5cb..bccd8a72 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -38,6 +38,8 @@ import { } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { TagOptionLabel } from "./tag-option-label"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; interface Position { @@ -155,6 +157,7 @@ export function EmailContextMenu({ const _tColor = useTranslations("email_viewer.color_tag"); const tEmailViewer = useTranslations("email_viewer"); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { tagNameCandidates } = useKeywordFormat(); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isPinned = email.keywords?.['$pinned'] === true; @@ -170,7 +173,7 @@ export function EmailContextMenu({ // Build color options from keyword definitions in settings const colorOptions = emailKeywords.map((kw) => ({ - name: kw.label, + candidates: tagNameCandidates(kw.id), value: kw.id, color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500", })); @@ -381,26 +384,28 @@ export function EmailContextMenu({ {/* Set tag submenu - only for single email */} {!showBatchActions && ( - {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} +
+ {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} +
{currentColors.length > 0 && ( <> diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 44c7e915..ac6178fb 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -16,6 +16,7 @@ import { useUIStore } from "@/stores/ui-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailHoverActions } from "./email-hover-actions"; import { getEmailColorTags } from "@/lib/thread-utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; interface EmailListItemProps { email: Email; @@ -40,6 +41,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { tagName } = useKeywordFormat(); const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const { identities } = useAuthStore(); @@ -232,7 +234,11 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte )} {email.hasAttachment && } {keywordDefs.map((kd) => ( - + ))} + )} title={tagName(kd.id)}> {kd.label} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0aa7fb97..7dca9126 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -12,6 +12,8 @@ import { withBasePath } from "@/lib/browser-navigation"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; +import { TagOptionLabel } from "./tag-option-label"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import { generateEmailSource } from "@/lib/email-source"; @@ -667,6 +669,7 @@ export function EmailViewer({ const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { tagName, tagNameCandidates } = useKeywordFormat(); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -711,7 +714,7 @@ export function EmailViewer({ // Color options for email tags (from user-defined keyword settings) const colorOptions = emailKeywords.map((kw) => ({ - name: kw.label, + candidates: tagNameCandidates(kw.id), value: kw.id, color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500', })); @@ -3012,9 +3015,10 @@ export function EmailViewer({ })} {showToolbarLabels && currentColors.length === 1 && ( - - {emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]} - + )} ) : ( @@ -3038,7 +3042,7 @@ export function EmailViewer({ )} > - {option.name} + {isActive && } ); @@ -3273,7 +3277,7 @@ export function EmailViewer({ )} > - {option.name} + {isActive && } ); @@ -3555,7 +3559,7 @@ export function EmailViewer({ )} > - {option.name} + {isActive && } ); @@ -3634,7 +3638,11 @@ export function EmailViewer({ 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 ( - + ); })} diff --git a/components/email/tag-option-label.tsx b/components/email/tag-option-label.tsx new file mode 100644 index 00000000..e396bdad --- /dev/null +++ b/components/email/tag-option-label.tsx @@ -0,0 +1,29 @@ +"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 ( + + {shortenedLabel} + + ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 51503972..6f4812da 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -11,6 +11,7 @@ import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; import { useAccountStore } from "@/stores/account-store"; import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; @@ -90,7 +91,8 @@ const SingleEmailItem = React.forwardRef( const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + const { tagName } = useKeywordFormat(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); @@ -282,7 +284,11 @@ const SingleEmailItem = React.forwardRef( )} {email.hasAttachment && } {resolvedKeywordDefs.map((kd) => ( - + ))} {showSourceFolder && } {scheduledSendLabel ? ( @@ -351,7 +357,7 @@ const SingleEmailItem = React.forwardRef( + )} title={tagName(kd.id)}> {kd.label} @@ -499,7 +505,8 @@ export const ThreadListItem = React.forwardRef state.emailKeywords); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + const { tagName } = useKeywordFormat(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; @@ -746,7 +753,10 @@ export const ThreadListItem = React.forwardRef} {keywordDef && ( - + )} {showSourceFolder && } {scheduledSendLabel ? ( @@ -827,7 +837,7 @@ export const ThreadListItem = React.forwardRef + )} title={tagName(keywordDef.id)}> {keywordDef.label} diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 221a6e7a..cca7a279 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -18,6 +18,7 @@ import type { import type { Mailbox } from "@/lib/jmap/types"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; interface FilterRuleModalProps { rule?: FilterRule; @@ -89,6 +90,7 @@ export function FilterRuleModal({ const t = useTranslations("settings.filters"); const isEdit = !!rule; const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const { tagName } = useKeywordFormat(); const [name, setName] = useState(rule?.name || ""); const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all"); @@ -477,7 +479,7 @@ export function FilterRuleModal({ > {emailKeywords.map((kw) => ( - + ))} )} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index bc61bcc6..06a9b662 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -38,6 +38,9 @@ import { } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { buildKeywordTree, hasChildKeywords, type KeywordNode } from "@/lib/keyword-nesting"; +import { useShortenedText } from "@/hooks/use-shortened-text"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { isEditableEventTarget } from "@/lib/keyboard"; import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -51,7 +54,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; -import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store"; +import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; @@ -241,6 +244,9 @@ function SidebarRowCounts({ interface SidebarRowProps { icon: ReactNode; label: string; + /** Progressively shorter renderings of `label`, longest first. The widest one + * that fits the row is shown; without this the full label is used. */ + labelCandidates?: string[]; depth?: number; isSelected?: boolean; isVirtual?: boolean; @@ -266,6 +272,7 @@ interface SidebarRowProps { function SidebarRow({ icon, label, + labelCandidates, depth = 0, isSelected = false, isVirtual = false, @@ -288,6 +295,7 @@ function SidebarRow({ }: SidebarRowProps) { const t = useTranslations('sidebar'); const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP; + const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]); return (
{!isCollapsed && ( <> - {label} + {shortenedLabel} = { }; function TagItem({ - kw, - isSelected, + node, + selectedKeyword, + expandedTags, isCollapsed, onTagSelect, - totalCount, - unreadCount, + onToggleExpand, + tagCounts, colorful, }: { - kw: KeywordDefinition; - isSelected: boolean; + node: KeywordNode; + selectedKeyword: string | null; + expandedTags: Set; isCollapsed: boolean; onTagSelect?: (keywordId: string | null) => void; - totalCount: number; - unreadCount: number; + onToggleExpand: (keywordId: string) => void; + tagCounts: Record; colorful: boolean; }) { const t = useTranslations('notifications'); - const palette = KEYWORD_PALETTE[kw.color]; + const { tagNameCandidates } = useKeywordFormat(); + const palette = KEYWORD_PALETTE[node.color]; + const hasChildren = node.children.length > 0; + const isExpanded = expandedTags.has(node.id); + const isSelected = selectedKeyword === node.id; + // Nested rows are placed by their indentation, so they show their own name. + // A root spells out its path, which matters when an intermediate tag is + // missing from this client's settings and the row would otherwise read as a + // bare leaf name. Toasts have the room for the whole thing. + const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label]; + const label = labelCandidates[0]; const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget } = useTagDrop({ - tagId: kw.id, - onSuccess: (count, _tagLabel) => { + tagId: node.id, + onSuccess: (count) => { if (count === 1) { - toast.success(t('email_tagged'), kw.label); + toast.success(t('email_tagged'), label); } else { - toast.success(t('emails_tagged', { count }), kw.label); + toast.success(t('emails_tagged', { count }), label); } }, onError: () => { - toast.error(t('tag_failed'), kw.label); + toast.error(t('tag_failed'), label); }, }); const tagIcon = colorful ? ( ) : ( @@ -602,18 +622,38 @@ function TagItem({ ); return ( - onTagSelect?.(isSelected ? null : kw.id)} - isCollapsed={isCollapsed} - dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} - isValidDropTarget={isValidDropTarget} - /> + <> + onTagSelect?.(isSelected ? null : node.id)} + hasChildren={hasChildren} + isExpanded={isExpanded} + onExpandToggle={() => onToggleExpand(node.id)} + isCollapsed={isCollapsed} + dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} + isValidDropTarget={isValidDropTarget} + /> + + {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( + + ))} + ); } @@ -737,6 +777,7 @@ export function Sidebar({ const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [expandedTags, setExpandedTags] = useState>(new Set()); const [foldersExpanded, setFoldersExpanded] = useState(() => { try { const stored = localStorage.getItem('sidebarFoldersExpanded'); @@ -779,6 +820,7 @@ export function Sidebar({ return new Set(); }); const emailKeywords = useSettingsStore(s => s.emailKeywords); + const nestedTags = useSettingsStore(s => s.nestedTags); const isEmbedded = useIsEmbedded(); // The Pro shell owns the global chrome (rail + tab bar), so the sidebar's // own AccountSwitcher would be a redundant second account UI in the same @@ -842,6 +884,37 @@ export function Sidebar({ }); }; + useEffect(() => { + const stored = localStorage.getItem('expandedTags'); + if (stored) { + try { + const parsed = JSON.parse(stored); + setExpandedTags(new Set(parsed)); + } catch (e) { + debug.error('Failed to parse expanded tags:', e); + } + } else { + setExpandedTags( + new Set(emailKeywords.filter((kw) => hasChildKeywords(kw.id, emailKeywords)).map((kw) => kw.id)) + ); + } + }, [emailKeywords]); + + const handleToggleTagExpand = (keywordId: string) => { + setExpandedTags((prev) => { + const next = new Set(prev); + if (next.has(keywordId)) { + next.delete(keywordId); + } else { + next.add(keywordId); + } + try { + localStorage.setItem('expandedTags', JSON.stringify(Array.from(next))); + } catch { /* storage full or unavailable */ } + return next; + }); + }; + // When the app renders its own virtual "Scheduled" folder (for delayed // sends, driven by EmailSubmission), hide the server-provided scheduled // mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled') @@ -852,6 +925,13 @@ export function Sidebar({ const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n)); const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-')); + // With nesting off every tag is its own root, so the same rows render through + // one path whether or not the ids describe a hierarchy. + const tagTree: KeywordNode[] = nestedTags + ? buildKeywordTree(emailKeywords) + : emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 })); + + // Multi-account mode (Pro shell): render every connected account as its // own collapsible group. The active account's tree comes from the // `mailboxes` prop (which is the live email-store value); other accounts @@ -1265,15 +1345,16 @@ export function Sidebar({ /> {((tagsExpanded && !isCollapsed) || isCollapsed) && ( <> - {emailKeywords.map((kw) => ( + {tagTree.map((node) => ( ))} diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx index 1c3c5f94..8593c41f 100644 --- a/components/settings/__tests__/keyword-settings.test.tsx +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { KeywordSettings } from '../keyword-settings'; import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; -// Mock SettingsSection to just render children -vi.mock('../settings-section', () => ({ +// Mock SettingsSection to just render children, keeping the real controls +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, })); describe('KeywordSettings', () => { beforeEach(() => { - useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] }); + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false }); }); it('renders all default keywords', () => { @@ -139,4 +140,74 @@ describe('KeywordSettings', () => { expect(added.id).toBe('my-custom-tag'); expect(added.label).toBe('My Custom Tag!'); }); + + it('offers no parent picker while nesting is off', () => { + render(); + fireEvent.click(screen.getByText('add_keyword')); + + expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument(); + }); + + it('nests a new tag under the selected parent', () => { + useSettingsStore.setState({ + emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }], + nestedTags: true, + }); + render(); + fireEvent.click(screen.getByText('add_keyword')); + + fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } }); + fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } }); + fireEvent.click(screen.getByText('add')); + + const keywords = useSettingsStore.getState().emailKeywords; + expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' }); + }); + + it('shows nested tags by their full path', () => { + useSettingsStore.setState({ + emailKeywords: [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, + ], + nestedTags: true, + }); + render(); + + expect(screen.getByText('Work/Clients')).toBeInTheDocument(); + expect(screen.getByText('$label:work/clients')).toBeInTheDocument(); + }); + + it('rejects a path that would exceed the keyword length limit', () => { + const deepId = 'a'.repeat(240); + useSettingsStore.setState({ + emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }], + nestedTags: true, + }); + render(); + fireEvent.click(screen.getByText('add_keyword')); + + fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } }); + fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } }); + + expect(screen.getByText('too_long')).toBeInTheDocument(); + expect(screen.getByText('add').closest('button')).toBeDisabled(); + }); + + it('locks the name and the delete action of a tag that has nested tags', () => { + useSettingsStore.setState({ + emailKeywords: [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, + ], + nestedTags: true, + }); + render(); + + expect(screen.getByTitle('has_children_delete')).toBeDisabled(); + + fireEvent.click(screen.getAllByTitle('edit')[0]); + expect(screen.getByDisplayValue('Work')).toBeDisabled(); + expect(screen.getByText('has_children_locked')).toBeInTheDocument(); + }); }); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index c4fcdb2d..cd6f408f 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -2,12 +2,30 @@ import React, { useState } from "react"; import { useTranslations } from "next-intl"; -import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; +import { + useSettingsStore, + KEYWORD_PALETTE, + DEFAULT_KEYWORDS, + type KeywordDefinition, +} from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; -import { SettingsSection } from "./settings-section"; +import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section"; import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +import { KEYWORD_PREFIX } from "@/lib/thread-utils"; +import { + buildKeywordTree, + composeKeywordId, + getParentKeywordId, + hasChildKeywords, + isKeywordDescendant, + keywordLevels, + type KeywordNode, + MAX_KEYWORD_ID_LENGTH, +} from "@/lib/keyword-nesting"; +import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; +import { useShortenedText } from "@/hooks/use-shortened-text"; const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); @@ -39,6 +57,8 @@ function KeywordColorPicker({ function KeywordRow({ keyword, + keywords, + nestedTags, onEdit, onDelete, onDragStart, @@ -49,6 +69,8 @@ function KeywordRow({ isDragging, }: { keyword: KeywordDefinition; + keywords: KeywordDefinition[]; + nestedTags: boolean; onEdit: () => void; onDelete: () => void; onDragStart: () => void; @@ -60,6 +82,13 @@ function KeywordRow({ }) { const t = useTranslations("settings.keywords"); const palette = KEYWORD_PALETTE[keyword.color]; + 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. + const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) + .map((rendering) => KEYWORD_PREFIX + rendering); + const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates); return (
- {keyword.label} - {"$label:" + keyword.id} + + {shortenedName} + + + {shortenedKeyword} +
@@ -102,37 +144,74 @@ function KeywordRow({ function KeywordEditForm({ initial, + keywords, existingIds, + nestedTags, onSave, onCancel, }: { initial?: KeywordDefinition; + keywords: KeywordDefinition[]; existingIds: string[]; + nestedTags: boolean; onSave: (keyword: KeywordDefinition) => void; onCancel: () => void; }) { const t = useTranslations("settings.keywords"); const [label, setLabel] = useState(initial?.label || ""); const [color, setColor] = useState(initial?.color || "blue"); + const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : ""); const isEditing = !!initial; - const normalizedId = label - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); + // Renaming or re-parenting a tag rewrites the keyword on every message below + // it, and this client only knows about the tags in its own settings - the + // server may hold nested keywords created elsewhere. Freeze the identity of a + // tag that has children and allow the color to change. + const isLocked = !!initial && hasChildKeywords(initial.id, keywords); + const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label); const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId); - const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate; + const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH; + const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong; + + // Every tag is a candidate parent except the one being edited and anything + // already below it, which would detach the branch from its own root. + const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }]; + const collectParentOptions = (nodes: KeywordNode[]) => { + for (const node of nodes) { + if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue; + parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) }); + collectParentOptions(node.children); + } + }; + collectParentOptions(buildKeywordTree(keywords)); const handleSave = () => { if (!isValid) return; + if (isLocked && initial) { + onSave({ ...initial, color }); + return; + } onSave({ id: normalizedId, label: label.trim(), color }); }; return (
+ {nestedTags && ( +
+ + onChange(e.target.value)} + disabled={disabled} + aria-label={ariaLabel} dir="auto" - className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground" + className={cn( + "px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150", + disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground", + className + )} > {options.map((option) => (
diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx index 8593c41f..eb3119f3 100644 --- a/components/settings/__tests__/keyword-settings.test.tsx +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -210,4 +210,20 @@ describe('KeywordSettings', () => { expect(screen.getByDisplayValue('Work')).toBeDisabled(); expect(screen.getByText('has_children_locked')).toBeInTheDocument(); }); + + it('defaults every tag to always visible in the sidebar', () => { + render(); + + const pickers = screen.getAllByLabelText('visibility_field'); + expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length); + pickers.forEach((picker) => expect(picker).toHaveValue('show')); + }); + + it('stores the visibility chosen for a tag', () => { + render(); + + fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } }); + + expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread'); + }); }); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index cd6f408f..9689ab08 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -6,7 +6,9 @@ import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, + getKeywordVisibility, type KeywordDefinition, + type KeywordVisibility, } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; @@ -61,6 +63,7 @@ function KeywordRow({ nestedTags, onEdit, onDelete, + onVisibilityChange, onDragStart, onDragOver, onDrop, @@ -73,6 +76,7 @@ function KeywordRow({ nestedTags: boolean; onEdit: () => void; onDelete: () => void; + onVisibilityChange: (visibility: KeywordVisibility) => void; onDragStart: () => void; onDragOver: (e: React.DragEvent) => void; onDrop: () => void; @@ -89,6 +93,11 @@ function KeywordRow({ const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) .map((rendering) => KEYWORD_PREFIX + rendering); const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates); + const visibilityOptions = [ + { value: "show", label: t("visibility.show") }, + { value: "unread", label: t("visibility.unread") }, + { value: "hide", label: t("visibility.hide") }, + ]; return (
{shortenedKeyword} + setQuery(event.target.value)} + placeholder={t("tag_filter_placeholder")} + aria-label={t("tag_filter_placeholder")} + className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ )} + +
+ {trimmedQuery ? ( + matches.length > 0 ? ( + matches.map((keyword) => renderRow(keyword.id, tagName(keyword.id))) + ) : ( +

{t("tag_no_matches")}

+ ) + ) : ( + renderBranch(tree) + )} +
+ + {onClearAll && selectedIds.length > 0 && ( + <> +
+ + + )} + + ); +} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index bd35f4c5..a2859e66 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; +import { getEmailTagIds } from "@/lib/thread-utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useTagDisplay } from "@/hooks/use-tag-display"; +import { TagBadge } from "./tag-badge"; interface ThreadEmailItemProps { email: Email; @@ -35,6 +39,11 @@ export function ThreadEmailItem({ const isStarred = email.keywords?.$flagged; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; + const { sortTagIds } = useKeywordFormat(); + const { variant: tagVariant } = useTagDisplay(); + // A message inside an expanded thread carries its own tags; the collapsed + // header pools them, so without this they disappear on the way in. + const tagIds = sortTagIds(getEmailTagIds(email.keywords)); const sender = email.from?.[0]; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const density = useSettingsStore((state) => state.density); @@ -178,6 +187,9 @@ export function ThreadEmailItem({ {email.hasAttachment && ( )} + {tagIds.map((id) => ( + + ))}
{/* Preview snippet */} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 6f4812da..70a49f05 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -6,12 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; import { useAccountStore } from "@/stores/account-store"; -import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; +import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useTagDisplay } from "@/hooks/use-tag-display"; +import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; @@ -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 ( + + + {count} + + ); +} + interface ThreadListItemProps { thread: ThreadGroup; isExpanded: boolean; @@ -51,7 +75,7 @@ interface ThreadListItemProps { onMarkAsRead?: (email: Email, read: boolean) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; } @@ -63,18 +87,18 @@ interface SingleEmailItemProps { onDoubleClick?: () => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; showPreview: boolean; - colorTag: string | null; + rowTint: string | null; onToggleStar?: () => void; onMarkAsRead?: (read: boolean) => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; } const SingleEmailItem = React.forwardRef( - function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) { + function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) { const t = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); const isUnread = !email.keywords?.$seen; @@ -90,9 +114,9 @@ const SingleEmailItem = React.forwardRef( ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; - const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const { tagName } = useKeywordFormat(); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + const { sortTagIds, tagColor } = useKeywordFormat(); + const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); @@ -112,14 +136,8 @@ const SingleEmailItem = React.forwardRef( ? formatDateTime(email.scheduledSendAt, timeFormat) : null; - // Resolve color tags using keyword definitions; unknown tags fall back to gray - const tagIds = getEmailColorTags(email.keywords); - const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); - const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; - const resolvedColorTag = !tintListRowsByTag ? null : (() => { - if (colorTag) return colorTag; - return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; - })(); + const tagIds = sortTagIds(getEmailTagIds(email.keywords)); + const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null)); const { dragHandlers, isDragging } = useEmailDrag({ email, @@ -174,16 +192,16 @@ const SingleEmailItem = React.forwardRef( data-unread={isUnread ? 'true' : 'false'} className={cn( "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", - resolvedColorTag ? resolvedColorTag : ( + resolvedRowTint ? resolvedRowTint : ( selected ? "bg-accent" : "bg-background" ), - selected && !resolvedColorTag && "shadow-sm", - !resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", - !resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", - resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110", - isUnread && !resolvedColorTag && "bg-accent/30", + selected && !resolvedRowTint && "shadow-sm", + !resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", + !resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", + resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110", + isUnread && !resolvedRowTint && "bg-accent/30", isChecked && "ring-2 ring-primary/20 bg-accent/40", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30" @@ -260,6 +278,13 @@ const SingleEmailItem = React.forwardRef( {sender?.name || sender?.email || 'Unknown'}
+ {tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} ( )} {email.hasAttachment && } - {resolvedKeywordDefs.map((kd) => ( - - ))} {showSourceFolder && } {scheduledSendLabel ? ( ( )}> {sender?.name || sender?.email || "Unknown"} + {tagPlacement === 'sender' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )}
{isPinned && ( @@ -353,15 +378,6 @@ const SingleEmailItem = React.forwardRef(
- {resolvedKeywordDefs.map((kd) => ( - - - {kd.label} - - ))} {showSourceFolder && } {scheduledSendLabel ? ( (
-
- {email.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {email.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -412,12 +437,12 @@ const SingleEmailItem = React.forwardRef( {!email.isScheduled && ( state.emailKeywords); - const { tagName } = useKeywordFormat(); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); - const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; - const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; + const { sortTagIds, tagColor } = useKeywordFormat(); + const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + // A collapsed row speaks for every message under it, so it carries their tags too. + const tagIds = sortTagIds(getThreadTagIds(thread.emails)); + const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null; const isSelected = selectedEmailId === latestEmail.id || thread.emails.some(e => e.id === selectedEmailId); @@ -525,12 +550,12 @@ export const ThreadListItem = React.forwardRef onEmailDoubleClick(latestEmail) : undefined} onContextMenu={onContextMenu} showPreview={showPreview} - colorTag={colorTag} + rowTint={rowTint} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} - onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} /> @@ -604,16 +629,16 @@ export const ThreadListItem = React.forwardRef )} {displayNames.join(', ')} - - - {emailCount} -
+ + + {tagIds.map((id) => ( + + ))} + )} {hasAttachment && } - {keywordDef && ( - - )} {showSourceFolder && } {scheduledSendLabel ? ( {displayNames.join(", ")} - - - {emailCount} + + + {tagPlacement === 'sender' && tagIds.map((id) => ( + + ))}
{hasPinned && ( @@ -833,15 +853,6 @@ export const ThreadListItem = React.forwardRef
- {keywordDef && ( - - - {keywordDef.label} - - )} {showSourceFolder && } {scheduledSendLabel ? (
-
- {latestEmail.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {latestEmail.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -892,12 +912,12 @@ export const ThreadListItem = React.forwardRef onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} - onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 31070e1d..f0c936a9 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -61,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; -import { useSettingsStore, KEYWORD_PALETTE, getKeywordVisibility } from "@/stores/settings-store"; +import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; @@ -557,22 +557,6 @@ function MailboxTreeItem({ ); } -const TAG_ICON_COLOR: Record = { - red: "text-red-600/75 dark:text-red-400/75", - orange: "text-orange-600/75 dark:text-orange-400/75", - yellow: "text-yellow-600/75 dark:text-yellow-400/75", - green: "text-green-600/75 dark:text-green-400/75", - blue: "text-blue-600/75 dark:text-blue-400/75", - purple: "text-purple-600/75 dark:text-purple-400/75", - pink: "text-pink-600/75 dark:text-pink-400/75", - teal: "text-teal-600/75 dark:text-teal-400/75", - cyan: "text-cyan-600/75 dark:text-cyan-400/75", - indigo: "text-indigo-600/75 dark:text-indigo-400/75", - amber: "text-amber-600/75 dark:text-amber-400/75", - lime: "text-lime-600/75 dark:text-lime-400/75", - gray: "text-gray-500", -}; - function ShowAllTagsRow({ hiddenCount, showAll, @@ -617,8 +601,8 @@ function TagItem({ colorful: boolean; }) { const t = useTranslations('notifications'); - const { tagNameCandidates } = useKeywordFormat(); - const palette = KEYWORD_PALETTE[node.color]; + const { tagNameCandidates, tagColor } = useKeywordFormat(); + const palette = tagColor(node.id); const hasChildren = node.children.length > 0; const isExpanded = expandedTags.has(node.id); const isSelected = selectedKeyword === node.id; @@ -644,12 +628,9 @@ function TagItem({ }); const tagIcon = colorful ? ( - + ) : ( - + ); return ( diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 625a6982..2a412f7e 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -236,7 +236,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { } }, [client, markAsRead]); - const handleSetColorTag = useCallback((emailId: string, color: string | null) => { + const handleSetTag = useCallback((emailId: string, tagId: string | null) => { if (!email || email.id !== emailId) return; // Drop existing color keywords, optionally add the new one. Matches the // mail page's local optimistic update. @@ -244,9 +244,8 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { for (const kw of settingsKeywords) { delete keywords[`$label:${kw.id}`]; } - if (color) { - const def = settingsKeywords.find((k) => k.color === color); - if (def) keywords[`$label:${def.id}`] = true; + if (tagId) { + keywords[`$label:${tagId}`] = true; } setEmailKeywordsLocal(emailId, keywords); setEmail({ ...email, keywords }); @@ -333,7 +332,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { onArchive={handleArchive} onToggleStar={handleToggleStar} onMarkAsRead={handleMarkAsRead} - onSetColorTag={handleSetColorTag} + onSetTag={handleSetTag} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} onEditDraft={handleEditDraft} diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index 4547663e..51beee54 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { useSettingsStore, KEYWORD_PALETTE, + KEYWORD_PALETTE_ROWS, getKeywordVisibility, type KeywordDefinition, type KeywordVisibility, @@ -25,11 +26,11 @@ import { type KeywordNode, MAX_KEYWORD_ID_LENGTH, } 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 { TagBadge } from "@/components/email/tag-badge"; -const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); - +/** Lighter, base and darker shade of each hue, one row per shade. */ function KeywordColorPicker({ value, onChange, @@ -38,19 +39,23 @@ function KeywordColorPicker({ onChange: (color: string) => void; }) { return ( -
- {PALETTE_KEYS.map((colorKey) => ( -
))}
); @@ -84,10 +89,7 @@ function KeywordRow({ isDragging: boolean; }) { const t = useTranslations("settings.keywords"); - const palette = KEYWORD_PALETTE[keyword.color]; 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. const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) .map((rendering) => KEYWORD_PREFIX + rendering); @@ -112,14 +114,9 @@ function KeywordRow({ )} > -
- - {shortenedName} - +
+ +
{ + 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']); + }); + }); +}); diff --git a/hooks/use-keyword-format.ts b/hooks/use-keyword-format.ts index f0f0b862..91c05bba 100644 --- a/hooks/use-keyword-format.ts +++ b/hooks/use-keyword-format.ts @@ -1,18 +1,22 @@ "use client"; 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"; /** - * 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 * 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 - * step the moment it is toggled: reading it straight from the store inside the - * formatter would leave every list showing stale names until something else - * happened to re-render them. + * someone who never asked for nesting. Subscribing to them also keeps tags in + * step the moment either changes: reading the store inside the formatter would + * leave every list stale until something else happened to re-render it. */ export function useKeywordFormat() { const keywords = useSettingsStore((state) => state.emailKeywords); @@ -22,8 +26,38 @@ export function useKeywordFormat() { () => ({ /** The tag's display name. */ tagName: (id: string) => formatKeyword(id, keywords, nested), + /** Its progressively shorter forms, longest first, for `useShortenedText`. */ tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)), + + /** + * The tag's colour. Falls back to grey for a keyword this client has no + * definition for - one created on another device, or whose tag was + * deleted - so such a tag still shows rather than silently vanishing. + */ + tagColor: (id: string): KeywordColor => { + const color = keywords.find((keyword) => keyword.id === id)?.color; + return (color ? KEYWORD_PALETTE[color] : undefined) ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR]; + }, + + /** + * Tag ids in the order the user arranged them in settings. + * + * The keywords on a message arrive as an unordered JMAP map, so without + * this the same two tags can swap places between rows. A tag with no + * local definition has no place in that order, so it sorts last, by name. + */ + sortTagIds: (ids: string[]): string[] => { + const rank = (id: string) => { + const index = keywords.findIndex((keyword) => keyword.id === id); + return index === -1 ? keywords.length : index; + }; + return [...ids].sort( + (a, b) => + rank(a) - rank(b) || + formatKeyword(a, keywords, nested).localeCompare(formatKeyword(b, keywords, nested)), + ); + }, }), [keywords, nested], ); diff --git a/hooks/use-tag-display.ts b/hooks/use-tag-display.ts new file mode 100644 index 00000000..0afdb186 --- /dev/null +++ b/hooks/use-tag-display.ts @@ -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(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): TagDisplay { + const [width, setWidth] = useState(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]); +} diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index 62cf41a2..5de19307 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -4,8 +4,9 @@ import { sortThreadGroups, getThreadParticipants, mergeThreadEmails, - getEmailColorTag, - getThreadColorTag, + getEmailTagId, + getThreadTagId, + getThreadTagIds, } from '../thread-utils'; import type { Email, ThreadGroup } from '../jmap/types'; @@ -245,47 +246,47 @@ describe('mergeThreadEmails', () => { }); }); -describe('getEmailColorTag', () => { +describe('getEmailTagId', () => { 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', () => { - 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', () => { - expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull(); + expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull(); }); it('returns null for undefined keywords', () => { - expect(getEmailColorTag(undefined)).toBeNull(); + expect(getEmailTagId(undefined)).toBeNull(); }); it('ignores keywords set to false', () => { - expect(getEmailColorTag({ '$label:red': false } as unknown as Record)).toBeNull(); + expect(getEmailTagId({ '$label:red': false } as unknown as Record)).toBeNull(); }); 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', () => { - 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', () => { - expect(getEmailColorTag({})).toBeNull(); + expect(getEmailTagId({})).toBeNull(); }); }); -describe('getThreadColorTag', () => { +describe('getThreadTagId', () => { it('returns first color found across thread emails', () => { const emails = [ makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e2', keywords: { '$label:blue': true } }), ]; - expect(getThreadColorTag(emails)).toBe('blue'); + expect(getThreadTagId(emails)).toBe('blue'); }); it('returns null when no emails have color tags', () => { @@ -293,7 +294,7 @@ describe('getThreadColorTag', () => { makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e2', keywords: { $flagged: true } }), ]; - expect(getThreadColorTag(emails)).toBeNull(); + expect(getThreadTagId(emails)).toBeNull(); }); it('returns first tag from earliest tagged email', () => { @@ -301,7 +302,7 @@ describe('getThreadColorTag', () => { makeEmail({ id: 'e1', keywords: { '$label:red': true } }), makeEmail({ id: 'e2', keywords: { '$label:blue': true } }), ]; - expect(getThreadColorTag(emails)).toBe('red'); + expect(getThreadTagId(emails)).toBe('red'); }); it('returns legacy tag from thread emails', () => { @@ -309,10 +310,41 @@ describe('getThreadColorTag', () => { makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e2', keywords: { '$color:green': true } }), ]; - expect(getThreadColorTag(emails)).toBe('green'); + expect(getThreadTagId(emails)).toBe('green'); }); it('returns null for empty email array', () => { - expect(getThreadColorTag([])).toBeNull(); + expect(getThreadTagId([])).toBeNull(); + }); +}); + +describe('getThreadTagIds', () => { + it('gathers the tags of every message in the thread', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }), + ]; + expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']); + }); + + it('reports a tag shared by several messages once', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', keywords: { '$label:red': true } }), + ]; + expect(getThreadTagIds(emails)).toEqual(['red']); + }); + + it('reads the legacy prefix alongside the current one', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { '$color:green': true } }), + makeEmail({ id: 'e2', keywords: { '$label:red': true } }), + ]; + expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']); + }); + + it('is empty for an untagged or empty thread', () => { + expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]); + expect(getThreadTagIds([])).toEqual([]); }); }); diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 41037492..4a152568 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -168,10 +168,10 @@ export const KEYWORD_PREFIX = "$label:"; export const KEYWORD_PREFIX_LEGACY = "$color:"; /** - * Gets all active label/color tag IDs from email keywords. + * Gets every tag id set on a message. * Reads both the current $label: prefix and the legacy $color: prefix. */ -export function getEmailColorTags(keywords: Record | undefined): string[] { +export function getEmailTagIds(keywords: Record | undefined): string[] { if (!keywords) return []; const tags: string[] = []; for (const key of Object.keys(keywords)) { @@ -187,22 +187,39 @@ export function getEmailColorTags(keywords: Record | 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. - * @deprecated Use getEmailColorTags for multi-tag support. + * @deprecated Use getEmailTagIds for multi-tag support. */ -export function getEmailColorTag(keywords: Record | undefined): string | null { - const tags = getEmailColorTags(keywords); +export function getEmailTagId(keywords: Record | undefined): string | null { + const tags = getEmailTagIds(keywords); return tags.length > 0 ? tags[0] : null; } /** - * Checks if a thread has any color tag (returns first found). + * The first tag id found anywhere in a thread, if any. */ -export function getThreadColorTag(emails: Email[]): string | null { +export function getThreadTagId(emails: Email[]): string | null { for (const email of emails) { - const color = getEmailColorTag(email.keywords); + const color = getEmailTagId(email.keywords); if (color) return color; } return null; } + +/** + * Every tag anywhere in a thread, deduplicated. + * + * A collapsed thread row stands in for all its messages, so it has to account + * for all their tags - showing only the first message's would hide the rest + * with nothing to indicate they exist. + */ +export function getThreadTagIds(emails: Email[]): string[] { + const tags = new Set(); + for (const email of emails) { + for (const tag of getEmailTagIds(email.keywords)) { + tags.add(tag); + } + } + return [...tags]; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 17ac17c6..23d15d86 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -325,13 +325,13 @@ "view_contact": "عرض جهة الاتصال", "message_details": "تفاصيل الرسالة", "more_reply_options": "خيارات رد إضافية", - "set_color": "تعيين وسم", + "set_tag": "تعيين وسم", "tag": "وسم", "more_actions": "المزيد من الإجراءات", "previous": "السابق", "next": "التالي", "move_to": "نقل إلى...", - "remove_color": "إزالة الوسم", + "remove_tag": "إزالة الوسم", "more_count": "+{count} أخرى", "characters_count": "{count} حرفًا", "quick_reply_placeholder": "اكتب ردًا سريعًا...", @@ -425,17 +425,6 @@ "message_id": "معرّف الرسالة", "list_info": "معلومات القائمة" }, - "color_tag": { - "title": "وسم لوني", - "red": "أحمر", - "orange": "برتقالي", - "yellow": "أصفر", - "green": "أخضر", - "blue": "أزرق", - "purple": "بنفسجي", - "pink": "وردي", - "none": "بلا" - }, "tooltips": { "reply": "رد (r)", "reply_all": "الرد على الجميع (a)", @@ -2033,8 +2022,7 @@ "delete": "حذف", "mark_as_spam": "الإبلاغ عن بريد مزعج", "not_spam": "ليس مزعجًا", - "color_tag": "وسم", - "remove_color": "إزالة الوسم", + "tag": "وسم", "items_selected": "{count} رسالة محددة", "edit_draft": "تعديل المسودة", "cancel_scheduled_send": "إلغاء الإرسال", diff --git a/locales/ca/common.json b/locales/ca/common.json index 3845877a..97919417 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -325,13 +325,13 @@ "view_contact": "Mostra el contacte", "message_details": "Detalls del missatge", "more_reply_options": "Més opcions de resposta", - "set_color": "Estableix l'etiqueta", + "set_tag": "Estableix l'etiqueta", "tag": "Etiqueta", "more_actions": "Més accions", "previous": "Anterior", "next": "Següent", "move_to": "Mou a...", - "remove_color": "Elimina l'etiqueta", + "remove_tag": "Elimina l'etiqueta", "more_count": "+{count} més", "characters_count": "{count} caràcters", "quick_reply_placeholder": "Escriviu una resposta ràpida...", @@ -425,17 +425,6 @@ "message_id": "ID del missatge", "list_info": "Informació de la llista" }, - "color_tag": { - "title": "Etiqueta de color", - "red": "Vermell", - "orange": "Taronja", - "yellow": "Groc", - "green": "Verd", - "blue": "Blau", - "purple": "Lila", - "pink": "Rosa", - "none": "Cap" - }, "tooltips": { "reply": "Respon (r)", "reply_all": "Respon a tots (a)", @@ -2001,8 +1990,7 @@ "delete": "Suprimeix", "mark_as_spam": "Denuncia com a brossa", "not_spam": "No és brossa", - "color_tag": "Etiqueta", - "remove_color": "Elimina l'etiqueta", + "tag": "Etiqueta", "items_selected": "{count} correus seleccionats", "edit_draft": "Edita l'esborrany", "cancel_scheduled_send": "Cancel·la l'enviament", diff --git a/locales/cs/common.json b/locales/cs/common.json index 8f76d8ac..a8886132 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -325,13 +325,13 @@ "view_contact": "Zobrazit kontakt", "message_details": "Podrobnosti zprávy", "more_reply_options": "Další možnosti odpovědi", - "set_color": "Nastavit štítek", + "set_tag": "Nastavit štítek", "tag": "Štítek", "more_actions": "Další akce", "previous": "Předchozí", "next": "Další", "move_to": "Přesunout do...", - "remove_color": "Odebrat štítek", + "remove_tag": "Odebrat štítek", "more_count": "+{count} dalších", "characters_count": "{count} znaků", "quick_reply_placeholder": "Napsat rychlou odpověď...", @@ -400,17 +400,6 @@ "message_id": "ID zprávy", "list_info": "Informace o konferenci" }, - "color_tag": { - "title": "Barevný štítek", - "red": "Červený", - "orange": "Oranžový", - "yellow": "Žlutý", - "green": "Zelený", - "blue": "Modrý", - "purple": "Fialový", - "pink": "Růžový", - "none": "Žádný" - }, "tooltips": { "reply": "Odpovědět (r)", "reply_all": "Odpovědět všem (a)", @@ -2033,8 +2022,7 @@ "delete": "Odstranit", "mark_as_spam": "Nahlásit spam", "not_spam": "Není spam", - "color_tag": "Štítek", - "remove_color": "Odebrat štítek", + "tag": "Štítek", "items_selected": "{count} vybraných zpráv", "edit_draft": "Upravit koncept", "cancel_scheduled_send": "Zrušit odeslání", diff --git a/locales/da/common.json b/locales/da/common.json index 8fa78adb..44aa3d90 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -325,13 +325,13 @@ "view_contact": "Vis kontakt", "message_details": "Beskeddetaljer", "more_reply_options": "Flere svar-muligheder", - "set_color": "Sæt tag", + "set_tag": "Sæt tag", "tag": "Tag", "more_actions": "Flere handlinger", "previous": "Forrige", "next": "Næste", "move_to": "Flyt til...", - "remove_color": "Fjern tag", + "remove_tag": "Fjern tag", "more_count": "+{count} mere", "characters_count": "{count} tegn", "quick_reply_placeholder": "Skriv et hurtigt svar...", @@ -425,17 +425,6 @@ "message_id": "Besked-ID", "list_info": "Listeinformation" }, - "color_tag": { - "title": "Farvetag", - "red": "Rød", - "orange": "Orange", - "yellow": "Gul", - "green": "Grøn", - "blue": "Blå", - "purple": "Lilla", - "pink": "Pink", - "none": "Ingen" - }, "tooltips": { "reply": "Svar (r)", "reply_all": "Svar alle (a)", @@ -2033,8 +2022,7 @@ "delete": "Slet", "mark_as_spam": "Rapportér spam", "not_spam": "Ikke spam", - "color_tag": "Tag", - "remove_color": "Fjern tag", + "tag": "Tag", "items_selected": "{count} e-mails valgt", "edit_draft": "Redigér kladde", "cancel_scheduled_send": "Annuller afsendelse", diff --git a/locales/de/common.json b/locales/de/common.json index 1d4ea7ad..20067cbc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -325,11 +325,11 @@ "view_contact": "Kontakt anzeigen", "message_details": "Nachrichtendetails", "more_reply_options": "Weitere Antwortoptionen", - "set_color": "Label setzen", + "set_tag": "Label setzen", "tag": "Label", "more_actions": "Weitere Aktionen", "move_to": "Verschieben nach...", - "remove_color": "Label entfernen", + "remove_tag": "Label entfernen", "more_count": "+{count} weitere", "characters_count": "{count} Zeichen", "quick_reply_placeholder": "Eine kurze Antwort schreiben...", @@ -398,17 +398,6 @@ "message_id": "Nachrichten-ID", "list_info": "Listeninformationen" }, - "color_tag": { - "title": "Farb-Tag", - "red": "Rot", - "orange": "Orange", - "yellow": "Gelb", - "green": "Grün", - "blue": "Blau", - "purple": "Violett", - "pink": "Rosa", - "none": "Keine" - }, "tooltips": { "reply": "Antworten", "reply_all": "Allen antworten (a)", @@ -2033,8 +2022,7 @@ "delete": "Löschen", "mark_as_spam": "Spam melden", "not_spam": "Kein Spam", - "color_tag": "Label", - "remove_color": "Label entfernen", + "tag": "Label", "items_selected": "{count} E-Mails ausgewählt", "edit_draft": "Entwurf bearbeiten", "cancel_scheduled_send": "Senden abbrechen", diff --git a/locales/en/common.json b/locales/en/common.json index 5555f2c6..96e19eb7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -327,13 +327,15 @@ "view_contact": "View contact", "message_details": "Message Details", "more_reply_options": "More reply options", - "set_color": "Set tag", + "set_tag": "Set tag", "tag": "Tag", "more_actions": "More actions", "previous": "Prev", "next": "Next", "move_to": "Move to...", - "remove_color": "Remove tag", + "remove_tag": "Remove tag", + "tag_filter_placeholder": "Filter tags", + "tag_no_matches": "No matching tags", "more_count": "+{count} more", "characters_count": "{count} characters", "quick_reply_placeholder": "Write a quick reply...", @@ -427,17 +429,6 @@ "message_id": "Message ID", "list_info": "List Information" }, - "color_tag": { - "title": "Color Tag", - "red": "Red", - "orange": "Orange", - "yellow": "Yellow", - "green": "Green", - "blue": "Blue", - "purple": "Purple", - "pink": "Pink", - "none": "None" - }, "tooltips": { "reply": "Reply (r)", "reply_all": "Reply All (a)", @@ -1019,7 +1010,7 @@ }, "keywords": { "title": "Email Tags", - "description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.", + "description": "Define tags to organize your emails. These are stored as JMAP keywords on the server.", "add_keyword": "Add Tag", "label_field": "Display Name", "label_placeholder": "e.g. Work, Personal, Urgent", @@ -2050,8 +2041,7 @@ "delete": "Delete", "mark_as_spam": "Report spam", "not_spam": "Not spam", - "color_tag": "Tag", - "remove_color": "Remove tag", + "tag": "Tag", "items_selected": "{count} emails selected", "edit_draft": "Edit Draft", "cancel_scheduled_send": "Cancel send", diff --git a/locales/es/common.json b/locales/es/common.json index 3f76eabe..4c2a3da9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -325,11 +325,11 @@ "view_contact": "Ver contacto", "message_details": "Detalles del Mensaje", "more_reply_options": "Más opciones de respuesta", - "set_color": "Establecer etiqueta", + "set_tag": "Establecer etiqueta", "tag": "Etiqueta", "more_actions": "Más acciones", "move_to": "Mover a...", - "remove_color": "Eliminar etiqueta", + "remove_tag": "Eliminar etiqueta", "more_count": "+{count} más", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escriba una respuesta rápida...", @@ -398,17 +398,6 @@ "message_id": "ID del Mensaje", "list_info": "Información de Lista" }, - "color_tag": { - "title": "Etiqueta de Color", - "red": "Rojo", - "orange": "Naranja", - "yellow": "Amarillo", - "green": "Verde", - "blue": "Azul", - "purple": "Morado", - "pink": "Rosa", - "none": "Ninguno" - }, "tooltips": { "reply": "Responder", "reply_all": "Responder a todos (a)", @@ -2033,8 +2022,7 @@ "delete": "Eliminar", "mark_as_spam": "Reportar spam", "not_spam": "No es spam", - "color_tag": "Etiqueta", - "remove_color": "Eliminar etiqueta", + "tag": "Etiqueta", "items_selected": "{count} correos seleccionados", "edit_draft": "Editar borrador", "cancel_scheduled_send": "Cancelar envío", diff --git a/locales/fa/common.json b/locales/fa/common.json index 31fcb160..4599c53b 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -325,13 +325,13 @@ "view_contact": "مشاهده مخاطب", "message_details": "جزئیات پیام", "more_reply_options": "گزینه‌های بیشتر پاسخ", - "set_color": "تنظیم برچسب", + "set_tag": "تنظیم برچسب", "tag": "برچسب", "more_actions": "عملیات بیشتر", "previous": "قبلی", "next": "بعدی", "move_to": "انتقال به...", - "remove_color": "حذف برچسب", + "remove_tag": "حذف برچسب", "more_count": "+{count} بیشتر", "characters_count": "{count} کاراکتر", "quick_reply_placeholder": "پاسخ سریع بنویسید...", @@ -425,17 +425,6 @@ "message_id": "شناسه پیام", "list_info": "اطلاعات لیست" }, - "color_tag": { - "title": "برچسب رنگی", - "red": "قرمز", - "orange": "نارنجی", - "yellow": "زرد", - "green": "سبز", - "blue": "آبی", - "purple": "بنفش", - "pink": "صورتی", - "none": "هیچکدام" - }, "tooltips": { "reply": "پاسخ (r)", "reply_all": "پاسخ به همه (a)", @@ -2033,8 +2022,7 @@ "delete": "حذف", "mark_as_spam": "گزارش هرزنامه", "not_spam": "هرزنامه نیست", - "color_tag": "برچسب", - "remove_color": "حذف برچسب", + "tag": "برچسب", "items_selected": "{count} ایمیل انتخاب شده", "edit_draft": "ویرایش پیش‌نویس", "cancel_scheduled_send": "لغو ارسال", diff --git a/locales/fr/common.json b/locales/fr/common.json index 7a7145af..cf60e2df 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -325,11 +325,11 @@ "view_contact": "Voir le contact", "message_details": "Détails du message", "more_reply_options": "Plus d'options de réponse", - "set_color": "Définir l'étiquette", + "set_tag": "Définir l'étiquette", "tag": "Étiquette", "more_actions": "Plus d'actions", "move_to": "Déplacer vers...", - "remove_color": "Retirer l'étiquette", + "remove_tag": "Retirer l'étiquette", "more_count": "+{count} de plus", "characters_count": "{count} caractères", "quick_reply_placeholder": "Écrivez une réponse rapide...", @@ -398,17 +398,6 @@ "message_id": "ID du message", "list_info": "Information de liste" }, - "color_tag": { - "title": "Étiquette de couleur", - "red": "Rouge", - "orange": "Orange", - "yellow": "Jaune", - "green": "Vert", - "blue": "Bleu", - "purple": "Violet", - "pink": "Rose", - "none": "Aucune" - }, "tooltips": { "reply": "Répondre", "reply_all": "Répondre à tous (a)", @@ -2033,8 +2022,7 @@ "delete": "Supprimer", "mark_as_spam": "Signaler comme spam", "not_spam": "Pas un spam", - "color_tag": "Étiquette", - "remove_color": "Supprimer l'étiquette", + "tag": "Étiquette", "items_selected": "{count} emails sélectionnés", "edit_draft": "Modifier le brouillon", "cancel_scheduled_send": "Annuler l’envoi", diff --git a/locales/he/common.json b/locales/he/common.json index 52d48ed9..918ccec8 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -272,13 +272,13 @@ "view_contact": "הצג איש קשר", "message_details": "פרטי הודעה", "more_reply_options": "אפשרויות תשובה נוספות", - "set_color": "הגדר תג", + "set_tag": "הגדר תג", "tag": "תג", "more_actions": "עוד פעולות", "previous": "הקודם", "next": "הבא", "move_to": "העבר ל...", - "remove_color": "הסר תג", + "remove_tag": "הסר תג", "more_count": "+{count}נוספים", "characters_count": "{count} תווים", "quick_reply_placeholder": "תשובה מהירה", @@ -347,17 +347,6 @@ "message_id": "מזהה הודעה", "list_info": "רשימת מידע" }, - "color_tag": { - "title": "תג צבע", - "red": "אדום", - "orange": "כתום", - "yellow": "צהוב", - "green": "ירוק", - "blue": "כחול", - "purple": "סגול", - "pink": "ורוד", - "none": "אין" - }, "tooltips": { "reply": "תשובה (ר)", "reply_all": "השב לכולם (א)", @@ -1999,8 +1988,7 @@ "delete": "לִמְחוֹק", "mark_as_spam": "דווח על ספאם", "not_spam": "לא ספאם", - "color_tag": "תווית", - "remove_color": "הסר תווית", + "tag": "תווית", "items_selected": "נבחרו הודעות דוא\"ל מסוג{count}", "edit_draft": "ערוך טיוטה", "cancel_scheduled_send": "ביטול שליחה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2a1a8361..790f4e8a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -325,13 +325,13 @@ "view_contact": "Névjegy megtekintése", "message_details": "Üzenet részletei", "more_reply_options": "További válasz opciók", - "set_color": "Címke beállítása", + "set_tag": "Címke beállítása", "tag": "Címke", "more_actions": "További műveletek", "previous": "Előző", "next": "Következő", "move_to": "Áthelyezés ide...", - "remove_color": "Címke eltávolítása", + "remove_tag": "Címke eltávolítása", "more_count": "+{count} további", "characters_count": "{count} karakter", "quick_reply_placeholder": "Gyors válasz írása...", @@ -425,17 +425,6 @@ "message_id": "Üzenet azonosító", "list_info": "Lista információk" }, - "color_tag": { - "title": "Színes címke", - "red": "Piros", - "orange": "Narancs", - "yellow": "Sárga", - "green": "Zöld", - "blue": "Kék", - "purple": "Lila", - "pink": "Rózsaszín", - "none": "Nincs" - }, "tooltips": { "reply": "Válasz (r)", "reply_all": "Válasz mindenkinek (a)", @@ -2033,8 +2022,7 @@ "delete": "Törlés", "mark_as_spam": "Spam jelentése", "not_spam": "Nem spam", - "color_tag": "Címke", - "remove_color": "Címke eltávolítása", + "tag": "Címke", "items_selected": "{count} e-mail kijelölve", "edit_draft": "Piszkozat szerkesztése", "cancel_scheduled_send": "Küldés megszakítása", diff --git a/locales/it/common.json b/locales/it/common.json index 1eff3cac..d43d911b 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -325,11 +325,11 @@ "view_contact": "Visualizza contatto", "message_details": "Dettagli del messaggio", "more_reply_options": "Più opzioni di risposta", - "set_color": "Imposta etichetta", + "set_tag": "Imposta etichetta", "tag": "Etichetta", "more_actions": "Altre azioni", "move_to": "Sposta in...", - "remove_color": "Rimuovi etichetta", + "remove_tag": "Rimuovi etichetta", "more_count": "+{count} altri", "characters_count": "{count} caratteri", "quick_reply_placeholder": "Scrivi una risposta veloce...", @@ -398,17 +398,6 @@ "message_id": "ID messaggio", "list_info": "Informazioni lista" }, - "color_tag": { - "title": "Etichetta colore", - "red": "Rosso", - "orange": "Arancione", - "yellow": "Giallo", - "green": "Verde", - "blue": "Blu", - "purple": "Viola", - "pink": "Rosa", - "none": "Nessuno" - }, "tooltips": { "reply": "Rispondi", "reply_all": "Rispondi a tutti (a)", @@ -2033,8 +2022,7 @@ "delete": "Elimina", "mark_as_spam": "Segnala come spam", "not_spam": "Non spam", - "color_tag": "Etichetta", - "remove_color": "Rimuovi etichetta", + "tag": "Etichetta", "items_selected": "{count} messaggi selezionati", "edit_draft": "Modifica bozza", "cancel_scheduled_send": "Annulla invio", diff --git a/locales/ja/common.json b/locales/ja/common.json index df35f292..2502dfa7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -325,11 +325,11 @@ "view_contact": "連絡先を表示", "message_details": "メッセージの詳細", "more_reply_options": "その他の返信オプション", - "set_color": "ラベルを設定", + "set_tag": "ラベルを設定", "tag": "ラベル", "more_actions": "その他の操作", "move_to": "移動...", - "remove_color": "ラベルを削除", + "remove_tag": "ラベルを削除", "more_count": "他{count}件", "characters_count": "{count}文字", "quick_reply_placeholder": "クイック返信を入力...", @@ -398,17 +398,6 @@ "message_id": "メッセージID", "list_info": "リスト情報" }, - "color_tag": { - "title": "カラータグ", - "red": "赤", - "orange": "オレンジ", - "yellow": "黄色", - "green": "緑", - "blue": "青", - "purple": "紫", - "pink": "ピンク", - "none": "なし" - }, "tooltips": { "reply": "返信", "reply_all": "全員に返信 (a)", @@ -2033,8 +2022,7 @@ "delete": "削除", "mark_as_spam": "迷惑メールを報告", "not_spam": "迷惑メールでない", - "color_tag": "ラベル", - "remove_color": "ラベルを削除", + "tag": "ラベル", "items_selected": "{count}件のメールを選択", "edit_draft": "下書きを編集", "cancel_scheduled_send": "送信をキャンセル", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6f9547ba..bfb65416 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -325,13 +325,13 @@ "view_contact": "연락처 보기", "message_details": "메시지 상세 정보", "more_reply_options": "답장 옵션 더보기", - "set_color": "태그 설정", + "set_tag": "태그 설정", "tag": "태그", "more_actions": "작업 더보기", "previous": "이전", "next": "다음", "move_to": "이동...", - "remove_color": "태그 제거", + "remove_tag": "태그 제거", "more_count": "+{count}개 더보기", "characters_count": "{count}자", "quick_reply_placeholder": "간단하게 답장을 작성해 보세요...", @@ -400,17 +400,6 @@ "message_id": "메시지 ID", "list_info": "목록 정보" }, - "color_tag": { - "title": "색상 태그", - "red": "빨간색", - "orange": "주황색", - "yellow": "노란색", - "green": "초록색", - "blue": "파란색", - "purple": "보라색", - "pink": "분홍색", - "none": "없음" - }, "tooltips": { "reply": "답장 (r)", "reply_all": "전체 답장 (a)", @@ -2033,8 +2022,7 @@ "delete": "삭제", "mark_as_spam": "스팸 신고", "not_spam": "정상 메일", - "color_tag": "태그", - "remove_color": "태그 제거", + "tag": "태그", "items_selected": "{count}개의 메일 선택됨", "edit_draft": "임시보관 메일 수정", "cancel_scheduled_send": "보내기 취소", diff --git a/locales/lv/common.json b/locales/lv/common.json index 9f980f28..c742af88 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -325,13 +325,13 @@ "view_contact": "Skatīt kontaktu", "message_details": "Informācija par ziņojumu", "more_reply_options": "Papildu atbildēšanas iespējas", - "set_color": "Iestatīt tagu", + "set_tag": "Iestatīt tagu", "tag": "Tags", "more_actions": "Citas darbības", "previous": "Iepr.", "next": "Nāk.", "move_to": "Pārvietot uz...", - "remove_color": "Noņemt tagu", + "remove_tag": "Noņemt tagu", "more_count": "+vairāk {count}", "characters_count": "{count} rakstzīmes", "quick_reply_placeholder": "Rakstīt ātru atbildi...", @@ -400,17 +400,6 @@ "message_id": "Ziņojuma ID", "list_info": "Informācija par adresātu sarakstu" }, - "color_tag": { - "title": "Krāsu tags", - "red": "Sarkans", - "orange": "Oranžs", - "yellow": "Dzeltens", - "green": "Zaļš", - "blue": "Zils", - "purple": "Violets", - "pink": "Rozā", - "none": "Nav" - }, "tooltips": { "reply": "Atbildēt (r)", "reply_all": "Atbildēt visiem (a)", @@ -2033,8 +2022,7 @@ "delete": "Dzēst", "mark_as_spam": "Atzīmēt kā mēstuli", "not_spam": "Nav mēstule", - "color_tag": "Tags", - "remove_color": "Noņemt tagu", + "tag": "Tags", "items_selected": "{count} vēstules atlasītas", "edit_draft": "Rediģēt melnrakstu", "cancel_scheduled_send": "Atcelt sūtīšanu", diff --git a/locales/nl/common.json b/locales/nl/common.json index 05966a6f..8048229b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -327,11 +327,13 @@ "view_contact": "Contact bekijken", "message_details": "Berichtdetails", "more_reply_options": "Meer antwoordopties", - "set_color": "Label instellen", + "set_tag": "Label instellen", "tag": "Label", "more_actions": "Meer acties", "move_to": "Verplaatsen naar...", - "remove_color": "Label verwijderen", + "remove_tag": "Label verwijderen", + "tag_filter_placeholder": "Labels filteren", + "tag_no_matches": "Geen overeenkomende labels", "more_count": "+{count} meer", "characters_count": "{count} tekens", "quick_reply_placeholder": "Schrijf een snel antwoord...", @@ -400,17 +402,6 @@ "message_id": "Bericht-ID", "list_info": "Lijstinformatie" }, - "color_tag": { - "title": "Kleurtag", - "red": "Rood", - "orange": "Oranje", - "yellow": "Geel", - "green": "Groen", - "blue": "Blauw", - "purple": "Paars", - "pink": "Roze", - "none": "Geen" - }, "tooltips": { "reply": "Beantwoorden", "reply_all": "Allen beantwoorden (a)", @@ -1016,7 +1007,7 @@ }, "keywords": { "title": "E-maillabels", - "description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.", + "description": "Definieer labels om uw e-mails te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.", "add_keyword": "Label toevoegen", "label_field": "Weergavenaam", "label_placeholder": "bijv. Werk, Persoonlijk, Urgent", @@ -2050,8 +2041,7 @@ "delete": "Verwijderen", "mark_as_spam": "Spam melden", "not_spam": "Geen spam", - "color_tag": "Label", - "remove_color": "Label verwijderen", + "tag": "Label", "items_selected": "{count} e-mails geselecteerd", "edit_draft": "Concept bewerken", "cancel_scheduled_send": "Verzenden annuleren", diff --git a/locales/pl/common.json b/locales/pl/common.json index 3cd54d10..9378c706 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -325,13 +325,13 @@ "view_contact": "Pokaż kontakt", "message_details": "Szczegóły wiadomości", "more_reply_options": "Więcej opcji odpowiedzi", - "set_color": "Ustaw etykietę", + "set_tag": "Ustaw etykietę", "tag": "Etykieta", "more_actions": "Więcej działań", "previous": "Poprz.", "next": "Nast.", "move_to": "Przenieś do...", - "remove_color": "Usuń etykietę", + "remove_tag": "Usuń etykietę", "more_count": "+{count} więcej", "characters_count": "{count} znaków", "quick_reply_placeholder": "Napisz szybką odpowiedź...", @@ -400,17 +400,6 @@ "message_id": "ID wiadomości", "list_info": "Informacje o liście" }, - "color_tag": { - "title": "Kolorowa etykieta", - "red": "Czerwony", - "orange": "Pomarańczowy", - "yellow": "Żółty", - "green": "Zielony", - "blue": "Niebieski", - "purple": "Fioletowy", - "pink": "Różowy", - "none": "Brak" - }, "tooltips": { "reply": "Odpowiedz (r)", "reply_all": "Odpowiedz wszystkim (a)", @@ -2033,8 +2022,7 @@ "delete": "Usuń", "mark_as_spam": "Zgłoś spam", "not_spam": "To nie spam", - "color_tag": "Etykieta", - "remove_color": "Usuń etykietę", + "tag": "Etykieta", "items_selected": "{count} zaznaczonych wiadomości", "edit_draft": "Edytuj szkic", "cancel_scheduled_send": "Anuluj wysyłkę", diff --git a/locales/pt/common.json b/locales/pt/common.json index 42b5e2de..6bbd2b2e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -325,11 +325,11 @@ "view_contact": "Ver contato", "message_details": "Detalhes da Mensagem", "more_reply_options": "Mais opções de resposta", - "set_color": "Definir etiqueta", + "set_tag": "Definir etiqueta", "tag": "Etiqueta", "more_actions": "Mais ações", "move_to": "Mover para...", - "remove_color": "Remover etiqueta", + "remove_tag": "Remover etiqueta", "more_count": "+{count} mais", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escreva uma resposta rápida...", @@ -398,17 +398,6 @@ "message_id": "ID da Mensagem", "list_info": "Informações da Lista" }, - "color_tag": { - "title": "Etiqueta de Cor", - "red": "Vermelho", - "orange": "Laranja", - "yellow": "Amarelo", - "green": "Verde", - "blue": "Azul", - "purple": "Roxo", - "pink": "Rosa", - "none": "Nenhuma" - }, "tooltips": { "reply": "Responder", "reply_all": "Responder a todos (a)", @@ -2033,8 +2022,7 @@ "delete": "Excluir", "mark_as_spam": "Reportar spam", "not_spam": "Não é spam", - "color_tag": "Etiqueta", - "remove_color": "Remover etiqueta", + "tag": "Etiqueta", "items_selected": "{count} e-mails selecionados", "edit_draft": "Editar rascunho", "cancel_scheduled_send": "Cancelar envio", diff --git a/locales/ro/common.json b/locales/ro/common.json index 02f011a4..12968735 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -325,13 +325,13 @@ "view_contact": "Vizualizare contact", "message_details": "Detalii mesaj", "more_reply_options": "Mai multe opțiuni de răspuns", - "set_color": "Setați eticheta", + "set_tag": "Setați eticheta", "tag": "Etichetă", "more_actions": "Alte acțiuni", "previous": "Anterior", "next": "Următorul", "move_to": "Mergi la...", - "remove_color": "Eliminați eticheta", + "remove_tag": "Eliminați eticheta", "more_count": "+{count} mai multe", "characters_count": "{count} caractere", "quick_reply_placeholder": "Scrie un răspuns rapid...", @@ -425,17 +425,6 @@ "message_id": "IDul mesajelor", "list_info": "Informații despre listă" }, - "color_tag": { - "title": "Etichetă de culoare", - "red": "Roșu", - "orange": "Portocaliu", - "yellow": "Galben", - "green": "Verde", - "blue": "Albastru", - "purple": "Violet", - "pink": "Roz", - "none": "Niciunul" - }, "tooltips": { "reply": "Răspunde (r)", "reply_all": "Răspunde tuturor (a)", @@ -2033,8 +2022,7 @@ "delete": "Șterge", "mark_as_spam": "Raportează spamul", "not_spam": "Nu este spam", - "color_tag": "Etichetă", - "remove_color": "Eliminați eticheta", + "tag": "Etichetă", "items_selected": "{count} e-mailuri selectate", "edit_draft": "Editează schița", "cancel_scheduled_send": "Anulează trimiterea", diff --git a/locales/ru/common.json b/locales/ru/common.json index 878c9af6..9302e3a6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -325,13 +325,13 @@ "view_contact": "Просмотреть контакт", "message_details": "Детали сообщения", "more_reply_options": "Дополнительные параметры ответа", - "set_color": "Установить тег", + "set_tag": "Установить тег", "tag": "Тег", "more_actions": "Другие действия", "previous": "Пред.", "next": "След.", "move_to": "Переместить в...", - "remove_color": "Удалить тег", + "remove_tag": "Удалить тег", "more_count": "+{count} ещё", "characters_count": "{count} символов", "quick_reply_placeholder": "Написать быстрый ответ...", @@ -400,17 +400,6 @@ "message_id": "Идентификатор сообщения", "list_info": "Информация о рассылке" }, - "color_tag": { - "title": "Цветной тег", - "red": "Красный", - "orange": "Оранжевый", - "yellow": "Жёлтый", - "green": "Зелёный", - "blue": "Синий", - "purple": "Фиолетовый", - "pink": "Розовый", - "none": "Нет" - }, "tooltips": { "reply": "Ответить (r)", "reply_all": "Ответить всем (a)", @@ -2033,8 +2022,7 @@ "delete": "Удалить", "mark_as_spam": "Отметить как спам", "not_spam": "Не спам", - "color_tag": "Тег", - "remove_color": "Удалить тег", + "tag": "Тег", "items_selected": "{count} писем выбрано", "edit_draft": "Редактировать черновик", "cancel_scheduled_send": "Отменить отправку", diff --git a/locales/sk/common.json b/locales/sk/common.json index 3d10b558..0d2fba68 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -325,13 +325,13 @@ "view_contact": "Zobraziť kontakt", "message_details": "Podrobnosti správy", "more_reply_options": "Viac možností odpovede", - "set_color": "Nastaviť štítok", + "set_tag": "Nastaviť štítok", "tag": "Štítok", "more_actions": "Viac akcií", "previous": "Predchádzajúci", "next": "Ďalší", "move_to": "Presunúť do...", - "remove_color": "Odstrániť štítok", + "remove_tag": "Odstrániť štítok", "more_count": "+{count} ďalších", "characters_count": "{count} znakov", "quick_reply_placeholder": "Napísať rýchlu odpoveď...", @@ -425,17 +425,6 @@ "message_id": "ID správy", "list_info": "Informácie o zozname" }, - "color_tag": { - "title": "Farebný štítok", - "red": "Červený", - "orange": "Oranžový", - "yellow": "Žltý", - "green": "Zelený", - "blue": "Modrý", - "purple": "Fialový", - "pink": "RŪžový", - "none": "Žiadny" - }, "tooltips": { "reply": "Odpovedať (r)", "reply_all": "Odpovedať všetkým (a)", @@ -2033,8 +2022,7 @@ "delete": "Odstrániť", "mark_as_spam": "Nahlásiť spam", "not_spam": "Nie je spam", - "color_tag": "Štítok", - "remove_color": "Odstrániť štítok", + "tag": "Štítok", "items_selected": "{count} vybraných e-mailov", "edit_draft": "Upraviť koncept", "cancel_scheduled_send": "Zrušiť odoslanie", diff --git a/locales/tr/common.json b/locales/tr/common.json index baa6a5cf..6e6ed5a0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -325,13 +325,13 @@ "view_contact": "Kişiyi görüntüle", "message_details": "İleti Ayrıntıları", "more_reply_options": "Daha fazla yanıt seçeneği", - "set_color": "Etiket ayarla", + "set_tag": "Etiket ayarla", "tag": "Etiket", "more_actions": "Diğer işlemler", "previous": "Önceki", "next": "Sonraki", "move_to": "Şuraya taşı...", - "remove_color": "Etiketi kaldır", + "remove_tag": "Etiketi kaldır", "more_count": "+{count} daha", "characters_count": "{count} karakter", "quick_reply_placeholder": "Hızlı yanıt yazın...", @@ -400,17 +400,6 @@ "message_id": "İleti Kimliği", "list_info": "Liste Bilgisi" }, - "color_tag": { - "title": "Renk Etiketi", - "red": "Kırmızı", - "orange": "Turuncu", - "yellow": "Sarı", - "green": "Yeşil", - "blue": "Mavi", - "purple": "Mor", - "pink": "Pembe", - "none": "Yok" - }, "tooltips": { "reply": "Yanıtla (r)", "reply_all": "Tümünü Yanıtla (a)", @@ -2033,8 +2022,7 @@ "delete": "Sil", "mark_as_spam": "Spam bildir", "not_spam": "Spam değil", - "color_tag": "Etiket", - "remove_color": "Etiketi kaldır", + "tag": "Etiket", "items_selected": "{count} e-posta seçildi", "edit_draft": "Taslağı Düzenle", "cancel_scheduled_send": "Göndermeyi iptal et", diff --git a/locales/uk/common.json b/locales/uk/common.json index 31a97d4e..6da37126 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -325,13 +325,13 @@ "view_contact": "Переглянути контакт", "message_details": "Деталі повідомлення", "more_reply_options": "Більше варіантів відповіді", - "set_color": "Встановити тег", + "set_tag": "Встановити тег", "tag": "Тег", "more_actions": "Більше дій", "previous": "попередня", "next": "Далі", "move_to": "Перейти до...", - "remove_color": "Видалити тег", + "remove_tag": "Видалити тег", "more_count": "+ ще {count}", "characters_count": "{count} символів", "quick_reply_placeholder": "Напишіть швидку відповідь...", @@ -400,17 +400,6 @@ "message_id": "ID повідомлення", "list_info": "Інформація про список" }, - "color_tag": { - "title": "Кольоровий тег", - "red": "Червоний", - "orange": "Помаранчевий", - "yellow": "Жовтий", - "green": "Зелений", - "blue": "Синій", - "purple": "Фіолетовий", - "pink": "Рожевий", - "none": "Жодного" - }, "tooltips": { "reply": "Відповісти (р)", "reply_all": "Відповісти всім (а)", @@ -2033,8 +2022,7 @@ "delete": "Видалити", "mark_as_spam": "Повідомити про спам", "not_spam": "Не спам", - "color_tag": "Мітка", - "remove_color": "Видалити мітку", + "tag": "Мітка", "items_selected": "Вибрано електронних листів: {count}", "edit_draft": "Редагувати чернетку", "cancel_scheduled_send": "Скасувати надсилання", diff --git a/locales/zh/common.json b/locales/zh/common.json index 6015e0c0..54273d96 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -325,13 +325,13 @@ "view_contact": "查看联系人", "message_details": "邮件详情", "more_reply_options": "更多回复选项", - "set_color": "设置颜色标签", + "set_tag": "设置颜色标签", "tag": "标签", "more_actions": "更多操作", "previous": "上一封", "next": "下一封", "move_to": "移动到…", - "remove_color": "删除标签", + "remove_tag": "删除标签", "more_count": "+{count} 更多", "characters_count": "{count} 个字符", "quick_reply_placeholder": "快速回复...", @@ -400,17 +400,6 @@ "message_id": "消息 ID", "list_info": "邮件列表信息" }, - "color_tag": { - "title": "颜色标签", - "red": "红色", - "orange": "橙色", - "yellow": "黄色", - "green": "绿色", - "blue": "蓝色", - "purple": "紫色", - "pink": "粉色", - "none": "无" - }, "tooltips": { "reply": "回复 (r)", "reply_all": "全部回复 (a)", @@ -2033,8 +2022,7 @@ "delete": "删除", "mark_as_spam": "举报垃圾邮件", "not_spam": "不是垃圾邮件", - "color_tag": "标签", - "remove_color": "删除标签", + "tag": "标签", "items_selected": "已选择 {count} 封邮件", "edit_draft": "编辑草稿", "cancel_scheduled_send": "取消发送", diff --git a/stores/__tests__/settings-store-keywords.test.ts b/stores/__tests__/settings-store-keywords.test.ts index 202e8428..822c8a72 100644 --- a/stores/__tests__/settings-store-keywords.test.ts +++ b/stores/__tests__/settings-store-keywords.test.ts @@ -1,5 +1,5 @@ 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'; describe('settings-store keywords', () => { @@ -16,7 +16,7 @@ describe('settings-store keywords', () => { DEFAULT_KEYWORDS.forEach((kw) => { expect(KEYWORD_PALETTE[kw.color]).toBeDefined(); expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy(); - expect(KEYWORD_PALETTE[kw.color].bg).toBeTruthy(); + expect(KEYWORD_PALETTE[kw.color].fill).toBeTruthy(); }); }); @@ -27,14 +27,39 @@ describe('settings-store keywords', () => { }); describe('KEYWORD_PALETTE', () => { - it('has 13 colors', () => { - expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(13); + it('has a lighter, base and darker shade of every hue', () => { + expect(KEYWORD_PALETTE_ROWS).toHaveLength(3); + KEYWORD_PALETTE_ROWS.forEach((row) => expect(row).toHaveLength(13)); + expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(39); }); - it('each color has dot and bg classes', () => { + it('lays every row out in the same hue order', () => { + const [light, base, dark] = KEYWORD_PALETTE_ROWS; + expect(light).toEqual(base.map((key) => `${key}-light`)); + expect(dark).toEqual(base.map((key) => `${key}-dark`)); + }); + + it('keeps the bare hue name on the base row, so saved tags still resolve', () => { + // A tag stored as `red` predates the lighter and darker rows. + expect(KEYWORD_PALETTE_ROWS[1]).toContain('red'); + expect(KEYWORD_PALETTE.red).toBeDefined(); + }); + + it('spells every class out so Tailwind can find it', () => { + // A composed class name would compile to nothing, so none may be built + // at runtime and each has to carry its own utility prefix. Object.values(KEYWORD_PALETTE).forEach((entry) => { expect(entry.dot).toMatch(/^bg-/); - expect(entry.bg).toMatch(/^bg-/); + expect(entry.fill).toMatch(/^bg-/); + expect(entry.border).toMatch(/^border-/); + expect(entry.text).toMatch(/^text-.* dark:text-/); + expect(entry.rowTint).toMatch(/^bg-.* dark:bg-/); + }); + }); + + it('resolves every row key', () => { + KEYWORD_PALETTE_ROWS.flat().forEach((key) => { + expect(KEYWORD_PALETTE[key]).toBeDefined(); }); }); }); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index b8afec55..e2ae45bf 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -125,23 +125,86 @@ export interface SidebarApp { showOnMobile: boolean; } -// Available color palette for keywords -export const KEYWORD_PALETTE: Record = { - red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' }, - orange: { dot: 'bg-orange-500', bg: 'bg-orange-50 dark:bg-orange-950/30' }, - yellow: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 dark:bg-yellow-950/30' }, - green: { dot: 'bg-green-500', bg: 'bg-green-50 dark:bg-green-950/30' }, - blue: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-950/30' }, - purple: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-950/30' }, - pink: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-950/30' }, - teal: { dot: 'bg-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' }, - cyan: { dot: 'bg-cyan-500', bg: 'bg-cyan-50 dark:bg-cyan-950/30' }, - indigo: { dot: 'bg-indigo-500', bg: 'bg-indigo-50 dark:bg-indigo-950/30' }, - amber: { dot: 'bg-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' }, - lime: { dot: 'bg-lime-500', bg: 'bg-lime-50 dark:bg-lime-950/30' }, - gray: { dot: 'bg-gray-500', bg: 'bg-gray-50 dark:bg-gray-950/30' }, +export interface KeywordColor { + /** Solid swatch: the dot form and the settings swatches. */ + dot: string; + /** The same solid colour as `dot`, for glyphs that take a text colour. */ + icon: string; + /** Lozenge background. */ + fill: string; + /** Lozenge border. */ + border: string; + /** Lozenge text. */ + text: string; + /** Full-row wash when `tintListRowsByTag` is on. */ + rowTint: string; +} + +/** + * Tag colours, written out literally. + * + * Tailwind v4 scans this file, but only for classes that appear verbatim - + * a composed `bg-${hue}-500` would compile to nothing. Every shade a tag can + * take therefore has to be spelled out, which is why this map is long. + * + * Three shades per hue: the middle one keeps the bare hue name, so a tag + * saved before the lighter and darker rows existed still resolves. + */ +export const KEYWORD_PALETTE: Record = { + // light + 'red-light': { dot: 'bg-red-300', icon: 'text-red-300', fill: 'bg-red-300/10', border: 'border-red-300/30', text: 'text-red-600 dark:text-red-200', rowTint: 'bg-red-50/60 dark:bg-red-950/20' }, + 'orange-light': { dot: 'bg-orange-300', icon: 'text-orange-300', fill: 'bg-orange-300/10', border: 'border-orange-300/30', text: 'text-orange-600 dark:text-orange-200', rowTint: 'bg-orange-50/60 dark:bg-orange-950/20' }, + 'amber-light': { dot: 'bg-amber-300', icon: 'text-amber-300', fill: 'bg-amber-300/10', border: 'border-amber-300/30', text: 'text-amber-600 dark:text-amber-200', rowTint: 'bg-amber-50/60 dark:bg-amber-950/20' }, + 'yellow-light': { dot: 'bg-yellow-300', icon: 'text-yellow-300', fill: 'bg-yellow-300/10', border: 'border-yellow-300/30', text: 'text-yellow-600 dark:text-yellow-200', rowTint: 'bg-yellow-50/60 dark:bg-yellow-950/20' }, + 'lime-light': { dot: 'bg-lime-300', icon: 'text-lime-300', fill: 'bg-lime-300/10', border: 'border-lime-300/30', text: 'text-lime-600 dark:text-lime-200', rowTint: 'bg-lime-50/60 dark:bg-lime-950/20' }, + 'green-light': { dot: 'bg-green-300', icon: 'text-green-300', fill: 'bg-green-300/10', border: 'border-green-300/30', text: 'text-green-600 dark:text-green-200', rowTint: 'bg-green-50/60 dark:bg-green-950/20' }, + 'teal-light': { dot: 'bg-teal-300', icon: 'text-teal-300', fill: 'bg-teal-300/10', border: 'border-teal-300/30', text: 'text-teal-600 dark:text-teal-200', rowTint: 'bg-teal-50/60 dark:bg-teal-950/20' }, + 'cyan-light': { dot: 'bg-cyan-300', icon: 'text-cyan-300', fill: 'bg-cyan-300/10', border: 'border-cyan-300/30', text: 'text-cyan-600 dark:text-cyan-200', rowTint: 'bg-cyan-50/60 dark:bg-cyan-950/20' }, + 'blue-light': { dot: 'bg-blue-300', icon: 'text-blue-300', fill: 'bg-blue-300/10', border: 'border-blue-300/30', text: 'text-blue-600 dark:text-blue-200', rowTint: 'bg-blue-50/60 dark:bg-blue-950/20' }, + 'indigo-light': { dot: 'bg-indigo-300', icon: 'text-indigo-300', fill: 'bg-indigo-300/10', border: 'border-indigo-300/30', text: 'text-indigo-600 dark:text-indigo-200', rowTint: 'bg-indigo-50/60 dark:bg-indigo-950/20' }, + 'purple-light': { dot: 'bg-purple-300', icon: 'text-purple-300', fill: 'bg-purple-300/10', border: 'border-purple-300/30', text: 'text-purple-600 dark:text-purple-200', rowTint: 'bg-purple-50/60 dark:bg-purple-950/20' }, + 'pink-light': { dot: 'bg-pink-300', icon: 'text-pink-300', fill: 'bg-pink-300/10', border: 'border-pink-300/30', text: 'text-pink-600 dark:text-pink-200', rowTint: 'bg-pink-50/60 dark:bg-pink-950/20' }, + 'gray-light': { dot: 'bg-gray-300', icon: 'text-gray-300', fill: 'bg-gray-300/10', border: 'border-gray-300/30', text: 'text-gray-600 dark:text-gray-200', rowTint: 'bg-gray-50/60 dark:bg-gray-950/20' }, + // base + red: { dot: 'bg-red-500', icon: 'text-red-500', fill: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-700 dark:text-red-300', rowTint: 'bg-red-50 dark:bg-red-950/30' }, + orange: { dot: 'bg-orange-500', icon: 'text-orange-500', fill: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-700 dark:text-orange-300', rowTint: 'bg-orange-50 dark:bg-orange-950/30' }, + amber: { dot: 'bg-amber-500', icon: 'text-amber-500', fill: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-700 dark:text-amber-300', rowTint: 'bg-amber-50 dark:bg-amber-950/30' }, + yellow: { dot: 'bg-yellow-500', icon: 'text-yellow-500', fill: 'bg-yellow-500/10', border: 'border-yellow-500/30', text: 'text-yellow-700 dark:text-yellow-300', rowTint: 'bg-yellow-50 dark:bg-yellow-950/30' }, + lime: { dot: 'bg-lime-500', icon: 'text-lime-500', fill: 'bg-lime-500/10', border: 'border-lime-500/30', text: 'text-lime-700 dark:text-lime-300', rowTint: 'bg-lime-50 dark:bg-lime-950/30' }, + green: { dot: 'bg-green-500', icon: 'text-green-500', fill: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-700 dark:text-green-300', rowTint: 'bg-green-50 dark:bg-green-950/30' }, + teal: { dot: 'bg-teal-500', icon: 'text-teal-500', fill: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-700 dark:text-teal-300', rowTint: 'bg-teal-50 dark:bg-teal-950/30' }, + cyan: { dot: 'bg-cyan-500', icon: 'text-cyan-500', fill: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-700 dark:text-cyan-300', rowTint: 'bg-cyan-50 dark:bg-cyan-950/30' }, + blue: { dot: 'bg-blue-500', icon: 'text-blue-500', fill: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-700 dark:text-blue-300', rowTint: 'bg-blue-50 dark:bg-blue-950/30' }, + indigo: { dot: 'bg-indigo-500', icon: 'text-indigo-500', fill: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-700 dark:text-indigo-300', rowTint: 'bg-indigo-50 dark:bg-indigo-950/30' }, + purple: { dot: 'bg-purple-500', icon: 'text-purple-500', fill: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-700 dark:text-purple-300', rowTint: 'bg-purple-50 dark:bg-purple-950/30' }, + pink: { dot: 'bg-pink-500', icon: 'text-pink-500', fill: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-700 dark:text-pink-300', rowTint: 'bg-pink-50 dark:bg-pink-950/30' }, + gray: { dot: 'bg-gray-500', icon: 'text-gray-500', fill: 'bg-gray-500/10', border: 'border-gray-500/30', text: 'text-gray-700 dark:text-gray-300', rowTint: 'bg-gray-50 dark:bg-gray-950/30' }, + // dark + 'red-dark': { dot: 'bg-red-700', icon: 'text-red-700', fill: 'bg-red-700/10', border: 'border-red-700/30', text: 'text-red-800 dark:text-red-400', rowTint: 'bg-red-100 dark:bg-red-950/50' }, + 'orange-dark': { dot: 'bg-orange-700', icon: 'text-orange-700', fill: 'bg-orange-700/10', border: 'border-orange-700/30', text: 'text-orange-800 dark:text-orange-400', rowTint: 'bg-orange-100 dark:bg-orange-950/50' }, + 'amber-dark': { dot: 'bg-amber-700', icon: 'text-amber-700', fill: 'bg-amber-700/10', border: 'border-amber-700/30', text: 'text-amber-800 dark:text-amber-400', rowTint: 'bg-amber-100 dark:bg-amber-950/50' }, + 'yellow-dark': { dot: 'bg-yellow-700', icon: 'text-yellow-700', fill: 'bg-yellow-700/10', border: 'border-yellow-700/30', text: 'text-yellow-800 dark:text-yellow-400', rowTint: 'bg-yellow-100 dark:bg-yellow-950/50' }, + 'lime-dark': { dot: 'bg-lime-700', icon: 'text-lime-700', fill: 'bg-lime-700/10', border: 'border-lime-700/30', text: 'text-lime-800 dark:text-lime-400', rowTint: 'bg-lime-100 dark:bg-lime-950/50' }, + 'green-dark': { dot: 'bg-green-700', icon: 'text-green-700', fill: 'bg-green-700/10', border: 'border-green-700/30', text: 'text-green-800 dark:text-green-400', rowTint: 'bg-green-100 dark:bg-green-950/50' }, + 'teal-dark': { dot: 'bg-teal-700', icon: 'text-teal-700', fill: 'bg-teal-700/10', border: 'border-teal-700/30', text: 'text-teal-800 dark:text-teal-400', rowTint: 'bg-teal-100 dark:bg-teal-950/50' }, + 'cyan-dark': { dot: 'bg-cyan-700', icon: 'text-cyan-700', fill: 'bg-cyan-700/10', border: 'border-cyan-700/30', text: 'text-cyan-800 dark:text-cyan-400', rowTint: 'bg-cyan-100 dark:bg-cyan-950/50' }, + 'blue-dark': { dot: 'bg-blue-700', icon: 'text-blue-700', fill: 'bg-blue-700/10', border: 'border-blue-700/30', text: 'text-blue-800 dark:text-blue-400', rowTint: 'bg-blue-100 dark:bg-blue-950/50' }, + 'indigo-dark': { dot: 'bg-indigo-700', icon: 'text-indigo-700', fill: 'bg-indigo-700/10', border: 'border-indigo-700/30', text: 'text-indigo-800 dark:text-indigo-400', rowTint: 'bg-indigo-100 dark:bg-indigo-950/50' }, + 'purple-dark': { dot: 'bg-purple-700', icon: 'text-purple-700', fill: 'bg-purple-700/10', border: 'border-purple-700/30', text: 'text-purple-800 dark:text-purple-400', rowTint: 'bg-purple-100 dark:bg-purple-950/50' }, + 'pink-dark': { dot: 'bg-pink-700', icon: 'text-pink-700', fill: 'bg-pink-700/10', border: 'border-pink-700/30', text: 'text-pink-800 dark:text-pink-400', rowTint: 'bg-pink-100 dark:bg-pink-950/50' }, + 'gray-dark': { dot: 'bg-gray-700', icon: 'text-gray-700', fill: 'bg-gray-700/10', border: 'border-gray-700/30', text: 'text-gray-800 dark:text-gray-400', rowTint: 'bg-gray-100 dark:bg-gray-950/50' }, } as const; +/** Palette laid out as the settings picker shows it: lighter, base, darker. */ +export const KEYWORD_PALETTE_ROWS: string[][] = [ + ['red-light', 'orange-light', 'amber-light', 'yellow-light', 'lime-light', 'green-light', 'teal-light', 'cyan-light', 'blue-light', 'indigo-light', 'purple-light', 'pink-light', 'gray-light'], + ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray'], + ['red-dark', 'orange-dark', 'amber-dark', 'yellow-dark', 'lime-dark', 'green-dark', 'teal-dark', 'cyan-dark', 'blue-dark', 'indigo-dark', 'purple-dark', 'pink-dark', 'gray-dark'], +]; + +/** The colour a tag falls back to when its definition is gone. */ +export const FALLBACK_KEYWORD_COLOR = 'gray'; + export const DEFAULT_KEYWORDS: KeywordDefinition[] = [ { id: 'red', label: 'Red', color: 'red' }, { id: 'orange', label: 'Orange', color: 'orange' }, From d9d9f91a867b589e8652342938e8f57b510a567d Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 14:50:03 +0200 Subject: [PATCH 06/11] feat: Make it easier to handle multiple tags - Tags can now be removed straight from the email header - Tagging control now allows the user to (de)select multiple tags in one go --- app/(main)/[locale]/page.tsx | 17 ++++-- components/email/__tests__/tag-badge.test.tsx | 41 ++++++++++++++ .../email/__tests__/tag-picker.test.tsx | 26 +++++++-- components/email/email-context-menu.tsx | 20 +------ components/email/email-list.tsx | 46 +++++++++------- components/email/email-viewer.tsx | 36 +++++------- components/email/tag-badge.tsx | 27 ++++++++- components/email/tag-picker.tsx | 55 +++++++++++-------- components/pro/pro-email-tab-body.tsx | 29 +++++++--- lib/__tests__/thread-utils.test.ts | 25 +++++++++ lib/thread-utils.ts | 7 ++- 11 files changed, 221 insertions(+), 108 deletions(-) create mode 100644 components/email/__tests__/tag-badge.test.tsx diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index cd844fc7..a4627415 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -34,6 +34,7 @@ import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; import { cn } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; import { ErrorBoundary, SidebarErrorFallback, @@ -1870,18 +1871,22 @@ export default function Home() { if (tagId === null) { // Remove all tag keywords Object.keys(keywords).forEach(key => { - if (key.startsWith("$label:") || key.startsWith("$color:")) { + if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) { keywords[key] = false; } }); } else { - const jmapKey = `$label:${tagId}`; - if (keywords[jmapKey]) { - // Toggle off if already active - keywords[jmapKey] = false; + // Both prefixes name the same tag when read, so taking one off has to + // clear whichever spellings are actually set. + const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId] + .filter(key => keywords[key]); + if (activeKeys.length > 0) { + activeKeys.forEach(key => { + keywords[key] = false; + }); } else { // Add the tag without disturbing others - keywords[jmapKey] = true; + keywords[KEYWORD_PREFIX + tagId] = true; } } diff --git a/components/email/__tests__/tag-badge.test.tsx b/components/email/__tests__/tag-badge.test.tsx new file mode 100644 index 00000000..70775ff6 --- /dev/null +++ b/components/email/__tests__/tag-badge.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TagBadge } from '../tag-badge'; +import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store'; + +const TAGS: KeywordDefinition[] = [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, +]; + +describe('TagBadge', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true }); + }); + + it('names the tag by its full path', () => { + render(); + expect(screen.getByText('Work/Clients')).toBeInTheDocument(); + }); + + it('names a tag it has no definition for by its id', () => { + render(); + expect(screen.getByText('from-elsewhere')).toBeInTheDocument(); + }); + + it('offers removal only when asked to', () => { + const onRemove = vi.fn(); + const { rerender } = render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'remove_tag' })); + expect(onRemove).toHaveBeenCalledOnce(); + }); + + it('leaves the dot alone, having nowhere to put the control', () => { + render( {}} />); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Work')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/tag-picker.test.tsx b/components/email/__tests__/tag-picker.test.tsx index e7c7e664..048d9096 100644 --- a/components/email/__tests__/tag-picker.test.tsx +++ b/components/email/__tests__/tag-picker.test.tsx @@ -53,12 +53,28 @@ describe('TagPicker', () => { expect(onToggle).toHaveBeenCalledWith('work/clients'); }); - it('offers the clear-all row only while something is applied', () => { - const { rerender } = render( {}} onClearAll={() => {}} />); - expect(screen.queryByText('remove_tag')).not.toBeInTheDocument(); + it('lists a tag it has no definition for, so it can be taken off', () => { + const onToggle = vi.fn(); + const { rerender } = render(); - rerender( {}} onClearAll={() => {}} />); - expect(screen.getByText('remove_tag')).toBeInTheDocument(); + const row = screen.getByText('from-elsewhere').closest('button')!; + expect(row).toHaveAttribute('aria-checked', 'true'); + + fireEvent.click(row); + expect(onToggle).toHaveBeenCalledWith('from-elsewhere'); + + // Nothing but the message says it exists, so deselecting is the last of it. + rerender(); + expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument(); + }); + + it('counts undefined tags towards the filter box, and matches them', () => { + const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`); + const { container } = render( {}} />); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } }); + expect(within(container).getByText('stray-3')).toBeInTheDocument(); + expect(within(container).queryByText('Work')).not.toBeInTheDocument(); }); it('hides the filter box until the list is long enough to need one', () => { diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 5c26667d..2cef2b5b 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -36,6 +36,7 @@ import { } from "lucide-react"; import { buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { getEmailTagIds } from "@/lib/thread-utils"; import { TagPicker } from "./tag-picker"; interface Position { @@ -99,20 +100,6 @@ const getMailboxIcon = (role?: string) => { } }; -/** Every tag id set on a message, reading the current prefix and the legacy one. */ -const getCurrentTagIds = (keywords: Record | undefined): string[] => { - if (!keywords) return []; - const tags: string[] = []; - for (const key of Object.keys(keywords)) { - if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - tags.push( - key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) - ); - } - } - return tags; -}; - export function EmailContextMenu({ email, position, @@ -155,7 +142,7 @@ export function EmailContextMenu({ const isStarred = email.keywords?.$flagged; const isPinned = email.keywords?.['$pinned'] === true; const isDraft = email.keywords?.['$draft'] === true; - const currentTagIds = getCurrentTagIds(email.keywords); + const currentTagIds = getEmailTagIds(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; // Marking your own outgoing mail as spam makes no sense - hide the action @@ -373,8 +360,7 @@ export function EmailContextMenu({
handleAction(() => onSetTag?.(tagId))} - onClearAll={() => handleAction(() => onSetTag?.(null))} + onToggle={(tagId) => onSetTag?.(tagId)} />
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 4333f72d..4329a53d 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -133,6 +133,14 @@ export function EmailList({ }, [emails, disableThreading, isScheduledView, threadEmailCounts]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); + /** + * The row the menu was opened on, as the list currently has it. The menu holds + * the message it was handed when it opened, but tags can be applied from + * inside it without dismissing it, so what it draws has to keep up. + */ + const contextMenuEmail = contextMenu.data + ? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data + : null; const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const [isProcessing, setIsProcessing] = useState(false); @@ -574,9 +582,9 @@ export function EmailList({
{/* Context Menu */} - {contextMenu.data && ( + {contextMenuEmail && ( onReply?.(contextMenu.data!)} - onReplyAll={() => onReplyAll?.(contextMenu.data!)} - onForward={() => onForward?.(contextMenu.data!)} - onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenu.data!)} + onReply={() => onReply?.(contextMenuEmail!)} + onReplyAll={() => onReplyAll?.(contextMenuEmail!)} + onForward={() => onForward?.(contextMenuEmail!)} + onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)} onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} - onToggleStar={() => onToggleStar?.(contextMenu.data!)} - onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} - onDelete={() => onDelete?.(contextMenu.data!)} - onArchive={() => onArchive?.(contextMenu.data!)} - onSetTag={(color) => onSetTag?.(contextMenu.data!.id, color)} - onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} - onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} - onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} - onEditDraft={() => onEditDraft?.(contextMenu.data!)} - onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined} - onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined} - onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined} + onToggleStar={() => onToggleStar?.(contextMenuEmail!)} + onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined} + onDelete={() => onDelete?.(contextMenuEmail!)} + onArchive={() => onArchive?.(contextMenuEmail!)} + onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)} + onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)} + onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)} + onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)} + onEditDraft={() => onEditDraft?.(contextMenuEmail!)} + onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined} + onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined} + onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchArchive={async () => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8883e444..bbdc0a76 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -16,6 +16,7 @@ import { TagBadge } from "./tag-badge"; import { TagPicker } from "./tag-picker"; import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { getEmailTagIds } from "@/lib/thread-utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import { generateEmailSource } from "@/lib/email-source"; @@ -204,19 +205,6 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st return 'Attachment'; }; -const getCurrentTagIds = (keywords: Record | undefined): string[] => { - if (!keywords) return []; - const tags: string[] = []; - for (const key of Object.keys(keywords)) { - if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - tags.push( - key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) - ); - } - } - return tags; -}; - // Helper function to format recipients with contextual display const _formatRecipients = ( recipients: Array<{ name?: string; email: string }> | undefined, @@ -818,7 +806,7 @@ export function EmailViewer({ const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); - const currentTagIds = getCurrentTagIds(email?.keywords); + const currentTagIds = getEmailTagIds(email?.keywords); const sortedTagIds = sortTagIds(currentTagIds); // The header spans the reading pane, so it measures its own width rather than // inheriting the message list's answer. @@ -3011,8 +2999,7 @@ export function EmailViewer({
{ if (email) onSetTag?.(email.id, tagId); setTagMenuOpen(false); }} - onClearAll={() => { if (email) onSetTag?.(email.id, null); setTagMenuOpen(false); }} + onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }} />
)} @@ -3205,7 +3192,7 @@ export function EmailViewer({
)} {/* Overflow: tag - submenu */} - {emailKeywords.length > 0 && ( + {(emailKeywords.length > 0 || currentTagIds.length > 0) && (
setMoreMenuSub('tag')} onMouseLeave={() => setMoreMenuSub(null)} @@ -3222,8 +3209,7 @@ export function EmailViewer({
{ if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }} - onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }} + onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }} />
)} @@ -3368,7 +3354,7 @@ export function EmailViewer({ {isStarred ? t('tooltips.unstar') : t('tooltips.star')} {/* Tag (opens sub-view) */} - {emailKeywords.length > 0 && ( + {(emailKeywords.length > 0 || currentTagIds.length > 0) && (
@@ -3551,7 +3536,12 @@ export function EmailViewer({ {sortedTagIds.length > 0 && (
{sortedTagIds.map((tagId) => ( - + onSetTag(email.id, tagId) : undefined} + /> ))}
)} diff --git a/components/email/tag-badge.tsx b/components/email/tag-badge.tsx index 9c61f6d8..ca36f35f 100644 --- a/components/email/tag-badge.tsx +++ b/components/email/tag-badge.tsx @@ -1,5 +1,7 @@ "use client"; +import { useTranslations } from "next-intl"; +import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { useShortenedText } from "@/hooks/use-shortened-text"; @@ -38,12 +40,19 @@ export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1"; export function TagBadge({ tagId, variant, + onRemove, className, }: { tagId: string; variant: TagBadgeVariant; + /** + * Takes the tag off the message. Only the named form offers it - a dot is the + * size of the control it would have to hold. + */ + onRemove?: () => void; className?: string; }) { + const t = useTranslations("email_viewer"); const { tagName, tagNameCandidates, tagColor } = useKeywordFormat(); const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId)); const color = tagColor(tagId); @@ -61,10 +70,9 @@ export function TagBadge({ return ( - {shortenedName} + + {shortenedName} + + {onRemove && ( + + )} ); } diff --git a/components/email/tag-picker.tsx b/components/email/tag-picker.tsx index e2df5e1d..0f0948ef 100644 --- a/components/email/tag-picker.tsx +++ b/components/email/tag-picker.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; -import { Check, Search, X } from "lucide-react"; +import { Check, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting"; @@ -26,12 +26,10 @@ const SEARCH_THRESHOLD = 10; 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; }) { @@ -42,15 +40,32 @@ export function TagPicker({ const [query, setQuery] = useState(""); const trimmedQuery = query.trim().toLowerCase(); - const showSearch = keywords.length >= SEARCH_THRESHOLD; + + /** + * Tags on the message this client has no definition for - set from another + * client, or outliving the tag they were made with. Listing them is the only + * way to take one off, and they leave the list as they are deselected because + * nothing but the message itself records that they exist. + */ + const unknownIds = useMemo( + () => + selectedIds + .filter((id) => !keywords.some((keyword) => keyword.id === id)) + .sort((a, b) => tagName(a).localeCompare(tagName(b))), + // `tagName` is rebuilt whenever the definitions or the nesting setting change. + [selectedIds, keywords, tagName], + ); + + const showSearch = keywords.length + unknownIds.length >= SEARCH_THRESHOLD; const matches = useMemo( () => trimmedQuery - ? keywords.filter((keyword) => tagName(keyword.id).toLowerCase().includes(trimmedQuery)) + ? [...keywords.map((keyword) => keyword.id), ...unknownIds].filter((id) => + tagName(id).toLowerCase().includes(trimmedQuery), + ) : [], - // `tagName` is rebuilt whenever the definitions or the nesting setting change. - [keywords, trimmedQuery, tagName], + [keywords, unknownIds, trimmedQuery, tagName], ); const tree = useMemo( @@ -111,28 +126,22 @@ export function TagPicker({
{trimmedQuery ? ( matches.length > 0 ? ( - matches.map((keyword) => renderRow(keyword.id, tagName(keyword.id))) + matches.map((id) => renderRow(id, tagName(id))) ) : (

{t("tag_no_matches")}

) ) : ( - renderBranch(tree) + <> + {renderBranch(tree)} + {unknownIds.length > 0 && ( + <> + {keywords.length > 0 &&
} + {unknownIds.map((id) => renderRow(id, tagName(id)))} + + )} + )}
- - {onClearAll && selectedIds.length > 0 && ( - <> -
- - - )} ); } diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 2a412f7e..b37a5abe 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -16,6 +16,7 @@ import type { Email } from "@/lib/jmap/types"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { getQuoteBodies } from "@/lib/email-composer-utils"; import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; +import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; interface ProEmailTabBodyProps { tabId: string; @@ -57,7 +58,6 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { const moveToMailbox = useEmailStore((s) => s.moveToMailbox); const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal); const mailboxes = useEmailStore((s) => s.mailboxes); - const settingsKeywords = useSettingsStore((s) => s.emailKeywords); const identities = useIdentityStore((s) => s.identities); const multiAccountIdentities = useProMultiAccountIdentities(); @@ -238,18 +238,29 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { const handleSetTag = useCallback((emailId: string, tagId: string | null) => { if (!email || email.id !== emailId) return; - // Drop existing color keywords, optionally add the new one. Matches the - // mail page's local optimistic update. + // Toggle one tag, or clear them all. Matches the mail page's local + // optimistic update, down to reaching tags this client cannot name. const keywords = { ...(email.keywords ?? {}) }; - for (const kw of settingsKeywords) { - delete keywords[`$label:${kw.id}`]; - } - if (tagId) { - keywords[`$label:${tagId}`] = true; + if (tagId === null) { + for (const key of Object.keys(keywords)) { + if (key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) { + keywords[key] = false; + } + } + } else { + const activeKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId] + .filter(key => keywords[key]); + if (activeKeys.length > 0) { + for (const key of activeKeys) { + keywords[key] = false; + } + } else { + keywords[KEYWORD_PREFIX + tagId] = true; + } } setEmailKeywordsLocal(emailId, keywords); setEmail({ ...email, keywords }); - }, [email, settingsKeywords, setEmailKeywordsLocal]); + }, [email, setEmailKeywordsLocal]); const handleMoveToMailbox = useCallback(async (mailboxId: string) => { if (!client || !email) return; diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index 5de19307..bc65d3e2 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -5,6 +5,7 @@ import { getThreadParticipants, mergeThreadEmails, getEmailTagId, + getEmailTagIds, getThreadTagId, getThreadTagIds, } from '../thread-utils'; @@ -246,6 +247,30 @@ describe('mergeThreadEmails', () => { }); }); +describe('getEmailTagIds', () => { + it('gathers every tag set on the message', () => { + expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true })) + .toEqual(['red', 'work']); + }); + + it('reads the legacy prefix alongside the current one', () => { + expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']); + }); + + it('reports a tag written under both prefixes once', () => { + expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']); + }); + + it('ignores keywords set to false', () => { + expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']); + }); + + it('is empty for an untagged message or none at all', () => { + expect(getEmailTagIds({ $seen: true })).toEqual([]); + expect(getEmailTagIds(undefined)).toEqual([]); + }); +}); + describe('getEmailTagId', () => { it('returns label from $label: keyword', () => { expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red'); diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 4a152568..7719a2e5 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -170,20 +170,21 @@ export const KEYWORD_PREFIX_LEGACY = "$color:"; /** * Gets every tag id set on a message. * Reads both the current $label: prefix and the legacy $color: prefix. + * A tag written under both spellings is one tag, so it is returned once. */ export function getEmailTagIds(keywords: Record | undefined): string[] { if (!keywords) return []; - const tags: string[] = []; + const tags = new Set(); for (const key of Object.keys(keywords)) { if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) { - tags.push( + tags.add( key.startsWith(KEYWORD_PREFIX) ? key.slice(KEYWORD_PREFIX.length) : key.slice(KEYWORD_PREFIX_LEGACY.length) ); } } - return tags; + return [...tags]; } /** From f1e1ed1df76e9d9a7db19be1405fe67786bbd5af Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 15:03:51 +0200 Subject: [PATCH 07/11] fix: make the tint of selected rows work the same way in dark and light mode --- .../email/__tests__/thread-list-item.test.tsx | 45 +++++++++++++++++++ components/email/thread-list-item.tsx | 12 +++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 1f7bd498..c0935c26 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -243,3 +243,48 @@ describe('ThreadListItem shift-range checkbox', () => { expect(selected.has('e3')).toBe(true); }); }); + +describe('ThreadListItem row tint', () => { + const rowClasses = (container: HTMLElement) => + container.querySelector('[data-email-id="email-1"]')!.className.split(' '); + + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + tintListRowsByTag: true, + }); + useEmailStore.setState({ + selectedEmailIds: new Set(['email-1']), + selectedMailbox: 'inbox', + }); + }); + + it('keeps a checked row tinted, and says so to either theme', () => { + const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } })); + const classes = rowClasses(container); + + expect(classes).toContain('bg-red-50'); + expect(classes).toContain('dark:bg-red-950/30'); + expect(classes).not.toContain('bg-accent/40'); + expect(classes).toContain('ring-primary/20'); + }); + + it('washes a checked row that has no tint to keep', () => { + const { container } = renderRow(makeEmail({ keywords: { $seen: true } })); + const classes = rowClasses(container); + + expect(classes).toContain('bg-accent/40'); + expect(classes).toContain('ring-primary/20'); + }); + + it('leaves the tint alone when the setting is off', () => { + useSettingsStore.setState({ tintListRowsByTag: false }); + const { container } = renderRow(makeEmail({ keywords: { $seen: true, '$label:red': true } })); + const classes = rowClasses(container); + + expect(classes).not.toContain('bg-red-50'); + expect(classes).toContain('bg-accent/40'); + }); +}); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 70a49f05..b5473341 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -202,9 +202,11 @@ const SingleEmailItem = React.forwardRef( !resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110", isUnread && !resolvedRowTint && "bg-accent/30", - isChecked && "ring-2 ring-primary/20 bg-accent/40", + isChecked && "ring-2 ring-primary/20", + isChecked && !resolvedRowTint && "bg-accent/40", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", - isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30" + isPressed && "scale-[0.98] ring-2 ring-primary/30", + isPressed && !resolvedRowTint && "bg-muted" )} onClick={handleClick} onDoubleClick={(e) => { @@ -640,8 +642,10 @@ export const ThreadListItem = React.forwardRef { From 7bb58f4f9f32464e154ed3f2b23fc6e5ed268198 Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 19:14:28 +0200 Subject: [PATCH 08/11] Add translations for new tag functionality --- locales/ar/common.json | 21 ++++++++++++++++++++- locales/ca/common.json | 21 ++++++++++++++++++++- locales/cs/common.json | 21 ++++++++++++++++++++- locales/da/common.json | 21 ++++++++++++++++++++- locales/de/common.json | 21 ++++++++++++++++++++- locales/es/common.json | 21 ++++++++++++++++++++- locales/fa/common.json | 21 ++++++++++++++++++++- locales/fr/common.json | 21 ++++++++++++++++++++- locales/he/common.json | 21 ++++++++++++++++++++- locales/hu/common.json | 21 ++++++++++++++++++++- locales/it/common.json | 21 ++++++++++++++++++++- locales/ja/common.json | 21 ++++++++++++++++++++- locales/ko/common.json | 21 ++++++++++++++++++++- locales/lv/common.json | 21 ++++++++++++++++++++- locales/pl/common.json | 21 ++++++++++++++++++++- locales/pt/common.json | 21 ++++++++++++++++++++- locales/ro/common.json | 21 ++++++++++++++++++++- locales/ru/common.json | 21 ++++++++++++++++++++- locales/sk/common.json | 21 ++++++++++++++++++++- locales/tr/common.json | 21 ++++++++++++++++++++- locales/uk/common.json | 21 ++++++++++++++++++++- locales/zh/common.json | 21 ++++++++++++++++++++- 22 files changed, 440 insertions(+), 22 deletions(-) diff --git a/locales/ar/common.json b/locales/ar/common.json index 23d15d86..3a870a64 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -130,6 +130,8 @@ "demo_reset": "إعادة تعيين", "demo_tour": "جولة", "tags": "الوسوم", + "show_all_tags": "إظهار الكل ({count})", + "show_fewer_tags": "إظهار أقل", "folders": "المجلدات", "shared": "مشترك", "mail": "البريد", @@ -332,6 +334,8 @@ "next": "التالي", "move_to": "نقل إلى...", "remove_tag": "إزالة الوسم", + "tag_filter_placeholder": "تصفية الوسوم", + "tag_no_matches": "لا توجد وسوم مطابقة", "more_count": "+{count} أخرى", "characters_count": "{count} حرفًا", "quick_reply_placeholder": "اكتب ردًا سريعًا...", @@ -1020,7 +1024,22 @@ "add": "إضافة", "cancel": "إلغاء", "migrating": "جارٍ تحديث الوسم على الرسائل الحالية…", - "migration_error": "فشل تحديث الوسم على الرسائل الحالية" + "migration_error": "فشل تحديث الوسم على الرسائل الحالية", + "nesting": { + "label": "وسوم متداخلة", + "description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي." + }, + "parent_field": "الوسم الأصل", + "no_parent": "بدون وسم أصل", + "too_long": "مسار الوسم طويل جدًا ({max} حرفًا على الأكثر)", + "has_children_locked": "توجد وسوم أخرى متداخلة تحت هذا الوسم، لذا فإن اسمه ووسمه الأصل مقفلان. انقلها أو احذفها أولًا.", + "has_children_delete": "احذف أولًا الوسوم المتداخلة تحت هذا الوسم", + "visibility_field": "الظهور في الشريط الجانبي", + "visibility": { + "show": "إظهار", + "unread": "إظهار عند وجود غير مقروء", + "hide": "إخفاء" + } }, "notifications": { "test_sound": "اختبار صوت الإشعار", diff --git a/locales/ca/common.json b/locales/ca/common.json index 97919417..11b15781 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -130,6 +130,8 @@ "demo_reset": "Reinicia", "demo_tour": "Visita guiada", "tags": "Etiquetes", + "show_all_tags": "Mostra-ho tot ({count})", + "show_fewer_tags": "Mostra'n menys", "folders": "Carpetes", "shared": "Compartit", "mail": "Correu", @@ -332,6 +334,8 @@ "next": "Següent", "move_to": "Mou a...", "remove_tag": "Elimina l'etiqueta", + "tag_filter_placeholder": "Filtra les etiquetes", + "tag_no_matches": "Cap etiqueta coincident", "more_count": "+{count} més", "characters_count": "{count} caràcters", "quick_reply_placeholder": "Escriviu una resposta ràpida...", @@ -988,7 +992,22 @@ "add": "Afegeix", "cancel": "Cancel·la", "migrating": "Actualitzant l'etiqueta als correus existents…", - "migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents" + "migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents", + "nesting": { + "label": "Etiquetes imbricades", + "description": "Imbrica etiquetes sota altres etiquetes i mostra-les com un arbre a la barra lateral." + }, + "parent_field": "Etiqueta principal", + "no_parent": "Sense etiqueta principal", + "too_long": "Aquest camí d'etiqueta és massa llarg (com a màxim {max} caràcters)", + "has_children_locked": "Hi ha altres etiquetes imbricades sota aquesta, per això el seu nom i la seva etiqueta principal estan bloquejats. Mou-les o elimina-les primer.", + "has_children_delete": "Elimina primer les etiquetes imbricades sota aquesta", + "visibility_field": "Visibilitat a la barra lateral", + "visibility": { + "show": "Mostra", + "unread": "Mostra si hi ha no llegits", + "hide": "Amaga" + } }, "notifications": { "test_sound": "Prova el so de notificació", diff --git a/locales/cs/common.json b/locales/cs/common.json index a8886132..7249e8f5 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -130,6 +130,8 @@ "demo_reset": "Resetovat", "demo_tour": "Průvodce", "tags": "Štítky", + "show_all_tags": "Zobrazit vše ({count})", + "show_fewer_tags": "Zobrazit méně", "folders": "Složky", "shared": "Sdílené", "mail": "Pošta", @@ -332,6 +334,8 @@ "next": "Další", "move_to": "Přesunout do...", "remove_tag": "Odebrat štítek", + "tag_filter_placeholder": "Filtrovat štítky", + "tag_no_matches": "Žádné odpovídající štítky", "more_count": "+{count} dalších", "characters_count": "{count} znaků", "quick_reply_placeholder": "Napsat rychlou odpověď...", @@ -1017,7 +1021,22 @@ "add": "Přidat", "cancel": "Zrušit", "migrating": "Aktualizace štítku v existujících e-mailech…", - "migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech" + "migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech", + "nesting": { + "label": "Vnořené štítky", + "description": "Vnořujte štítky pod jiné štítky a zobrazujte je v postranním panelu jako strom." + }, + "parent_field": "Nadřazený štítek", + "no_parent": "Bez nadřazeného štítku", + "too_long": "Tato cesta štítku je příliš dlouhá (nejvýše {max} znaků)", + "has_children_locked": "Pod tímto štítkem jsou vnořeny další štítky, proto jsou jeho název a nadřazený štítek uzamčeny. Nejprve je přesuňte nebo odeberte.", + "has_children_delete": "Nejprve odeberte štítky vnořené pod tímto", + "visibility_field": "Viditelnost v postranním panelu", + "visibility": { + "show": "Zobrazit", + "unread": "Zobrazit při nepřečtených", + "hide": "Skrýt" + } }, "notifications": { "test_sound": "Otestovat zvuk oznámení", diff --git a/locales/da/common.json b/locales/da/common.json index 44aa3d90..d2f8c102 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -130,6 +130,8 @@ "demo_reset": "Nulstil", "demo_tour": "Rundvisning", "tags": "Tags", + "show_all_tags": "Vis alle ({count})", + "show_fewer_tags": "Vis færre", "folders": "Mapper", "shared": "Delt", "mail": "Mail", @@ -332,6 +334,8 @@ "next": "Næste", "move_to": "Flyt til...", "remove_tag": "Fjern tag", + "tag_filter_placeholder": "Filtrer tags", + "tag_no_matches": "Ingen matchende tags", "more_count": "+{count} mere", "characters_count": "{count} tegn", "quick_reply_placeholder": "Skriv et hurtigt svar...", @@ -1020,7 +1024,22 @@ "add": "Tilføj", "cancel": "Annuller", "migrating": "Opdaterer tag på eksisterende e-mails…", - "migration_error": "Kunne ikke opdatere tag på eksisterende e-mails" + "migration_error": "Kunne ikke opdatere tag på eksisterende e-mails", + "nesting": { + "label": "Indlejrede tags", + "description": "Indlejr tags under andre tags og vis dem som et træ i sidepanelet." + }, + "parent_field": "Overordnet tag", + "no_parent": "Intet overordnet tag", + "too_long": "Denne tagsti er for lang (højst {max} tegn)", + "has_children_locked": "Andre tags er indlejret under dette, så dets navn og overordnede tag er låst. Flyt eller fjern dem først.", + "has_children_delete": "Fjern først de tags, der er indlejret under dette", + "visibility_field": "Synlighed i sidepanel", + "visibility": { + "show": "Vis", + "unread": "Vis ved ulæste", + "hide": "Skjul" + } }, "notifications": { "test_sound": "Test notifikationslyd", diff --git a/locales/de/common.json b/locales/de/common.json index 20067cbc..c1311520 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -130,6 +130,8 @@ "demo_reset": "Zurücksetzen", "demo_tour": "Tour", "tags": "Tags", + "show_all_tags": "Alle anzeigen ({count})", + "show_fewer_tags": "Weniger anzeigen", "folders": "Ordner", "mail": "E-Mail", "nav_label": "Navigation", @@ -330,6 +332,8 @@ "more_actions": "Weitere Aktionen", "move_to": "Verschieben nach...", "remove_tag": "Label entfernen", + "tag_filter_placeholder": "Labels filtern", + "tag_no_matches": "Keine passenden Labels", "more_count": "+{count} weitere", "characters_count": "{count} Zeichen", "quick_reply_placeholder": "Eine kurze Antwort schreiben...", @@ -1017,7 +1021,22 @@ "add": "Hinzufügen", "cancel": "Abbrechen", "migrating": "Label auf vorhandenen E-Mails aktualisieren…", - "migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden" + "migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden", + "nesting": { + "label": "Verschachtelte Labels", + "description": "Labels unter anderen Labels verschachteln und als Baum in der Seitenleiste anzeigen." + }, + "parent_field": "Übergeordnetes Label", + "no_parent": "Kein übergeordnetes Label", + "too_long": "Dieser Label-Pfad ist zu lang (höchstens {max} Zeichen)", + "has_children_locked": "Unter diesem Label sind andere Labels verschachtelt, daher sind Name und übergeordnetes Label gesperrt. Verschieben oder entfernen Sie diese zuerst.", + "has_children_delete": "Entfernen Sie zuerst die Labels, die unter diesem verschachtelt sind", + "visibility_field": "Sichtbarkeit in der Seitenleiste", + "visibility": { + "show": "Anzeigen", + "unread": "Bei Ungelesenen anzeigen", + "hide": "Ausblenden" + } }, "notifications": { "test_sound": "Benachrichtigungston testen", diff --git a/locales/es/common.json b/locales/es/common.json index 4c2a3da9..ffeb9e38 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -130,6 +130,8 @@ "demo_reset": "Restablecer", "demo_tour": "Tour", "tags": "Etiquetas", + "show_all_tags": "Mostrar todo ({count})", + "show_fewer_tags": "Mostrar menos", "folders": "Carpetas", "mail": "Correo", "nav_label": "Navegación", @@ -330,6 +332,8 @@ "more_actions": "Más acciones", "move_to": "Mover a...", "remove_tag": "Eliminar etiqueta", + "tag_filter_placeholder": "Filtrar etiquetas", + "tag_no_matches": "No hay etiquetas coincidentes", "more_count": "+{count} más", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escriba una respuesta rápida...", @@ -1017,7 +1021,22 @@ "add": "Añadir", "cancel": "Cancelar", "migrating": "Actualizando etiqueta en correos existentes…", - "migration_error": "Error al actualizar la etiqueta en correos existentes" + "migration_error": "Error al actualizar la etiqueta en correos existentes", + "nesting": { + "label": "Etiquetas anidadas", + "description": "Anida etiquetas debajo de otras etiquetas y muéstralas como un árbol en la barra lateral." + }, + "parent_field": "Etiqueta principal", + "no_parent": "Sin etiqueta principal", + "too_long": "Esta ruta de etiqueta es demasiado larga (máximo {max} caracteres)", + "has_children_locked": "Hay otras etiquetas anidadas bajo esta, por lo que su nombre y su etiqueta principal están bloqueados. Muévelas o elimínalas primero.", + "has_children_delete": "Elimina primero las etiquetas anidadas bajo esta", + "visibility_field": "Visibilidad en la barra lateral", + "visibility": { + "show": "Mostrar", + "unread": "Mostrar si hay no leídos", + "hide": "Ocultar" + } }, "notifications": { "test_sound": "Probar sonido de notificación", diff --git a/locales/fa/common.json b/locales/fa/common.json index 4599c53b..466d8394 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -130,6 +130,8 @@ "demo_reset": "بازنشانی", "demo_tour": "تور", "tags": "برچسب‌ها", + "show_all_tags": "نمایش همه ({count})", + "show_fewer_tags": "نمایش کمتر", "folders": "پوشه‌ها", "shared": "اشتراکی", "mail": "ایمیل", @@ -332,6 +334,8 @@ "next": "بعدی", "move_to": "انتقال به...", "remove_tag": "حذف برچسب", + "tag_filter_placeholder": "فیلتر برچسب‌ها", + "tag_no_matches": "برچسب مطابقی یافت نشد", "more_count": "+{count} بیشتر", "characters_count": "{count} کاراکتر", "quick_reply_placeholder": "پاسخ سریع بنویسید...", @@ -1020,7 +1024,22 @@ "add": "افزودن", "cancel": "انصراف", "migrating": "در حال به‌روزرسانی برچسب روی ایمیل‌های موجود…", - "migration_error": "به‌روزرسانی برچسب ناموفق بود" + "migration_error": "به‌روزرسانی برچسب ناموفق بود", + "nesting": { + "label": "برچسب‌های تودرتو", + "description": "برچسب‌ها را زیر برچسب‌های دیگر قرار دهید و آن‌ها را به‌صورت درختی در نوار کناری نمایش دهید." + }, + "parent_field": "برچسب والد", + "no_parent": "بدون برچسب والد", + "too_long": "این مسیر برچسب خیلی طولانی است (حداکثر {max} نویسه)", + "has_children_locked": "برچسب‌های دیگری زیر این برچسب قرار دارند، بنابراین نام و برچسب والد آن قفل است. ابتدا آن‌ها را جابه‌جا یا حذف کنید.", + "has_children_delete": "ابتدا برچسب‌های زیرمجموعهٔ این برچسب را حذف کنید", + "visibility_field": "نمایش در نوار کناری", + "visibility": { + "show": "نمایش", + "unread": "نمایش در صورت وجود خوانده‌نشده", + "hide": "پنهان کردن" + } }, "notifications": { "test_sound": "تست صدای اعلان", diff --git a/locales/fr/common.json b/locales/fr/common.json index cf60e2df..06891007 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -130,6 +130,8 @@ "demo_reset": "Réinitialiser", "demo_tour": "Visite", "tags": "Étiquettes", + "show_all_tags": "Tout afficher ({count})", + "show_fewer_tags": "Afficher moins", "folders": "Dossiers", "mail": "Messagerie", "nav_label": "Navigation", @@ -330,6 +332,8 @@ "more_actions": "Plus d'actions", "move_to": "Déplacer vers...", "remove_tag": "Retirer l'étiquette", + "tag_filter_placeholder": "Filtrer les étiquettes", + "tag_no_matches": "Aucune étiquette correspondante", "more_count": "+{count} de plus", "characters_count": "{count} caractères", "quick_reply_placeholder": "Écrivez une réponse rapide...", @@ -1017,7 +1021,22 @@ "add": "Ajouter", "cancel": "Annuler", "migrating": "Mise à jour de l'étiquette sur les e-mails existants…", - "migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants" + "migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants", + "nesting": { + "label": "Étiquettes imbriquées", + "description": "Imbriquez des étiquettes sous d'autres étiquettes et affichez-les sous forme d'arborescence dans la barre latérale." + }, + "parent_field": "Étiquette parente", + "no_parent": "Aucune étiquette parente", + "too_long": "Ce chemin d'étiquette est trop long ({max} caractères au maximum)", + "has_children_locked": "D'autres étiquettes sont imbriquées sous celle-ci, son nom et son étiquette parente sont donc verrouillés. Déplacez-les ou supprimez-les d'abord.", + "has_children_delete": "Retirez d'abord les étiquettes imbriquées sous celle-ci", + "visibility_field": "Visibilité dans la barre latérale", + "visibility": { + "show": "Afficher", + "unread": "Afficher si non lus", + "hide": "Masquer" + } }, "notifications": { "test_sound": "Tester le son de notification", diff --git a/locales/he/common.json b/locales/he/common.json index 918ccec8..a758742d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -122,6 +122,8 @@ "demo_reset": "אִתחוּל", "demo_tour": "סִיוּר", "tags": "תגים", + "show_all_tags": "הצג הכל ({count})", + "show_fewer_tags": "הצג פחות", "folders": "תיקיות", "mail": "דוֹאַר", "nav_label": "ניווט", @@ -279,6 +281,8 @@ "next": "הבא", "move_to": "העבר ל...", "remove_tag": "הסר תג", + "tag_filter_placeholder": "סינון תגים", + "tag_no_matches": "אין תגים תואמים", "more_count": "+{count}נוספים", "characters_count": "{count} תווים", "quick_reply_placeholder": "תשובה מהירה", @@ -982,7 +986,22 @@ "add": "לְהוֹסִיף", "cancel": "לְבַטֵל", "migrating": "מעדכן מילת מפתח באימיילים קיימים...", - "migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות" + "migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות", + "nesting": { + "label": "תגים מקוננים", + "description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד." + }, + "parent_field": "תג אב", + "no_parent": "ללא תג אב", + "too_long": "נתיב התג ארוך מדי (עד {max} תווים)", + "has_children_locked": "תגים אחרים מקוננים תחת תג זה, ולכן שמו ותג האב שלו נעולים. העבר או הסר אותם תחילה.", + "has_children_delete": "הסר תחילה את התגים המקוננים תחת תג זה", + "visibility_field": "הצגה בסרגל הצד", + "visibility": { + "show": "הצג", + "unread": "הצג כשיש שלא נקראו", + "hide": "הסתר" + } }, "notifications": { "test_sound": "צליל הודעת בדיקה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 790f4e8a..ed8bec08 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -130,6 +130,8 @@ "demo_reset": "Visszaállítás", "demo_tour": "Bemutató", "tags": "Címkék", + "show_all_tags": "Összes megjelenítése ({count})", + "show_fewer_tags": "Kevesebb megjelenítése", "folders": "Mappák", "shared": "Megosztott", "mail": "Levelek", @@ -332,6 +334,8 @@ "next": "Következő", "move_to": "Áthelyezés ide...", "remove_tag": "Címke eltávolítása", + "tag_filter_placeholder": "Címkék szűrése", + "tag_no_matches": "Nincs találat a címkék közt", "more_count": "+{count} további", "characters_count": "{count} karakter", "quick_reply_placeholder": "Gyors válasz írása...", @@ -1020,7 +1024,22 @@ "add": "Hozzáadás", "cancel": "Mégse", "migrating": "Címke frissítése a meglévő e-maileken...", - "migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken" + "migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken", + "nesting": { + "label": "Beágyazott címkék", + "description": "Ágyazzon címkéket más címkék alá, és jelenítse meg őket fastruktúraként az oldalsávon." + }, + "parent_field": "Szülőcímke", + "no_parent": "Nincs szülőcímke", + "too_long": "Ez a címkeútvonal túl hosszú (legfeljebb {max} karakter)", + "has_children_locked": "Más címkék vannak beágyazva ez alá, ezért a neve és a szülőcímkéje zárolva van. Előbb helyezze át vagy távolítsa el őket.", + "has_children_delete": "Előbb távolítsa el az ez alá beágyazott címkéket", + "visibility_field": "Láthatóság az oldalsávon", + "visibility": { + "show": "Megjelenítés", + "unread": "Megjelenítés olvasatlanoknál", + "hide": "Elrejtés" + } }, "notifications": { "test_sound": "Értesítési hang tesztelése", diff --git a/locales/it/common.json b/locales/it/common.json index d43d911b..ee386f71 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -130,6 +130,8 @@ "demo_reset": "Reimposta", "demo_tour": "Tour", "tags": "Etichette", + "show_all_tags": "Mostra tutto ({count})", + "show_fewer_tags": "Mostra meno", "folders": "Cartelle", "mail": "Posta", "nav_label": "Navigazione", @@ -330,6 +332,8 @@ "more_actions": "Altre azioni", "move_to": "Sposta in...", "remove_tag": "Rimuovi etichetta", + "tag_filter_placeholder": "Filtra etichette", + "tag_no_matches": "Nessuna etichetta corrispondente", "more_count": "+{count} altri", "characters_count": "{count} caratteri", "quick_reply_placeholder": "Scrivi una risposta veloce...", @@ -1017,7 +1021,22 @@ "add": "Aggiungi", "cancel": "Annulla", "migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…", - "migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti" + "migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti", + "nesting": { + "label": "Etichette nidificate", + "description": "Nidifica le etichette sotto altre etichette e mostrale come un albero nella barra laterale." + }, + "parent_field": "Etichetta principale", + "no_parent": "Nessuna etichetta principale", + "too_long": "Questo percorso di etichetta è troppo lungo (al massimo {max} caratteri)", + "has_children_locked": "Altre etichette sono nidificate sotto questa, quindi il suo nome e la sua etichetta principale sono bloccati. Spostale o rimuovile prima.", + "has_children_delete": "Rimuovi prima le etichette nidificate sotto questa", + "visibility_field": "Visibilità nella barra laterale", + "visibility": { + "show": "Mostra", + "unread": "Mostra se non letti", + "hide": "Nascondi" + } }, "notifications": { "test_sound": "Testa il suono di notifica", diff --git a/locales/ja/common.json b/locales/ja/common.json index 2502dfa7..0501ef06 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -130,6 +130,8 @@ "demo_reset": "リセット", "demo_tour": "ツアー", "tags": "タグ", + "show_all_tags": "すべて表示({count})", + "show_fewer_tags": "表示を減らす", "folders": "フォルダ", "mail": "メール", "nav_label": "ナビゲーション", @@ -330,6 +332,8 @@ "more_actions": "その他の操作", "move_to": "移動...", "remove_tag": "ラベルを削除", + "tag_filter_placeholder": "ラベルを絞り込む", + "tag_no_matches": "一致するラベルがありません", "more_count": "他{count}件", "characters_count": "{count}文字", "quick_reply_placeholder": "クイック返信を入力...", @@ -1017,7 +1021,22 @@ "add": "追加", "cancel": "キャンセル", "migrating": "既存のメールのラベルを更新中…", - "migration_error": "既存のメールのラベルの更新に失敗しました" + "migration_error": "既存のメールのラベルの更新に失敗しました", + "nesting": { + "label": "ネストされたラベル", + "description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。" + }, + "parent_field": "親ラベル", + "no_parent": "親ラベルなし", + "too_long": "このラベルのパスが長すぎます(最大{max}文字)", + "has_children_locked": "このラベルの下に他のラベルがネストされているため、名前と親ラベルは変更できません。先に移動または削除してください。", + "has_children_delete": "先にこのラベルの下にネストされたラベルを削除してください", + "visibility_field": "サイドバーでの表示", + "visibility": { + "show": "表示する", + "unread": "未読がある場合に表示", + "hide": "表示しない" + } }, "notifications": { "test_sound": "通知音をテスト", diff --git a/locales/ko/common.json b/locales/ko/common.json index bfb65416..c6d9420a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -130,6 +130,8 @@ "demo_reset": "초기화", "demo_tour": "둘러보기", "tags": "태그", + "show_all_tags": "전체 보기({count})", + "show_fewer_tags": "간략히 보기", "folders": "폴더", "mail": "메일", "nav_label": "내비게이션", @@ -332,6 +334,8 @@ "next": "다음", "move_to": "이동...", "remove_tag": "태그 제거", + "tag_filter_placeholder": "태그 검색", + "tag_no_matches": "일치하는 태그 없음", "more_count": "+{count}개 더보기", "characters_count": "{count}자", "quick_reply_placeholder": "간단하게 답장을 작성해 보세요...", @@ -1017,7 +1021,22 @@ "add": "추가", "cancel": "취소", "migrating": "기존 이메일의 태그 업데이트 중…", - "migration_error": "기존 이메일의 태그 업데이트에 실패했습니다" + "migration_error": "기존 이메일의 태그 업데이트에 실패했습니다", + "nesting": { + "label": "중첩 태그", + "description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다." + }, + "parent_field": "상위 태그", + "no_parent": "상위 태그 없음", + "too_long": "이 태그 경로가 너무 깁니다(최대 {max}자)", + "has_children_locked": "이 태그 아래에 다른 태그가 중첩되어 있어 이름과 상위 태그가 잠겨 있습니다. 먼저 옮기거나 삭제하세요.", + "has_children_delete": "이 태그 아래에 중첩된 태그를 먼저 삭제하세요", + "visibility_field": "사이드바 표시", + "visibility": { + "show": "표시", + "unread": "읽지 않음이 있을 때 표시", + "hide": "숨기기" + } }, "notifications": { "test_sound": "알림음 테스트", diff --git a/locales/lv/common.json b/locales/lv/common.json index c742af88..746dbc88 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -130,6 +130,8 @@ "demo_reset": "Atiestatīt", "demo_tour": "Ekskursija", "tags": "Tagi", + "show_all_tags": "Rādīt visus ({count})", + "show_fewer_tags": "Rādīt mazāk", "folders": "Mapes", "mail": "Pasts", "nav_label": "Navigācija", @@ -332,6 +334,8 @@ "next": "Nāk.", "move_to": "Pārvietot uz...", "remove_tag": "Noņemt tagu", + "tag_filter_placeholder": "Filtrēt tagus", + "tag_no_matches": "Nav atbilstošu tagu", "more_count": "+vairāk {count}", "characters_count": "{count} rakstzīmes", "quick_reply_placeholder": "Rakstīt ātru atbildi...", @@ -1017,7 +1021,22 @@ "add": "Pievienot", "cancel": "Atcelt", "migrating": "Taga atjaunināšana esošajos e-pastos…", - "migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos" + "migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos", + "nesting": { + "label": "Ligzdoti tagi", + "description": "Ligzdojiet tagus zem citiem tagiem un rādiet tos sānjoslā kā koku." + }, + "parent_field": "Vecāktags", + "no_parent": "Nav vecāktaga", + "too_long": "Šis taga ceļš ir pārāk garš (ne vairāk kā {max} rakstzīmes)", + "has_children_locked": "Zem šī taga ir ligzdoti citi tagi, tāpēc tā nosaukums un vecāktags ir bloķēti. Vispirms pārvietojiet vai noņemiet tos.", + "has_children_delete": "Vispirms noņemiet zem šī ligzdotos tagus", + "visibility_field": "Redzamība sānjoslā", + "visibility": { + "show": "Rādīt", + "unread": "Rādīt, ja ir nelasīti", + "hide": "Slēpt" + } }, "notifications": { "test_sound": "Pārbaudīt paziņojuma skaņu", diff --git a/locales/pl/common.json b/locales/pl/common.json index 9378c706..9b0cf6ed 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -130,6 +130,8 @@ "demo_reset": "Resetuj", "demo_tour": "Przewodnik", "tags": "Etykiety", + "show_all_tags": "Pokaż wszystkie ({count})", + "show_fewer_tags": "Pokaż mniej", "folders": "Foldery", "mail": "Poczta", "nav_label": "Nawigacja", @@ -332,6 +334,8 @@ "next": "Nast.", "move_to": "Przenieś do...", "remove_tag": "Usuń etykietę", + "tag_filter_placeholder": "Filtruj etykiety", + "tag_no_matches": "Brak pasujących etykiet", "more_count": "+{count} więcej", "characters_count": "{count} znaków", "quick_reply_placeholder": "Napisz szybką odpowiedź...", @@ -1017,7 +1021,22 @@ "add": "Dodaj", "cancel": "Anuluj", "migrating": "Aktualizowanie etykiety w istniejących e-mailach…", - "migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach" + "migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach", + "nesting": { + "label": "Zagnieżdżone etykiety", + "description": "Zagnieżdżaj etykiety pod innymi etykietami i wyświetlaj je w panelu bocznym jako drzewo." + }, + "parent_field": "Etykieta nadrzędna", + "no_parent": "Brak etykiety nadrzędnej", + "too_long": "Ta ścieżka etykiety jest za długa (maksymalnie {max} znaków)", + "has_children_locked": "Pod tą etykietą zagnieżdżone są inne etykiety, więc jej nazwa i etykieta nadrzędna są zablokowane. Najpierw je przenieś lub usuń.", + "has_children_delete": "Najpierw usuń etykiety zagnieżdżone pod tą", + "visibility_field": "Widoczność w panelu bocznym", + "visibility": { + "show": "Pokaż", + "unread": "Pokaż przy nieprzeczytanych", + "hide": "Ukryj" + } }, "notifications": { "test_sound": "Przetestuj dźwięk powiadomienia", diff --git a/locales/pt/common.json b/locales/pt/common.json index 6bbd2b2e..1d226b70 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -130,6 +130,8 @@ "demo_reset": "Repor", "demo_tour": "Tour", "tags": "Etiquetas", + "show_all_tags": "Mostrar tudo ({count})", + "show_fewer_tags": "Mostrar menos", "folders": "Pastas", "mail": "E-mail", "nav_label": "Navegação", @@ -330,6 +332,8 @@ "more_actions": "Mais ações", "move_to": "Mover para...", "remove_tag": "Remover etiqueta", + "tag_filter_placeholder": "Filtrar etiquetas", + "tag_no_matches": "Nenhuma etiqueta correspondente", "more_count": "+{count} mais", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escreva uma resposta rápida...", @@ -1017,7 +1021,22 @@ "add": "Adicionar", "cancel": "Cancelar", "migrating": "A atualizar etiqueta nos e-mails existentes…", - "migration_error": "Falha ao atualizar etiqueta nos e-mails existentes" + "migration_error": "Falha ao atualizar etiqueta nos e-mails existentes", + "nesting": { + "label": "Etiquetas aninhadas", + "description": "Aninhe etiquetas sob outras etiquetas e mostre-as como uma árvore na barra lateral." + }, + "parent_field": "Etiqueta principal", + "no_parent": "Sem etiqueta principal", + "too_long": "Este caminho de etiqueta é demasiado longo (no máximo {max} caracteres)", + "has_children_locked": "Existem outras etiquetas aninhadas sob esta, por isso o seu nome e a sua etiqueta principal estão bloqueados. Mova-as ou remova-as primeiro.", + "has_children_delete": "Remova primeiro as etiquetas aninhadas sob esta", + "visibility_field": "Visibilidade na barra lateral", + "visibility": { + "show": "Mostrar", + "unread": "Mostrar se não lidas", + "hide": "Ocultar" + } }, "notifications": { "test_sound": "Testar som de notificação", diff --git a/locales/ro/common.json b/locales/ro/common.json index 12968735..7a3ceb82 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -130,6 +130,8 @@ "demo_reset": "Resetare", "demo_tour": "Tur de prezentare", "tags": "Etichete", + "show_all_tags": "Afișează tot ({count})", + "show_fewer_tags": "Afișează mai puține", "folders": "Dosare", "shared": "Partajat", "mail": "E-mail", @@ -332,6 +334,8 @@ "next": "Următorul", "move_to": "Mergi la...", "remove_tag": "Eliminați eticheta", + "tag_filter_placeholder": "Filtrează etichetele", + "tag_no_matches": "Nicio etichetă corespunzătoare", "more_count": "+{count} mai multe", "characters_count": "{count} caractere", "quick_reply_placeholder": "Scrie un răspuns rapid...", @@ -1020,7 +1024,22 @@ "add": "Adăugați", "cancel": "Anulează", "migrating": "Actualizarea etichetei pentru e-mailurile existente…", - "migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente" + "migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente", + "nesting": { + "label": "Etichete imbricate", + "description": "Imbricați etichete sub alte etichete și afișați-le ca un arbore în bara laterală." + }, + "parent_field": "Etichetă părinte", + "no_parent": "Fără etichetă părinte", + "too_long": "Această cale de etichetă este prea lungă (cel mult {max} caractere)", + "has_children_locked": "Alte etichete sunt imbricate sub aceasta, așa că numele și eticheta părinte sunt blocate. Mutați-le sau eliminați-le mai întâi.", + "has_children_delete": "Eliminați mai întâi etichetele imbricate sub aceasta", + "visibility_field": "Vizibilitate în bara laterală", + "visibility": { + "show": "Afișează", + "unread": "Afișează dacă sunt necitite", + "hide": "Ascunde" + } }, "notifications": { "test_sound": "Testați sunetul de notificare", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9302e3a6..4dfe531f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -130,6 +130,8 @@ "demo_reset": "Сбросить", "demo_tour": "Тур", "tags": "Теги", + "show_all_tags": "Показать все ({count})", + "show_fewer_tags": "Показать меньше", "folders": "Папки", "mail": "Почта", "nav_label": "Навигация", @@ -332,6 +334,8 @@ "next": "След.", "move_to": "Переместить в...", "remove_tag": "Удалить тег", + "tag_filter_placeholder": "Фильтр тегов", + "tag_no_matches": "Подходящих тегов нет", "more_count": "+{count} ещё", "characters_count": "{count} символов", "quick_reply_placeholder": "Написать быстрый ответ...", @@ -1017,7 +1021,22 @@ "add": "Добавить", "cancel": "Отмена", "migrating": "Обновление тега в существующих письмах…", - "migration_error": "Не удалось обновить тег в существующих письмах" + "migration_error": "Не удалось обновить тег в существующих письмах", + "nesting": { + "label": "Вложенные теги", + "description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева." + }, + "parent_field": "Родительский тег", + "no_parent": "Без родительского тега", + "too_long": "Этот путь тега слишком длинный (не более {max} символов)", + "has_children_locked": "В этот тег вложены другие теги, поэтому его имя и родительский тег заблокированы. Сначала переместите или удалите их.", + "has_children_delete": "Сначала удалите теги, вложенные в этот", + "visibility_field": "Видимость в боковой панели", + "visibility": { + "show": "Показывать", + "unread": "Показывать при непрочитанных", + "hide": "Скрывать" + } }, "notifications": { "test_sound": "Проверить звук уведомления", diff --git a/locales/sk/common.json b/locales/sk/common.json index 0d2fba68..288868d1 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -130,6 +130,8 @@ "demo_reset": "Resetovať", "demo_tour": "Sprievodca", "tags": "Štítky", + "show_all_tags": "Zobraziť všetko ({count})", + "show_fewer_tags": "Zobraziť menej", "folders": "Priečinky", "shared": "Zdieľané", "mail": "Pošta", @@ -332,6 +334,8 @@ "next": "Ďalší", "move_to": "Presunúť do...", "remove_tag": "Odstrániť štítok", + "tag_filter_placeholder": "Filtrovať štítky", + "tag_no_matches": "Žiadne zodpovedajúce štítky", "more_count": "+{count} ďalších", "characters_count": "{count} znakov", "quick_reply_placeholder": "Napísať rýchlu odpoveď...", @@ -1020,7 +1024,22 @@ "add": "Pridať", "cancel": "Zrušiť", "migrating": "Aktualizácia štítku v existujúcich e-mailoch…", - "migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch" + "migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch", + "nesting": { + "label": "Vnorené štítky", + "description": "Vnorujte štítky pod iné štítky a zobrazujte ich v bočnom paneli ako strom." + }, + "parent_field": "Nadradený štítok", + "no_parent": "Bez nadradeného štítku", + "too_long": "Táto cesta štítku je príliš dlhá (najviac {max} znakov)", + "has_children_locked": "Pod týmto štítkom sú vnorené ďalšie štítky, preto sú jeho názov a nadradený štítok uzamknuté. Najprv ich presuňte alebo odstráňte.", + "has_children_delete": "Najprv odstráňte štítky vnorené pod týmto", + "visibility_field": "Viditeľnosť v bočnom paneli", + "visibility": { + "show": "Zobraziť", + "unread": "Zobraziť pri neprečítaných", + "hide": "Skryť" + } }, "notifications": { "test_sound": "Otestovať zvuk oznámenia", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6e6ed5a0..849e7609 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -130,6 +130,8 @@ "demo_reset": "Sıfırla", "demo_tour": "Tur", "tags": "Etiketler", + "show_all_tags": "Tümünü göster ({count})", + "show_fewer_tags": "Daha az göster", "folders": "Klasörler", "shared": "Paylaşılan", "mail": "Posta", @@ -332,6 +334,8 @@ "next": "Sonraki", "move_to": "Şuraya taşı...", "remove_tag": "Etiketi kaldır", + "tag_filter_placeholder": "Etiketleri filtrele", + "tag_no_matches": "Eşleşen etiket yok", "more_count": "+{count} daha", "characters_count": "{count} karakter", "quick_reply_placeholder": "Hızlı yanıt yazın...", @@ -1017,7 +1021,22 @@ "add": "Ekle", "cancel": "İptal", "migrating": "Mevcut e-postalardaki etiket güncelleniyor…", - "migration_error": "Mevcut e-postalardaki etiket güncellenemedi" + "migration_error": "Mevcut e-postalardaki etiket güncellenemedi", + "nesting": { + "label": "İç içe etiketler", + "description": "Etiketleri başka etiketlerin altına yerleştirin ve kenar çubuğunda ağaç olarak gösterin." + }, + "parent_field": "Üst etiket", + "no_parent": "Üst etiket yok", + "too_long": "Bu etiket yolu çok uzun (en fazla {max} karakter)", + "has_children_locked": "Bunun altında başka etiketler var, bu nedenle adı ve üst etiketi kilitli. Önce onları taşıyın veya kaldırın.", + "has_children_delete": "Önce bunun altındaki etiketleri kaldırın", + "visibility_field": "Kenar çubuğunda görünürlük", + "visibility": { + "show": "Göster", + "unread": "Okunmamış varsa göster", + "hide": "Gizle" + } }, "notifications": { "test_sound": "Bildirim sesini test et", diff --git a/locales/uk/common.json b/locales/uk/common.json index 6da37126..54e41245 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -130,6 +130,8 @@ "demo_reset": "Скинути", "demo_tour": "Тур", "tags": "Теги", + "show_all_tags": "Показати всі ({count})", + "show_fewer_tags": "Показати менше", "folders": "Папки", "mail": "Пошта", "nav_label": "Навігація", @@ -332,6 +334,8 @@ "next": "Далі", "move_to": "Перейти до...", "remove_tag": "Видалити тег", + "tag_filter_placeholder": "Фільтр тегів", + "tag_no_matches": "Немає відповідних тегів", "more_count": "+ ще {count}", "characters_count": "{count} символів", "quick_reply_placeholder": "Напишіть швидку відповідь...", @@ -1017,7 +1021,22 @@ "add": "додати", "cancel": "Скасувати", "migrating": "Оновлення ключового слова в наявних електронних листах…", - "migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах" + "migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах", + "nesting": { + "label": "Вкладені теги", + "description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева." + }, + "parent_field": "Батьківський тег", + "no_parent": "Без батьківського тега", + "too_long": "Цей шлях тега задовгий (щонайбільше {max} символів)", + "has_children_locked": "У цей тег вкладено інші теги, тому його назву та батьківський тег заблоковано. Спочатку перемістіть або видаліть їх.", + "has_children_delete": "Спочатку видаліть теги, вкладені в цей", + "visibility_field": "Видимість на бічній панелі", + "visibility": { + "show": "Показувати", + "unread": "Показувати за непрочитаних", + "hide": "Приховувати" + } }, "notifications": { "test_sound": "Тестовий звук сповіщення", diff --git a/locales/zh/common.json b/locales/zh/common.json index 54273d96..014116f7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -130,6 +130,8 @@ "demo_reset": "重置", "demo_tour": "引导", "tags": "标签", + "show_all_tags": "显示全部({count})", + "show_fewer_tags": "收起", "folders": "文件夹", "mail": "邮件", "nav_label": "导航", @@ -332,6 +334,8 @@ "next": "下一封", "move_to": "移动到…", "remove_tag": "删除标签", + "tag_filter_placeholder": "筛选标签", + "tag_no_matches": "没有匹配的标签", "more_count": "+{count} 更多", "characters_count": "{count} 个字符", "quick_reply_placeholder": "快速回复...", @@ -1017,7 +1021,22 @@ "add": "添加", "cancel": "取消", "migrating": "正在更新现有邮件的标签…", - "migration_error": "更新现有邮件的标签失败" + "migration_error": "更新现有邮件的标签失败", + "nesting": { + "label": "嵌套标签", + "description": "将标签嵌套在其他标签之下,并在侧边栏中以树形显示。" + }, + "parent_field": "上级标签", + "no_parent": "无上级标签", + "too_long": "此标签路径过长(最多 {max} 个字符)", + "has_children_locked": "此标签下嵌套了其他标签,因此其名称和上级标签已锁定。请先移动或删除它们。", + "has_children_delete": "请先删除嵌套在此标签下的标签", + "visibility_field": "侧边栏显示", + "visibility": { + "show": "显示", + "unread": "有未读时显示", + "hide": "隐藏" + } }, "notifications": { "test_sound": "测试通知声音", From d52dfebad4a8e50ff59a0598f1c20471d9ada669 Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 19:16:31 +0200 Subject: [PATCH 09/11] Fix Catalan translation warnings --- locales/ca/common.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/locales/ca/common.json b/locales/ca/common.json index 11b15781..533247b5 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -680,6 +680,38 @@ "recipient_name_placeholder": "Nom mostrat", "autocomplete_search_server": "Cerca al servidor", "autocomplete_searching": "Cercant...", + "toolbar": { + "bold": "Negreta", + "italic": "Cursiva", + "underline": "Subratllat", + "strikethrough": "Ratllat", + "text_color": "Color del text", + "remove_color": "Elimina el color", + "heading_1": "Encapçalament 1", + "heading_2": "Encapçalament 2", + "bullet_list": "Llista de pics", + "ordered_list": "Llista numerada", + "quote": "Cita", + "code_block": "Bloc de codi", + "align_left": "Alinea a l'esquerra", + "align_center": "Centra", + "align_right": "Alinea a la dreta", + "text_direction": "Direcció del text (RTL/LTR)", + "link": "Enllaç", + "table": "Taula", + "clear_formatting": "Neteja el format", + "undo": "Desfés", + "redo": "Refés", + "add_row_above": "Afegeix una fila a sobre", + "add_row_below": "Afegeix una fila a sota", + "add_column_before": "Afegeix una columna abans", + "add_column_after": "Afegeix una columna després", + "delete_row": "Elimina la fila", + "delete_column": "Elimina la columna", + "toggle_header_row": "Commuta la fila de capçalera", + "delete_table": "Elimina la taula", + "pick_size": "Tria la mida" + }, "send_filing_warning": "Enviat, però la neteja posterior a l'enviament ha fallat; és possible que quedi un esborrany obsolet." }, "confirm_dialog": { From ea7892b4974e4299ee48ee15ce1eeb27a21d04e5 Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 19:52:31 +0200 Subject: [PATCH 10/11] feat: Change the dev mode defaults to include nested tags --- app/api/dev-jmap/[...path]/route.ts | 12 ++++----- next.config.ts | 1 + .../__tests__/settings-store-keywords.test.ts | 27 ++++++++++++++++++- stores/settings-store.ts | 15 +++++++++-- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index faec6968..41b34ffa 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -120,7 +120,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1), + id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work/clients/acme': true }, size: 5100, receivedAt: daysAgo(1), from: [{ name: 'Dubois, Pierre', email: 'pierre@dubois.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'de Vries, Karel', email: 'karel@devries.example' }], @@ -152,7 +152,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0), + id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:work/clients': true, '$label:receipts': true }, size: 6200, receivedAt: daysAgo(0), from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: '[bulwark-webmail] New issue: Add dark mode toggle (#42)', @@ -181,7 +181,7 @@ const emails: MockEmail[] = [ }, // Newsletter with full HTML { - id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0), + id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal/finance': true }, size: 18200, receivedAt: daysAgo(0), from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Launchpad Weekly #47 - The future of the open web', @@ -228,7 +228,7 @@ const emails: MockEmail[] = [ ], }, { - id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3), + id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:personal': true }, size: 4100, receivedAt: hoursAgo(3), from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Code review request: JMAP-342 contact import', @@ -255,7 +255,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 4700, receivedAt: daysAgo(1), + id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:work': true }, size: 4700, receivedAt: daysAgo(1), from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Your Hetzner invoice is available - February 2026', @@ -355,7 +355,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6), + id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$color:work/archived': true }, size: 4100, receivedAt: daysAgo(6), from: [{ name: 'Stripe Developer', email: 'developer-updates@stripe.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Action required: API v2023-10 deprecation on April 15, 2026', diff --git a/next.config.ts b/next.config.ts index eeef0a99..104b8186 100644 --- a/next.config.ts +++ b/next.config.ts @@ -59,6 +59,7 @@ const nextConfig: NextConfig = { NEXT_PUBLIC_GIT_COMMIT: gitCommitHash, NEXT_PUBLIC_APP_VERSION: appVersion, NEXT_PUBLIC_BASE_PATH: basePath, + NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "", }, }; diff --git a/stores/__tests__/settings-store-keywords.test.ts b/stores/__tests__/settings-store-keywords.test.ts index 822c8a72..40747543 100644 --- a/stores/__tests__/settings-store-keywords.test.ts +++ b/stores/__tests__/settings-store-keywords.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store'; +import { useSettingsStore, DEFAULT_KEYWORDS, DEV_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store'; import type { KeywordDefinition } from '../settings-store'; describe('settings-store keywords', () => { @@ -24,6 +24,31 @@ describe('settings-store keywords', () => { const ids = DEFAULT_KEYWORDS.map((k) => k.id); expect(new Set(ids).size).toBe(ids.length); }); + + it('ships no nested tag, which is opt-in', () => { + DEFAULT_KEYWORDS.forEach((kw) => expect(kw.id).not.toContain('/')); + }); + }); + + describe('DEV_KEYWORDS', () => { + it('every nested tag has its parent defined, so the tree has no gaps', () => { + const ids = new Set(DEV_KEYWORDS.map((k) => k.id)); + DEV_KEYWORDS.forEach((kw) => { + const cut = kw.id.lastIndexOf('/'); + if (cut > 0) expect(ids, `orphan: ${kw.id}`).toContain(kw.id.slice(0, cut)); + }); + }); + + it('nests deeply enough to exercise the tree', () => { + const depths = DEV_KEYWORDS.map((k) => k.id.split('/').length); + expect(Math.max(...depths)).toBeGreaterThanOrEqual(3); + }); + + it('each dev keyword has a valid palette color and a unique id', () => { + const ids = DEV_KEYWORDS.map((k) => k.id); + expect(new Set(ids).size).toBe(ids.length); + DEV_KEYWORDS.forEach((kw) => expect(KEYWORD_PALETTE[kw.color]).toBeDefined()); + }); }); describe('KEYWORD_PALETTE', () => { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index e2ae45bf..2b628a57 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -215,6 +215,17 @@ export const DEFAULT_KEYWORDS: KeywordDefinition[] = [ { id: 'pink', label: 'Pink', color: 'pink' }, ]; +export const DEV_KEYWORDS: KeywordDefinition[] = [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'teal' }, + { id: 'work/clients/acme', label: 'Acme', color: 'green' }, + { id: 'personal', label: 'Personal', color: 'purple' }, + { id: 'personal/finance', label: 'Finance', color: 'amber' }, + { id: 'receipts', label: 'Receipts', color: 'gray' }, +]; + +const USING_MOCK_SERVER = process.env.NEXT_PUBLIC_DEV_MOCK_JMAP === 'true'; + interface SettingsState { // Appearance fontSize: FontSize; @@ -557,8 +568,8 @@ const DEFAULT_SETTINGS = { folderIcons: {} as Record, // Keywords - emailKeywords: DEFAULT_KEYWORDS, - nestedTags: false, + emailKeywords: USING_MOCK_SERVER ? DEV_KEYWORDS : DEFAULT_KEYWORDS, + nestedTags: USING_MOCK_SERVER, // Attachment Reminder attachmentReminderEnabled: true, From 1cdbf75270d77ca6d028dd6550cdd6db2d64827f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:39:49 +0200 Subject: [PATCH 11/11] fix: use full tag path in drag-drop toasts, fresh email in context menu markAsRead Nested tag toasts from drag-and-drop only showed the leaf name for non-root tags, contradicting the comment above it and making two same-named leaves under different parents (e.g. Personal/Receipts vs Work/Receipts) indistinguishable in the toast. The context menu's markAsRead handler was the one action left reading the stale contextMenu.data instead of the live-refreshed contextMenuEmail introduced alongside it, so it could act on outdated email state while every sibling handler was already updated. --- components/email/email-list.tsx | 2 +- components/layout/sidebar.tsx | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 4329a53d..9251f78c 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -598,7 +598,7 @@ export function EmailList({ onReplyAll={() => onReplyAll?.(contextMenuEmail!)} onForward={() => onForward?.(contextMenuEmail!)} onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)} - onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} + onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)} onToggleStar={() => onToggleStar?.(contextMenuEmail!)} onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined} onDelete={() => onDelete?.(contextMenuEmail!)} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index f0c936a9..b3aa1078 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -609,21 +609,26 @@ function TagItem({ // Nested rows are placed by their indentation, so they show their own name. // A root spells out its path, which matters when an intermediate tag is // missing from this client's settings and the row would otherwise read as a - // bare leaf name. Toasts have the room for the whole thing. + // bare leaf name. const labelCandidates = node.depth === 0 ? tagNameCandidates(node.id) : [node.label]; const label = labelCandidates[0]; + // Toasts have the room for the whole thing, and no indentation to lean on, + // so they always spell out the full path - otherwise two leaves with the + // same name in different branches (e.g. "Personal/Receipts" and + // "Work/Receipts") would read as the same tag. + const fullLabel = tagNameCandidates(node.id)[0]; const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget } = useTagDrop({ tagId: node.id, onSuccess: (count) => { if (count === 1) { - toast.success(t('email_tagged'), label); + toast.success(t('email_tagged'), fullLabel); } else { - toast.success(t('emails_tagged', { count }), label); + toast.success(t('emails_tagged', { count }), fullLabel); } }, onError: () => { - toast.error(t('tag_failed'), label); + toast.error(t('tag_failed'), fullLabel); }, });