feat: Add nesting of tags in a tree

- levels are joined by forward slashes in the keywords
- behaviour is opt-in for now
- long paths are shortened if there is not enough display room

Closes #687.
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:38 +02:00
parent ce97a54aaa
commit ca0ba818b7
22 changed files with 1084 additions and 95 deletions
+1
View File
@@ -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
+26 -21
View File
@@ -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 && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="flex-1">{option.name}</span>
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
<div className="max-w-[18rem]">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
<button
key={option.value}
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<TagOptionLabel candidates={option.candidates} />
{isActive && (
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
)}
</button>
);
})}
</div>
{currentColors.length > 0 && (
<>
<ContextMenuSeparator />
+8 -2
View File
@@ -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 && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
<span
key={kd.id}
className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')}
title={tagName(kd.id)}
/>
))}
<span className={cn(
'text-xs tabular-nums',
@@ -290,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
)} title={tagName(kd.id)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
+16 -8
View File
@@ -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({
})}
</span>
{showToolbarLabels && currentColors.length === 1 && (
<span className="text-xs font-medium text-foreground">
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
</span>
<TagOptionLabel
candidates={tagNameCandidates(currentColors[0])}
className="max-w-40 text-xs font-medium text-foreground"
/>
)}
</>
) : (
@@ -3038,7 +3042,7 @@ export function EmailViewer({
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
<TagOptionLabel candidates={option.candidates} />
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
@@ -3273,7 +3277,7 @@ export function EmailViewer({
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
<TagOptionLabel candidates={option.candidates} />
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
@@ -3555,7 +3559,7 @@ export function EmailViewer({
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
<TagOptionLabel candidates={option.candidates} />
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
@@ -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 (
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
<span
key={tagId}
className={cn("w-2.5 h-2.5 rounded-full shrink-0", dotClass)}
title={tagName(tagId)}
/>
);
})}
</span>
+29
View File
@@ -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 (
<span ref={labelRef} className={cn("min-w-0 truncate", className)} title={candidates[0]}>
{shortenedLabel}
</span>
);
}
+16 -6
View File
@@ -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<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, SingleEmailItemProps>(
)}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
<span
key={kd.id}
className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')}
title={tagName(kd.id)}
/>
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
@@ -351,7 +357,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
<span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}>
)} title={tagName(kd.id)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{kd.label}
</span>
@@ -499,7 +505,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => 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<HTMLDivElement, ThreadListItemPro
)}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
<span
className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')}
title={tagName(keywordDef.id)}
/>
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
@@ -827,7 +837,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
)} title={tagName(keywordDef.id)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
+3 -1
View File
@@ -18,6 +18,7 @@ import type {
import type { Mailbox } from "@/lib/jmap/types";
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
interface FilterRuleModalProps {
rule?: FilterRule;
@@ -89,6 +90,7 @@ export function FilterRuleModal({
const t = useTranslations("settings.filters");
const isEdit = !!rule;
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { tagName } = useKeywordFormat();
const [name, setName] = useState(rule?.name || "");
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
@@ -477,7 +479,7 @@ export function FilterRuleModal({
>
<option value="">{t("label_placeholder")}</option>
{emailKeywords.map((kw) => (
<option key={kw.id} value={kw.id}>{kw.label}</option>
<option key={kw.id} value={kw.id}>{tagName(kw.id)}</option>
))}
</select>
)}
+116 -35
View File
@@ -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 (
<div
@@ -353,7 +361,7 @@ function SidebarRow({
</span>
{!isCollapsed && (
<>
<span className="flex-1 truncate">{label}</span>
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
<SidebarRowCounts
unread={unread}
total={total}
@@ -559,42 +567,54 @@ const TAG_ICON_COLOR: Record<string, string> = {
};
function TagItem({
kw,
isSelected,
node,
selectedKeyword,
expandedTags,
isCollapsed,
onTagSelect,
totalCount,
unreadCount,
onToggleExpand,
tagCounts,
colorful,
}: {
kw: KeywordDefinition;
isSelected: boolean;
node: KeywordNode;
selectedKeyword: string | null;
expandedTags: Set<string>;
isCollapsed: boolean;
onTagSelect?: (keywordId: string | null) => void;
totalCount: number;
unreadCount: number;
onToggleExpand: (keywordId: string) => void;
tagCounts: Record<string, { total: number; unread: number }>;
colorful: boolean;
}) {
const t = useTranslations('notifications');
const palette = KEYWORD_PALETTE[kw.color];
const { tagNameCandidates } = 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 ? (
<Tag
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[kw.color] || "text-muted-foreground")}
className={cn("w-4 h-4 flex-shrink-0", TAG_ICON_COLOR[node.color] || "text-muted-foreground")}
fill="currentColor"
/>
) : (
@@ -602,18 +622,38 @@ function TagItem({
);
return (
<SidebarRow
icon={tagIcon}
label={kw.label}
depth={0}
isSelected={isSelected}
unread={unreadCount}
total={totalCount}
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
<>
<SidebarRow
icon={tagIcon}
label={label}
labelCandidates={labelCandidates}
depth={node.depth}
isSelected={isSelected}
unread={tagCounts[node.id]?.unread ?? 0}
total={tagCounts[node.id]?.total ?? 0}
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
hasChildren={hasChildren}
isExpanded={isExpanded}
onExpandToggle={() => onToggleExpand(node.id)}
isCollapsed={isCollapsed}
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
isValidDropTarget={isValidDropTarget}
/>
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
<TagItem
key={child.id}
node={child}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
onToggleExpand={onToggleExpand}
tagCounts={tagCounts}
colorful={colorful}
/>
))}
</>
);
}
@@ -737,6 +777,7 @@ export function Sidebar({
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
const [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) => (
<TagItem
key={kw.id}
kw={kw}
isSelected={selectedKeyword === kw.id}
key={node.id}
node={node}
selectedKeyword={selectedKeyword}
expandedTags={expandedTags}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
totalCount={tagCounts[kw.id]?.total ?? 0}
unreadCount={tagCounts[kw.id]?.unread ?? 0}
onToggleExpand={handleToggleTagExpand}
tagCounts={tagCounts}
colorful={colorfulSidebarIcons}
/>
))}
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { KeywordSettings } from '../keyword-settings';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
// Mock SettingsSection to just render children
vi.mock('../settings-section', () => ({
// Mock SettingsSection to just render children, keeping the real controls
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('KeywordSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
});
it('renders all default keywords', () => {
@@ -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(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
});
it('nests a new tag under the selected parent', () => {
useSettingsStore.setState({
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
});
it('shows nested tags by their full path', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
});
it('rejects a path that would exceed the keyword length limit', () => {
const deepId = 'a'.repeat(240);
useSettingsStore.setState({
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
expect(screen.getByText('too_long')).toBeInTheDocument();
expect(screen.getByText('add').closest('button')).toBeDisabled();
});
it('locks the name and the delete action of a tag that has nested tags', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
fireEvent.click(screen.getAllByTitle('edit')[0]);
expect(screen.getByDisplayValue('Work')).toBeDisabled();
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
});
});
+121 -15
View File
@@ -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 (
<div
@@ -76,8 +105,20 @@ function KeywordRow({
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
<span
ref={nameRef}
className="flex-1 min-w-0 text-sm font-medium truncate"
title={formatKeyword(keyword.id, keywords, nestedTags)}
>
{shortenedName}
</span>
<span
ref={keywordRef}
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
title={KEYWORD_PREFIX + keyword.id}
>
{shortenedKeyword}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
@@ -90,8 +131,9 @@ function KeywordRow({
<button
type="button"
onClick={onDelete}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t("delete")}
disabled={hasChildren}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
title={hasChildren ? t("has_children_delete") : t("delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -102,37 +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 (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
{nestedTags && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("parent_field")}
</label>
<Select
value={parentId}
onChange={setParentId}
options={parentOptions}
disabled={isLocked}
ariaLabel={t("parent_field")}
className="w-full"
/>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("label_field")}
@@ -141,15 +220,29 @@ function KeywordEditForm({
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isLocked}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
placeholder={t("label_placeholder")}
autoFocus
maxLength={30}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
{nestedTags && normalizedId.length > 0 && (
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
{KEYWORD_PREFIX + normalizedId}
</p>
)}
{isLocked && (
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
)}
{isDuplicate && (
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
)}
{isTooLong && (
<p className="text-xs text-destructive mt-1">
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
</p>
)}
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
@@ -182,7 +275,7 @@ function KeywordEditForm({
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
@@ -266,6 +359,13 @@ export function KeywordSettings() {
return (
<SettingsSection title={t("title")} description={t("description")}>
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
<ToggleSwitch
checked={nestedTags}
onChange={(checked) => updateSetting("nestedTags", checked)}
/>
</SettingItem>
<div className="space-y-2">
{isMigrating && (
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
@@ -278,7 +378,9 @@ export function KeywordSettings() {
<KeywordEditForm
key={keyword.id}
initial={keyword}
keywords={emailKeywords}
existingIds={existingIds.filter((id) => id !== keyword.id)}
nestedTags={nestedTags}
onSave={handleEdit}
onCancel={() => setEditingId(null)}
/>
@@ -286,6 +388,8 @@ export function KeywordSettings() {
<KeywordRow
key={keyword.id}
keyword={keyword}
keywords={emailKeywords}
nestedTags={nestedTags}
onEdit={() => {
setEditingId(keyword.id);
setIsAdding(false);
@@ -303,7 +407,9 @@ export function KeywordSettings() {
{isAdding ? (
<KeywordEditForm
keywords={emailKeywords}
existingIds={existingIds}
nestedTags={nestedTags}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
+11 -2
View File
@@ -111,15 +111,24 @@ interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
ariaLabel?: string;
}
export function Select({ value, onChange, options }: SelectProps) {
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label={ariaLabel}
dir="auto"
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
className={cn(
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
className
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { useShortenedText } from '../use-shortened-text';
const CANDIDATES = ['Work/Clients/Acme/Sales', 'Work/../Acme/Sales', 'Work/.../Sales'];
/**
* Reports `width` for the observed element and measures text at 10px per
* character, so a width of N*10 fits any candidate of N characters or fewer.
*/
function stubMeasurement(width: number) {
// Implementing the interface rather than passing an anonymous class keeps the
// members the hook never calls from reading as dead code.
class StubResizeObserver implements ResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
/** The hook observes once on mount; hand it `width` straight back. */
observe(target: Element) {
this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this);
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', StubResizeObserver);
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
font: '',
measureText: (text: string) => ({ width: text.length * 10 }),
} as unknown as CanvasRenderingContext2D);
}
function Probe({ candidates }: { candidates: string[] }) {
const [ref, text] = useShortenedText(candidates);
return <span ref={ref} data-testid="probe">{text}</span>;
}
function renderProbe(candidates: string[]): string {
render(<Probe candidates={candidates} />);
return screen.getByTestId('probe').textContent ?? '';
}
describe('useShortenedText', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns the longest candidate where the DOM cannot be measured', () => {
// No ResizeObserver: server rendering, and jsdom by default. Showing the
// whole path beats shortening it on a guess.
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
});
it('keeps the full path when the element is wide enough', () => {
stubMeasurement(230);
expect(renderProbe(CANDIDATES)).toBe('Work/Clients/Acme/Sales');
});
it('steps down only as far as the width requires', () => {
stubMeasurement(200);
expect(renderProbe(CANDIDATES)).toBe('Work/../Acme/Sales');
});
it('falls back to the shortest candidate when none of them fit', () => {
stubMeasurement(40);
expect(renderProbe(CANDIDATES)).toBe('Work/.../Sales');
});
});
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useMemo } from "react";
import { useSettingsStore } from "@/stores/settings-store";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
/**
* Names 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.
*/
export function useKeywordFormat() {
const keywords = useSettingsStore((state) => state.emailKeywords);
const nested = useSettingsStore((state) => state.nestedTags);
return useMemo(
() => ({
/** The tag's display name. */
tagName: (id: string) => formatKeyword(id, keywords, nested),
/** Its progressively shorter forms, longest first, for `useShortenedText`. */
tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)),
}),
[keywords, nested],
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useMemo, useState } from "react";
/**
* Measures text the way the browser will, using the font the element actually
* renders with. One canvas is reused for every measurement.
*/
let measureContext: CanvasRenderingContext2D | null | undefined;
function measureText(text: string, font: string): number {
if (measureContext === undefined) {
measureContext = document.createElement("canvas").getContext("2d");
}
if (!measureContext) return 0;
measureContext.font = font;
return measureContext.measureText(text).width;
}
/**
* Picks the first of `candidates` that fits the element the returned ref is
* attached to, remeasuring whenever that element is resized.
*
* Candidates run longest first, so the result is the most complete one there is
* room for. A character budget cannot do this job: the columns this is used in
* are resized by the user and share their row with controls whose width depends
* on the locale, so any fixed number is either so generous that it never
* triggers or so tight that it shortens text that would have fit.
*
* Attach the ref to an element whose width does *not* depend on its own text -
* a flex child that is allowed to shrink, i.e. one with `truncate` or
* `min-w-0`. On anything else, picking a shorter candidate would change the
* width that picked it and the two would oscillate.
*
* Where measurement is unavailable - server rendering, and jsdom under test -
* this returns the first candidate, so the text is complete rather than
* arbitrarily shortened.
*/
export function useShortenedText(
candidates: string[],
): [(node: HTMLElement | null) => void, string] {
const [element, setElement] = useState<HTMLElement | null>(null);
const [box, setBox] = useState<{ width: number; font: string } | null>(null);
useEffect(() => {
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const style = window.getComputedStyle(element);
setBox({
width: entry.contentRect.width,
font: `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
});
});
observer.observe(element);
return () => observer.disconnect();
}, [element]);
// Candidates are rebuilt on every render, so key the choice on their content.
// They must not contain a newline, which keeps this join unambiguous.
const key = candidates.join("\n");
return [
setElement,
useMemo(() => {
const options = key.split("\n");
if (!box || box.width === 0) return options[0];
return (
options.find((option) => measureText(option, box.font) <= box.width)
?? options[options.length - 1]
);
}, [key, box]),
];
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
import type { KeywordDefinition } from "@/stores/settings-store";
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
// Work
// Clients
// Acme
// Personal
const KEYWORDS: KeywordDefinition[] = [
kw("work", "Work"),
kw("work/clients", "Clients"),
kw("work/clients/acme", "Acme"),
kw("work/personal", "Personal"),
];
describe("formatKeyword with nesting on", () => {
it("joins the display name of every level", () => {
expect(formatKeyword("work/clients/acme", KEYWORDS, true)).toBe("Work/Clients/Acme");
});
it("returns the plain display name for a tag with one level", () => {
expect(formatKeyword("work", KEYWORDS, true)).toBe("Work");
});
it("falls back to the raw level for one this client does not know", () => {
expect(formatKeyword("work/archive/2026", KEYWORDS, true)).toBe("Work/archive/2026");
expect(formatKeyword("unknown", [], true)).toBe("unknown");
});
});
describe("formatKeyword with nesting off", () => {
it("names a tag by its own label, leaving a slash in the id uninterpreted", () => {
// The setting says a slash means nothing, so an id that happens to contain
// one - from before it was turned off, or from another client - is a single
// opaque token rather than a hierarchy.
expect(formatKeyword("work/clients/acme", KEYWORDS, false)).toBe("Acme");
expect(formatKeyword("work", KEYWORDS, false)).toBe("Work");
});
it("falls back to the whole id when the tag has no definition", () => {
expect(formatKeyword("work/archive/2026", KEYWORDS, false)).toBe("work/archive/2026");
});
it("offers no shortening, leaving the markup to clip", () => {
expect(keywordRenderings(formatKeywordLabels("work/clients/acme", KEYWORDS, false)))
.toEqual(["Acme"]);
});
});
describe("keywordRenderings", () => {
it("shortens by one intermediate level at a time, outermost first", () => {
expect(keywordRenderings(["Work", "Clients", "Acme", "EU", "Sales"])).toEqual([
"Work/Clients/Acme/EU/Sales",
"Work/../Acme/EU/Sales",
"Work/.../EU/Sales",
"Work/.../Sales",
]);
});
it("collapses to a single ... as soon as the run covers more than one level", () => {
expect(keywordRenderings(["Work", "Clients", "Acme", "Sales"])).toEqual([
"Work/Clients/Acme/Sales",
"Work/../Acme/Sales",
"Work/.../Sales",
]);
});
it("uses .. for a lone intermediate level, never ...", () => {
expect(keywordRenderings(["Work", "Clients", "Acme"])).toEqual([
"Work/Clients/Acme",
"Work/../Acme",
]);
});
it("has nothing to shorten without an intermediate level", () => {
expect(keywordRenderings(["Work", "Acme"])).toEqual(["Work/Acme"]);
expect(keywordRenderings(["Work"])).toEqual(["Work"]);
});
it("drops a rendering that would not come out shorter", () => {
// "../" costs as much as the level it replaces, so shortening buys nothing.
expect(keywordRenderings(["a", "it", "b"])).toEqual(["a/it/b"]);
expect(keywordRenderings(["a", "x", "b"])).toEqual(["a/x/b"]);
});
});
// How the components use the two together: resolve a tag to its display names,
// then hand the ladder to `useShortenedText` to pick a rung.
describe("keywordRenderings over formatKeywordLabels", () => {
it("shortens a display name by the same ladder as an id", () => {
const deep: KeywordDefinition[] = [
kw("work", "Work"),
kw("work/clients", "Clients"),
kw("work/clients/acme", "Acme"),
kw("work/clients/acme/eu", "Europe"),
];
expect(keywordRenderings(formatKeywordLabels("work/clients/acme/eu", deep, true))).toEqual([
"Work/Clients/Acme/Europe",
"Work/../Acme/Europe",
"Work/.../Europe",
]);
});
it("treats a slash inside one display name as part of that name, not a level", () => {
const slashed: KeywordDefinition[] = [kw("work", "Work"), kw("work/acme-r-d", "Acme/R&D")];
// Two levels, so there is no intermediate level to shorten.
expect(keywordRenderings(formatKeywordLabels("work/acme-r-d", slashed, true))).toEqual([
"Work/Acme/R&D",
]);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect } from "vitest";
import {
MAX_KEYWORD_ID_LENGTH,
buildKeywordTree,
composeKeywordId,
getParentKeywordId,
hasChildKeywords,
isKeywordDescendant,
keywordLevels,
normalizeKeywordLevel,
} from "@/lib/keyword-nesting";
import type { KeywordDefinition } from "@/stores/settings-store";
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
// Work
// Clients
// Acme
// Personal
const KEYWORDS: KeywordDefinition[] = [
kw("work", "Work"),
kw("work/clients", "Clients"),
kw("work/clients/acme", "Acme"),
kw("work/personal", "Personal"),
];
describe("normalizeKeywordLevel", () => {
it("lowercases and folds unsupported characters into single dashes", () => {
expect(normalizeKeywordLevel("My Custom Tag!")).toBe("my-custom-tag");
expect(normalizeKeywordLevel(" Spaced Out ")).toBe("spaced-out");
expect(normalizeKeywordLevel("--Trimmed--")).toBe("trimmed");
});
it("treats a slash as part of the name, not as a level", () => {
expect(normalizeKeywordLevel("Acme/R&D")).toBe("acme-r-d");
});
it("returns an empty string when nothing usable is left", () => {
expect(normalizeKeywordLevel(" ")).toBe("");
expect(normalizeKeywordLevel("!!!")).toBe("");
});
});
describe("composeKeywordId", () => {
it("returns a bare slug at the top level", () => {
expect(composeKeywordId(null, "Work")).toBe("work");
expect(composeKeywordId("", "Work")).toBe("work");
});
it("appends the slug below the parent", () => {
expect(composeKeywordId("work/clients", "Acme")).toBe("work/clients/acme");
});
it("never produces a trailing separator for an unusable name", () => {
expect(composeKeywordId("work", "!!!")).toBe("");
});
});
describe("keywordLevels", () => {
it("splits an id into its levels", () => {
expect(keywordLevels("work/clients/acme")).toEqual(["work", "clients", "acme"]);
expect(keywordLevels("work")).toEqual(["work"]);
});
});
describe("getParentKeywordId", () => {
it("drops the last level", () => {
expect(getParentKeywordId("work/clients/acme")).toBe("work/clients");
});
it("returns null for a top-level tag", () => {
expect(getParentKeywordId("work")).toBeNull();
});
});
describe("isKeywordDescendant", () => {
it("matches anything below the ancestor", () => {
expect(isKeywordDescendant("work/clients/acme", "work")).toBe(true);
expect(isKeywordDescendant("work/clients", "work")).toBe(true);
});
it("does not match the ancestor itself or a shared name prefix", () => {
expect(isKeywordDescendant("work", "work")).toBe(false);
expect(isKeywordDescendant("workshop/tools", "work")).toBe(false);
});
});
describe("hasChildKeywords", () => {
it("reports whether any defined tag sits below the given one", () => {
expect(hasChildKeywords("work", KEYWORDS)).toBe(true);
expect(hasChildKeywords("work/clients", KEYWORDS)).toBe(true);
expect(hasChildKeywords("work/clients/acme", KEYWORDS)).toBe(false);
});
});
describe("MAX_KEYWORD_ID_LENGTH", () => {
it("leaves room for the `$label:` prefix within the 255-character keyword limit", () => {
expect(MAX_KEYWORD_ID_LENGTH).toBe(248);
expect("$label:".length + MAX_KEYWORD_ID_LENGTH).toBe(255);
});
});
describe("buildKeywordTree", () => {
it("nests each tag under its parent and records the depth", () => {
const [work] = buildKeywordTree(KEYWORDS);
expect(work.id).toBe("work");
expect(work.depth).toBe(0);
expect(work.children.map((c) => c.id)).toEqual(["work/clients", "work/personal"]);
const clients = work.children[0];
expect(clients.depth).toBe(1);
expect(clients.children.map((c) => c.id)).toEqual(["work/clients/acme"]);
expect(clients.children[0].depth).toBe(2);
});
it("keeps the manual order within a level", () => {
const reordered = [KEYWORDS[0], KEYWORDS[3], KEYWORDS[1], KEYWORDS[2]];
const [work] = buildKeywordTree(reordered);
expect(work.children.map((c) => c.id)).toEqual(["work/personal", "work/clients"]);
});
it("keeps a tag whose parent is not defined at the root", () => {
const orphan = buildKeywordTree([kw("work/clients/acme", "Acme")]);
expect(orphan).toHaveLength(1);
expect(orphan[0].id).toBe("work/clients/acme");
expect(orphan[0].depth).toBe(0);
});
it("returns every tag as a root when no id describes a hierarchy", () => {
const flat = buildKeywordTree([kw("red", "Red"), kw("blue", "Blue")]);
expect(flat.map((n) => n.id)).toEqual(["red", "blue"]);
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* Naming a tag on screen.
*
* A nested tag is written out level by level - `Work/Clients/Acme` - and a flat
* one is simply its own name, so nothing here asks the caller which kind it
* has. `keywordRenderings` additionally offers progressively shorter forms for
* a name with nowhere to fit, which `useShortenedText` measures against the
* room actually available.
*/
import type { KeywordDefinition } from "@/stores/settings-store";
import { KEYWORD_SEPARATOR, keywordLevels } from "./keyword-nesting";
/** Stands in for one level left out of a name. */
export const KEYWORD_SHORTENED_LEVEL = "..";
/** Stands in for a run of more than one level left out of a name. */
export const KEYWORD_SHORTENED_RUN = "...";
/**
* The display name of a tag, one entry per level, outermost first. A tag with
* one level yields a single entry, so callers need not care either way.
*
* `nested` is the user's setting. With nesting off a slash carries no meaning,
* so the id is one opaque token and the tag is named by its own label - nobody
* who left the setting alone should find their tags rewritten because an id
* happens to contain a slash, which can outlast turning nesting off, or arrive
* through settings sync or another client.
*
* With nesting on, each level resolves to that tag's display name, falling back
* to the raw level of the id when it has no definition - the settings list only
* describes the tags this client knows about. Levels stay separate entries
* because a display name may itself contain a slash, which is part of that one
* name rather than a level of its own.
*/
export function formatKeywordLabels(
id: string,
keywords: KeywordDefinition[],
nested: boolean,
): string[] {
const label = (levelId: string) => keywords.find((keyword) => keyword.id === levelId)?.label;
if (!nested) return [label(id) ?? id];
const levels = keywordLevels(id);
return levels.map((level, index) =>
label(levels.slice(0, index + 1).join(KEYWORD_SEPARATOR)) ?? level,
);
}
/**
* The display name of a tag: `Work/Clients/Acme` for a nested one, its own name
* otherwise. The general way to name a tag on screen.
*/
export function formatKeyword(
id: string,
keywords: KeywordDefinition[],
nested: boolean,
): string {
return formatKeywordLabels(id, keywords, nested).join(KEYWORD_SEPARATOR);
}
/**
* Every way a name can be written, longest first: in full, then with an ever
* longer run of intermediate levels replaced by `..`, collapsing to a single
* `...` as soon as that run covers more than one level.
*
* The outermost and innermost levels always survive - between them they say
* which branch a tag belongs to and which tag it is, which is exactly what a
* trailing ellipsis destroys. A rendering that would not actually come out
* shorter than the one before it (levels named `it`, say) is dropped, so
* walking the list never makes the text grow.
*/
export function keywordRenderings(levels: string[]): string[] {
const renderings = [levels.join(KEYWORD_SEPARATOR)];
for (let shortened = 1; shortened <= levels.length - 2; shortened++) {
const marker = shortened === 1 ? KEYWORD_SHORTENED_LEVEL : KEYWORD_SHORTENED_RUN;
const rendering = [levels[0], marker, ...levels.slice(shortened + 1)]
.join(KEYWORD_SEPARATOR);
if (rendering.length < renderings[renderings.length - 1].length) {
renderings.push(rendering);
}
}
return renderings;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Tag nesting.
*
* A tag is stored on the server as the JMAP keyword `$label:<id>`, where `id`
* is a slug derived from the display name. Nesting reuses that single id: the
* levels are joined with a forward slash, so `$label:work/clients` is the child
* of `$label:work`. Keeping the hierarchy inside the id means the server stays
* the source of truth for tag membership and existing lookups by keyword keep
* working.
*
* RFC 8621 section 4.1.1 allows a keyword of 1-255 characters from the ASCII
* range %x21-%x7e minus `( ) { ] % * " \`, so the separator is legal but the
* length of a deep id is not free - `MAX_KEYWORD_ID_LENGTH` is the budget a
* composed id has to stay within.
*
* Turning any of this into text for the screen lives in `keyword-format`.
*/
import type { KeywordDefinition } from "@/stores/settings-store";
import { KEYWORD_PREFIX } from "./thread-utils";
/** Separates parent from child inside a tag id. */
export const KEYWORD_SEPARATOR = "/";
/** Longest keyword a JMAP server has to accept (RFC 8621, section 4.1.1). */
export const MAX_KEYWORD_LENGTH = 255;
/** What is left for the id once the `$label:` prefix is spent. */
export const MAX_KEYWORD_ID_LENGTH = MAX_KEYWORD_LENGTH - KEYWORD_PREFIX.length;
/** A tag definition placed in the hierarchy its id describes. */
export interface KeywordNode extends KeywordDefinition {
children: KeywordNode[];
depth: number;
}
/**
* Reduces a display name to one level of an id: lowercase, and everything
* outside `[a-z0-9_-]` folded to a single dash. The separator is not exempt -
* a slash typed into the name is a literal part of that name, not a level.
* The only slug function for tag ids; keep it the only one.
*/
export function normalizeKeywordLevel(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/** Builds the id a tag named `name` gets under `parentId` (null = top level). */
export function composeKeywordId(parentId: string | null, name: string): string {
const level = normalizeKeywordLevel(name);
if (!parentId || !level) return level;
return `${parentId}${KEYWORD_SEPARATOR}${level}`;
}
/** Splits `work/clients/acme` into `["work", "clients", "acme"]`. */
export function keywordLevels(id: string): string[] {
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
}
/** The id of the tag one level up, or null for a top-level tag. */
export function getParentKeywordId(id: string): string | null {
const index = id.lastIndexOf(KEYWORD_SEPARATOR);
return index === -1 ? null : id.slice(0, index);
}
/** True when `candidateId` sits anywhere below `ancestorId`. */
export function isKeywordDescendant(candidateId: string, ancestorId: string): boolean {
return candidateId.startsWith(`${ancestorId}${KEYWORD_SEPARATOR}`);
}
/** True when any defined tag sits below `id`. */
export function hasChildKeywords(id: string, keywords: KeywordDefinition[]): boolean {
return keywords.some((keyword) => isKeywordDescendant(keyword.id, id));
}
/**
* Arranges tag definitions into the tree their ids describe, preserving the
* user's manual order within each level.
*
* A tag whose direct parent is not defined stays at the root rather than being
* hidden or grafted onto a grandparent; callers name such a root in full so the
* missing level is still visible.
*/
export function buildKeywordTree(keywords: KeywordDefinition[]): KeywordNode[] {
const nodes = new Map<string, KeywordNode>();
for (const keyword of keywords) {
nodes.set(keyword.id, { ...keyword, children: [], depth: 0 });
}
const roots: KeywordNode[] = [];
for (const keyword of keywords) {
const node = nodes.get(keyword.id)!;
const parentId = getParentKeywordId(keyword.id);
const parent = parentId ? nodes.get(parentId) : undefined;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
const setDepth = (node: KeywordNode, depth: number) => {
node.depth = depth;
node.children.forEach((child) => setDepth(child, depth + 1));
};
roots.forEach((root) => setDepth(root, 0));
return roots;
}
+10 -1
View File
@@ -1032,7 +1032,16 @@
"add": "Add",
"cancel": "Cancel",
"migrating": "Updating tag on existing emails…",
"migration_error": "Failed to update tag on existing emails"
"migration_error": "Failed to update tag on existing emails",
"nesting": {
"label": "Nested Tags",
"description": "Nest tags underneath other tags and show them as a tree in the sidebar."
},
"parent_field": "Parent Tag",
"no_parent": "No parent",
"too_long": "This tag path is too long (at most {max} characters)",
"has_children_locked": "Other tags are nested under this one, so its name and parent are locked. Move or remove them first.",
"has_children_delete": "Remove the tags nested under this one first"
},
"notifications": {
"test_sound": "Test notification sound",
+10 -1
View File
@@ -1029,7 +1029,16 @@
"add": "Toevoegen",
"cancel": "Annuleren",
"migrating": "Label bijwerken op bestaande e-mails…",
"migration_error": "Label bijwerken op bestaande e-mails mislukt"
"migration_error": "Label bijwerken op bestaande e-mails mislukt",
"nesting": {
"label": "Geneste labels",
"description": "Nest labels onder andere labels en toon ze als een boomstructuur in de zijbalk."
},
"parent_field": "Bovenliggend label",
"no_parent": "Geen bovenliggend label",
"too_long": "Dit labelpad is te lang (maximaal {max} tekens)",
"has_children_locked": "Er vallen andere labels onder dit label, dus de naam en het bovenliggende label liggen vast. Verplaats of verwijder ze eerst.",
"has_children_delete": "Verwijder eerst de labels die hieronder vallen"
},
"notifications": {
"test_sound": "Meldingsgeluid testen",
@@ -156,4 +156,19 @@ describe('settings-store keywords', () => {
expect(kw?.label).toBe('Scarlet');
});
});
describe('nestedTags', () => {
it('is off by default', () => {
useSettingsStore.getState().resetToDefaults();
expect(useSettingsStore.getState().nestedTags).toBe(false);
});
it('is included in exported settings', () => {
useSettingsStore.getState().updateSetting('nestedTags', true);
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
nestedTags?: boolean;
};
expect(exported.nestedTags).toBe(true);
});
});
});
+3
View File
@@ -287,6 +287,7 @@ interface SettingsState {
// Keywords (labels/tags)
emailKeywords: KeywordDefinition[];
nestedTags: boolean; // Treat "/" in a tag id as a parent/child separator
// Attachment Reminder
attachmentReminderEnabled: boolean;
@@ -485,6 +486,7 @@ const DEFAULT_SETTINGS = {
// Keywords
emailKeywords: DEFAULT_KEYWORDS,
nestedTags: false,
// Attachment Reminder
attachmentReminderEnabled: true,
@@ -661,6 +663,7 @@ export const useSettingsStore = create<SettingsState>()(
showFolderTotalCount: state.showFolderTotalCount,
folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords,
nestedTags: state.nestedTags,
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
hideInlineImageAttachments: state.hideInlineImageAttachments,