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