diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 98d71d31..cd844fc7 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1833,7 +1833,7 @@ export default function Home() { keywords['$pinned'] = true; } - // Same unified-view routing as color tags: write to the email's own + // Same unified-view routing as tags: write to the email's own // account via the login it is reachable through. (#281) const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; @@ -1857,25 +1857,25 @@ export default function Home() { } }; - const handleSetColorTag = async (emailId: string, color: string | null) => { + const handleSetTag = async (emailId: string, tagId: string | null) => { if (!client) return; try { - // Remove any existing label/color tags + // Remove any existing tag keywords const email = emails.find(e => e.id === emailId); if (!email) return; const keywords = { ...email.keywords }; - if (color === null) { - // Remove all label/color tags + if (tagId === null) { + // Remove all tag keywords Object.keys(keywords).forEach(key => { if (key.startsWith("$label:") || key.startsWith("$color:")) { keywords[key] = false; } }); } else { - const jmapKey = `$label:${color}`; + const jmapKey = `$label:${tagId}`; if (keywords[jmapKey]) { // Toggle off if already active keywords[jmapKey] = false; @@ -1907,7 +1907,7 @@ export default function Home() { // Refresh tag counts fetchTagCounts(client); } catch (error) { - console.error("Failed to set color tag:", error); + console.error("Failed to set tag:", error); } }; @@ -3309,8 +3309,8 @@ export default function Home() { onArchive={async (email) => { await handleArchive(email); }} - onSetColorTag={(emailId, color) => { - handleSetColorTag(emailId, color); + onSetTag={(emailId, color) => { + handleSetTag(emailId, color); }} onMoveToMailbox={async (emailId, mailboxId) => { if (client) { @@ -3534,7 +3534,7 @@ export default function Home() { }} onArchive={() => handleArchive()} onToggleStar={handleToggleStar} - onSetColorTag={handleSetColorTag} + onSetTag={handleSetTag} onMarkAsSpam={() => handleMarkAsSpam()} onUndoSpam={() => handleUndoSpam()} onMarkAsRead={async (emailId, read) => { diff --git a/components/email/__tests__/tag-picker.test.tsx b/components/email/__tests__/tag-picker.test.tsx new file mode 100644 index 00000000..e7c7e664 --- /dev/null +++ b/components/email/__tests__/tag-picker.test.tsx @@ -0,0 +1,101 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TagPicker } from '../tag-picker'; +import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store'; + +const TAGS: KeywordDefinition[] = [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, + { id: 'work/clients/acme', label: 'Acme', color: 'red' }, + { id: 'personal', label: 'Personal', color: 'purple' }, +]; + +/** Ten tags is the point at which the filter box appears. */ +const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({ + id: `tag-${i}`, + label: i === 0 ? 'Invoices' : `Tag ${i}`, + color: 'blue', +})); + +describe('TagPicker', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true }); + }); + + it('names a nested tag by its own label, not the whole path', () => { + render( {}} />); + + // The tree conveys the hierarchy, so a child needs only its own name. + expect(screen.getByText('Clients')).toBeInTheDocument(); + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument(); + }); + + it('indents each level below its parent', () => { + const { container } = render( {}} />); + const acme = screen.getByText('Acme'); + + // Two levels down: two nested indent wrappers between it and the list. + const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4'); + expect(indents).not.toBeNull(); + expect(container.querySelectorAll('.ps-4').length).toBe(2); + }); + + it('marks the applied tags and reports toggles by id', () => { + const onToggle = vi.fn(); + render(); + + const row = screen.getByText('Clients').closest('button')!; + expect(row).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false'); + + fireEvent.click(row); + expect(onToggle).toHaveBeenCalledWith('work/clients'); + }); + + it('offers the clear-all row only while something is applied', () => { + const { rerender } = render( {}} onClearAll={() => {}} />); + expect(screen.queryByText('remove_tag')).not.toBeInTheDocument(); + + rerender( {}} onClearAll={() => {}} />); + expect(screen.getByText('remove_tag')).toBeInTheDocument(); + }); + + it('hides the filter box until the list is long enough to need one', () => { + render( {}} />); + expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument(); + + useSettingsStore.setState({ emailKeywords: MANY_TAGS }); + render( {}} />); + expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0); + }); + + it('flattens to matches while filtering, and says so when there are none', () => { + useSettingsStore.setState({ emailKeywords: MANY_TAGS }); + const { container } = render( {}} />); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } }); + expect(within(container).getByText('Invoices')).toBeInTheDocument(); + expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } }); + expect(within(container).getByText('tag_no_matches')).toBeInTheDocument(); + }); + + it('matches the full path, so a child is reachable by its parent name', () => { + useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] }); + const { container } = render( {}} />); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } }); + // Filtered rows are flat, so they carry the whole path. + expect(within(container).getByText('Work/Clients')).toBeInTheDocument(); + }); + + it('lists tags flat when nesting is off', () => { + useSettingsStore.setState({ nestedTags: false }); + const { container } = render( {}} />); + + expect(container.querySelectorAll('.ps-4').length).toBe(0); + expect(screen.getByText('Clients')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 9b4ac331..1f7bd498 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -118,6 +118,67 @@ describe('ThreadListItem tag badge', () => { }); }); +describe('ThreadListItem multi-message thread', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + useEmailStore.setState({ + selectedEmailIds: new Set(), + selectedMailbox: 'inbox', + }); + }); + + function renderThread(emails: Email[], expanded = false) { + const [thread] = groupEmailsByThread(emails); + return render( + {}} + onEmailSelect={() => {}} + />, + ); + } + + it('carries the tags of every message, not just the first', () => { + // A collapsed row stands in for the whole thread, so a tag applied only to + // a later message still has to surface. + renderThread([ + makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }), + ]); + + expect(screen.getByText('Red')).toBeInTheDocument(); + expect(screen.getByText('Blue')).toBeInTheDocument(); + }); + + it('names a tag shared by several messages once', () => { + renderThread([ + makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }), + ]); + + expect(screen.getAllByText('Red')).toHaveLength(1); + }); + + it('shows each message its own tags once the thread is expanded', () => { + renderThread( + [ + makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }), + ], + true, + ); + + // Once on the header and once on the message that carries it. + expect(screen.getAllByText('Red').length).toBeGreaterThan(1); + }); +}); + describe('ThreadListItem row content', () => { beforeEach(() => { useSettingsStore.setState({ diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index bccd8a72..5c26667d 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -23,8 +23,6 @@ import { Archive, FolderInput, Tag, - X, - Check, Inbox, Send, File, @@ -36,11 +34,9 @@ import { XCircle, Paperclip, } from "lucide-react"; -import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; +import { buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; -import { useKeywordFormat } from "@/hooks/use-keyword-format"; -import { TagOptionLabel } from "./tag-option-label"; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { TagPicker } from "./tag-picker"; interface Position { x: number; @@ -68,7 +64,7 @@ interface EmailContextMenuProps { onTogglePinned?: () => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMoveToMailbox?: (mailboxId: string) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; @@ -103,8 +99,8 @@ const getMailboxIcon = (role?: string) => { } }; -// Get all active label/color tag IDs from email keywords -const getCurrentColors = (keywords: Record | undefined): 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)) { @@ -137,7 +133,7 @@ export function EmailContextMenu({ onTogglePinned, onDelete, onArchive, - onSetColorTag, + onSetTag, onMoveToMailbox, onMarkAsSpam, onUndoSpam, @@ -154,15 +150,12 @@ export function EmailContextMenu({ }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const tSidebar = useTranslations("sidebar"); - const _tColor = useTranslations("email_viewer.color_tag"); const tEmailViewer = useTranslations("email_viewer"); - const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const { tagNameCandidates } = useKeywordFormat(); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isPinned = email.keywords?.['$pinned'] === true; const isDraft = email.keywords?.['$draft'] === true; - const currentColors = getCurrentColors(email.keywords); + const currentTagIds = getCurrentTagIds(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; // Marking your own outgoing mail as spam makes no sense - hide the action @@ -171,13 +164,6 @@ export function EmailContextMenu({ const isScheduled = email.isScheduled === true; const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending'; - // Build color options from keyword definitions in settings - const colorOptions = emailKeywords.map((kw) => ({ - candidates: tagNameCandidates(kw.id), - value: kw.id, - color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500", - })); - // Build mailbox tree for move-to submenu with proper hierarchy const moveTargetIds = new Set( mailboxes @@ -383,39 +369,14 @@ export function EmailContextMenu({ {/* Set tag submenu - only for single email */} {!showBatchActions && ( - -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} + +
+ handleAction(() => onSetTag?.(tagId))} + onClearAll={() => handleAction(() => onSetTag?.(null))} + />
- {currentColors.length > 0 && ( - <> - - handleAction(() => onSetColorTag?.(null))} - /> - - )}
)} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index a2691da7..2435d1c1 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -15,7 +15,7 @@ interface EmailHoverActionsProps { onMarkAsRead?: (read: boolean) => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMarkAsSpam?: () => void; // When the email lives in a junk folder (incl. the aggregate "All Junk" view) // the spam quick-action flips to "not spam". @@ -76,7 +76,7 @@ export function EmailHoverActions({ onMarkAsRead, onDelete, onArchive, - onSetColorTag, + onSetTag, onMarkAsSpam, isInJunk = false, onUndoSpam, @@ -112,7 +112,7 @@ export function EmailHoverActions({ onArchive?.(); break; case "tag": - onSetColorTag?.(null); + onSetTag?.(null); break; case "spam": if (isInJunk) onUndoSpam?.(); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 6dfe4c33..4333f72d 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useTranslations } from "next-intl"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { SearchChips } from "@/components/search/search-chips"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; @@ -39,7 +40,7 @@ interface EmailListProps { onTogglePinned?: (email: Email) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onMoveToMailbox?: (emailId: string, mailboxId: string) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; @@ -70,7 +71,7 @@ export function EmailList({ onTogglePinned, onDelete, onArchive, - onSetColorTag, + onSetTag, onMarkAsSpam, onUndoSpam, onMoveToMailbox, @@ -136,6 +137,8 @@ export function EmailList({ const [isProcessing, setIsProcessing] = useState(false); const parentRef = useRef(null); + // One tag treatment for the whole list, measured from the scroll container. + const tagDisplay = useMeasuredTagDisplay(parentRef); const density = useSettingsStore((state) => state.density); const showPreview = useSettingsStore((state) => state.showPreview); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -332,6 +335,7 @@ export function EmailList({ }, [density, isFocusedMailLayout, showPreview]); return ( +
{/* Batch Actions Toolbar */}
onMarkAsRead(email, read) : undefined} onDelete={onDelete ? (email) => onDelete(email) : undefined} onArchive={onArchive ? (email) => onArchive(email) : undefined} - onSetColorTag={onSetColorTag} + onSetTag={onSetTag} onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined} /> @@ -591,7 +595,7 @@ export function EmailList({ onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} onDelete={() => onDelete?.(contextMenu.data!)} onArchive={() => onArchive?.(contextMenu.data!)} - onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)} + onSetTag={(color) => onSetTag?.(contextMenu.data!.id, color)} onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} @@ -645,5 +649,6 @@ export function EmailList({
+ ); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 7dca9126..8883e444 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -12,7 +12,9 @@ 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 { TagBadge } from "./tag-badge"; +import { TagPicker } from "./tag-picker"; +import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; @@ -76,7 +78,7 @@ import { import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { toast } from "@/stores/toast-store"; @@ -117,7 +119,7 @@ interface EmailViewerProps { onArchive?: () => void; onToggleStar?: () => void; onMarkAsRead?: (emailId: string, read: boolean) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void; onQuickReply?: (body: string) => Promise; onMarkAsSpam?: () => void; @@ -202,7 +204,7 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st return 'Attachment'; }; -const getCurrentColors = (keywords: Record | undefined): string[] => { +const getCurrentTagIds = (keywords: Record | undefined): string[] => { if (!keywords) return []; const tags: string[] = []; for (const key of Object.keys(keywords)) { @@ -630,7 +632,7 @@ export function EmailViewer({ onArchive, onToggleStar, onMarkAsRead, - onSetColorTag, + onSetTag, onDownloadAttachment, onQuickReply, onMarkAsSpam, @@ -669,7 +671,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 { sortTagIds, tagColor } = useKeywordFormat(); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -712,12 +714,6 @@ export function EmailViewer({ const isScheduled = email?.isScheduled === true; const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending'; - // Color options for email tags (from user-defined keyword settings) - const colorOptions = emailKeywords.map((kw) => ({ - candidates: tagNameCandidates(kw.id), - value: kw.id, - color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500', - })); // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); @@ -822,8 +818,13 @@ export function EmailViewer({ const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); - const currentColors = getCurrentColors(email?.keywords); - const currentColor = currentColors[0] ?? null; + const currentTagIds = getCurrentTagIds(email?.keywords); + const sortedTagIds = sortTagIds(currentTagIds); + // The header spans the reading pane, so it measures its own width rather than + // inheriting the message list's answer. + const headerTagsRef = useRef(null); + const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef); + const currentColor = currentTagIds[0] ?? null; // Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic // onRenderEmailBody hook. Verification/decryption status UI is provided by the @@ -1021,7 +1022,7 @@ export function EmailViewer({ showToolbarLabels, isLoading, moveTree.length, - colorOptions.length, + emailKeywords.length, currentColor, isInJunkFolder, isTablet, @@ -3000,65 +3001,19 @@ export function EmailViewer({
{tagMenuOpen && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} +
+ { if (email) onSetTag?.(email.id, tagId); setTagMenuOpen(false); }} + onClearAll={() => { if (email) onSetTag?.(email.id, null); setTagMenuOpen(false); }} + />
)}
@@ -3250,7 +3205,7 @@ export function EmailViewer({
)} {/* Overflow: tag - submenu */} - {colorOptions.length > 0 && ( + {emailKeywords.length > 0 && (
setMoreMenuSub('tag')} onMouseLeave={() => setMoreMenuSub(null)} @@ -3264,36 +3219,12 @@ export function EmailViewer({ {moreMenuSub === 'tag' && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} +
+ { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }} + onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }} + />
)}
@@ -3437,19 +3368,21 @@ export function EmailViewer({ {isStarred ? t('tooltips.unstar') : t('tooltips.star')} {/* Tag (opens sub-view) */} - {colorOptions.length > 0 && ( + {emailKeywords.length > 0 && ( - ); - })} - {currentColors.length > 0 && ( - - )} - + {moreMenuSub === 'tag' && ( + { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }} + onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }} + /> )}
@@ -3631,28 +3542,19 @@ export function EmailViewer({ )} /> )} - {/* Color tag dots */} - {currentColors.length > 0 && ( - - {currentColors.map((tagId) => { - const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; - const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500'; - return ( - - ); - })} - - )} {isImportant && ( {t('important')} )}
+ {sortedTagIds.length > 0 && ( +
+ {sortedTagIds.map((tagId) => ( + + ))} +
+ )}
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
diff --git a/components/email/tag-badge.tsx b/components/email/tag-badge.tsx new file mode 100644 index 00000000..9c61f6d8 --- /dev/null +++ b/components/email/tag-badge.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useShortenedText } from "@/hooks/use-shortened-text"; + +/** + * How much room the surface has for a tag. + * - `badge` names the tag; `dot` only identifies it by colour. + */ +export type TagBadgeVariant = "badge" | "dot"; + +/** + * The lozenge shape, shared so anything standing next to a tag lines up with + * it rather than approximating its padding and text size. + */ +export const TAG_LOZENGE_CLASS = + "inline-flex min-w-0 shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium"; + +/** + * The row a group of tags sits in. Using it for neighbouring lozenges too keeps + * the spacing between them the same as the spacing within them - a wider gap on + * one side is what makes a neighbour look indented. + */ +export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1"; + +/** + * A tag, drawn the one way tags are drawn. + * + * The lozenge carries the colour in its border and text rather than pairing a + * swatch with plain text: the name is the tag, and the colour is how you pick + * it out of a row at a glance. That also matches every other coloured pill in + * the app, all of which set a text colour alongside the background. + * + * A deep name shortens to fit its own box (`Work/../Acme`) before the browser + * clips it, so the outermost and innermost levels survive. + */ +export function TagBadge({ + tagId, + variant, + className, +}: { + tagId: string; + variant: TagBadgeVariant; + className?: string; +}) { + const { tagName, tagNameCandidates, tagColor } = useKeywordFormat(); + const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId)); + const color = tagColor(tagId); + const name = tagName(tagId); + + if (variant === "dot") { + return ( + + ); + } + + return ( + + {shortenedName} + + ); +} diff --git a/components/email/tag-option-label.tsx b/components/email/tag-option-label.tsx deleted file mode 100644 index e396bdad..00000000 --- a/components/email/tag-option-label.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { useShortenedText } from "@/hooks/use-shortened-text"; - -/** - * A tag name inside one of the tag pickers, shortened to what that picker has - * room for. - * - * The pickers differ in width - a narrow popover, a context submenu, a - * full-width mobile sheet - so each row measures itself instead of sharing one - * cap. `candidates` runs longest first (see `keywordRenderings`); the full name - * stays reachable through the tooltip. - */ -export function TagOptionLabel({ - candidates, - className, -}: { - candidates: string[]; - className?: string; -}) { - const [labelRef, shortenedLabel] = useShortenedText(candidates); - - return ( - - {shortenedLabel} - - ); -} diff --git a/components/email/tag-picker.tsx b/components/email/tag-picker.tsx new file mode 100644 index 00000000..e2df5e1d --- /dev/null +++ b/components/email/tag-picker.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Check, Search, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useSettingsStore } from "@/stores/settings-store"; +import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; + +/** Below this many tags a filter box costs more room than it saves. */ +const SEARCH_THRESHOLD = 10; + +/** + * The list of tags to apply to a message. + * + * Shared by all four places one appears - the toolbar popover, the overflow + * flyout, the mobile sheet and the context menu - because they had drifted into + * four different dot sizes, check alignments and separators, and only one of + * them capped its height. + * + * Nested tags are drawn as a tree rather than repeating the parent's name on + * every child. Filtering flattens it: with a query the hierarchy is noise, and + * the full path is what gets matched. + */ +export function TagPicker({ + selectedIds, + onToggle, + onClearAll, + touch = false, +}: { + selectedIds: string[]; + onToggle: (tagId: string) => void; + onClearAll?: () => void; + /** Larger hit areas for the mobile sheet. */ + touch?: boolean; +}) { + const t = useTranslations("email_viewer"); + const keywords = useSettingsStore((state) => state.emailKeywords); + const nestedTags = useSettingsStore((state) => state.nestedTags); + const { tagName, tagColor } = useKeywordFormat(); + const [query, setQuery] = useState(""); + + const trimmedQuery = query.trim().toLowerCase(); + const showSearch = keywords.length >= SEARCH_THRESHOLD; + + const matches = useMemo( + () => + trimmedQuery + ? keywords.filter((keyword) => tagName(keyword.id).toLowerCase().includes(trimmedQuery)) + : [], + // `tagName` is rebuilt whenever the definitions or the nesting setting change. + [keywords, trimmedQuery, tagName], + ); + + const tree = useMemo( + () => (nestedTags ? buildKeywordTree(keywords) : keywords.map((k) => ({ ...k, children: [], depth: 0 }))), + [keywords, nestedTags], + ); + + const rowClass = cn( + "w-full text-start flex items-center gap-2 hover:bg-muted cursor-pointer", + touch ? "px-4 py-2.5 min-h-[44px] text-sm gap-3" : "px-3 py-1.5 text-sm", + ); + const dotClass = touch ? "w-3.5 h-3.5" : "w-3 h-3"; + const checkClass = touch ? "w-4 h-4" : "w-3.5 h-3.5"; + + const renderRow = (id: string, label: string) => { + const isActive = selectedIds.includes(id); + return ( + + ); + }; + + const renderBranch = (nodes: KeywordNode[]) => + nodes.map((node) => ( +
+ {renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)} + {node.children.length > 0 &&
{renderBranch(node.children)}
} +
+ )); + + return ( + <> + {showSearch && ( +
+ + setQuery(event.target.value)} + placeholder={t("tag_filter_placeholder")} + aria-label={t("tag_filter_placeholder")} + className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ )} + +
+ {trimmedQuery ? ( + matches.length > 0 ? ( + matches.map((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' },