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:
@@ -17,6 +17,7 @@
|
|||||||
- Multi-select for batch archive, delete, move, and tag
|
- Multi-select for batch archive, delete, move, and tag
|
||||||
- Archive directly, by year, or by month
|
- 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 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
|
- Star or unstar, with a configurable mark-as-read delay
|
||||||
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
||||||
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
|
import { TagOptionLabel } from "./tag-option-label";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
@@ -155,6 +157,7 @@ export function EmailContextMenu({
|
|||||||
const _tColor = useTranslations("email_viewer.color_tag");
|
const _tColor = useTranslations("email_viewer.color_tag");
|
||||||
const tEmailViewer = useTranslations("email_viewer");
|
const tEmailViewer = useTranslations("email_viewer");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const { tagNameCandidates } = useKeywordFormat();
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isPinned = email.keywords?.['$pinned'] === true;
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
@@ -170,7 +173,7 @@ export function EmailContextMenu({
|
|||||||
|
|
||||||
// Build color options from keyword definitions in settings
|
// Build color options from keyword definitions in settings
|
||||||
const colorOptions = emailKeywords.map((kw) => ({
|
const colorOptions = emailKeywords.map((kw) => ({
|
||||||
name: kw.label,
|
candidates: tagNameCandidates(kw.id),
|
||||||
value: kw.id,
|
value: kw.id,
|
||||||
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
|
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
|
||||||
}));
|
}));
|
||||||
@@ -381,26 +384,28 @@ export function EmailContextMenu({
|
|||||||
{/* Set tag submenu - only for single email */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||||
{colorOptions.map((option) => {
|
<div className="max-w-[18rem]">
|
||||||
const isActive = currentColors.includes(option.value);
|
{colorOptions.map((option) => {
|
||||||
return (
|
const isActive = currentColors.includes(option.value);
|
||||||
<button
|
return (
|
||||||
key={option.value}
|
<button
|
||||||
role="menuitem"
|
key={option.value}
|
||||||
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
role="menuitem"
|
||||||
className={cn(
|
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
|
||||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
|
className={cn(
|
||||||
isActive && "bg-accent font-medium"
|
"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>
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
{isActive && (
|
<TagOptionLabel candidates={option.candidates} />
|
||||||
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
{isActive && (
|
||||||
)}
|
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
|
||||||
</button>
|
)}
|
||||||
);
|
</button>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
{currentColors.length > 0 && (
|
{currentColors.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useUIStore } from "@/stores/ui-store";
|
|||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
import { EmailHoverActions } from "./email-hover-actions";
|
import { EmailHoverActions } from "./email-hover-actions";
|
||||||
import { getEmailColorTags } from "@/lib/thread-utils";
|
import { getEmailColorTags } from "@/lib/thread-utils";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
|
|
||||||
interface EmailListItemProps {
|
interface EmailListItemProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -40,6 +41,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const { tagName } = useKeywordFormat();
|
||||||
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const { identities } = useAuthStore();
|
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" />}
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{keywordDefs.map((kd) => (
|
{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(
|
<span className={cn(
|
||||||
'text-xs tabular-nums',
|
'text-xs tabular-nums',
|
||||||
@@ -290,7 +296,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
<span key={kd.id} className={cn(
|
<span key={kd.id} className={cn(
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
"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"
|
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")} />
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||||
{kd.label}
|
{kd.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { withBasePath } from "@/lib/browser-navigation";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
|
import { TagOptionLabel } from "./tag-option-label";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
import { generateEmailSource } from "@/lib/email-source";
|
import { generateEmailSource } from "@/lib/email-source";
|
||||||
@@ -667,6 +669,7 @@ export function EmailViewer({
|
|||||||
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
|
||||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const { tagName, tagNameCandidates } = useKeywordFormat();
|
||||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -711,7 +714,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Color options for email tags (from user-defined keyword settings)
|
// Color options for email tags (from user-defined keyword settings)
|
||||||
const colorOptions = emailKeywords.map((kw) => ({
|
const colorOptions = emailKeywords.map((kw) => ({
|
||||||
name: kw.label,
|
candidates: tagNameCandidates(kw.id),
|
||||||
value: kw.id,
|
value: kw.id,
|
||||||
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
|
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
|
||||||
}));
|
}));
|
||||||
@@ -3012,9 +3015,10 @@ export function EmailViewer({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{showToolbarLabels && currentColors.length === 1 && (
|
{showToolbarLabels && currentColors.length === 1 && (
|
||||||
<span className="text-xs font-medium text-foreground">
|
<TagOptionLabel
|
||||||
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
candidates={tagNameCandidates(currentColors[0])}
|
||||||
</span>
|
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={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" />}
|
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -3273,7 +3277,7 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
<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" />}
|
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
|
||||||
</button>
|
</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={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" />}
|
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -3634,7 +3638,11 @@ export function EmailViewer({
|
|||||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||||
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500';
|
||||||
return (
|
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>
|
</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useUIStore } from "@/stores/ui-store";
|
|||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { useLongPress } from "@/hooks/use-long-press";
|
import { useLongPress } from "@/hooks/use-long-press";
|
||||||
import { ThreadEmailItem } from "./thread-email-item";
|
import { ThreadEmailItem } from "./thread-email-item";
|
||||||
@@ -90,7 +91,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const 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 density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
@@ -282,7 +284,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{resolvedKeywordDefs.map((kd) => (
|
{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!} />}
|
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
@@ -351,7 +357,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
<span key={kd.id} className={cn(
|
<span key={kd.id} className={cn(
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
"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"
|
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")} />
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
|
||||||
{kd.label}
|
{kd.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -499,7 +505,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
const threadColor = getThreadColorTag(thread.emails);
|
const threadColor = getThreadColorTag(thread.emails);
|
||||||
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
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 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 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" />}
|
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{keywordDef && (
|
{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!} />}
|
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
|
||||||
{scheduledSendLabel ? (
|
{scheduledSendLabel ? (
|
||||||
@@ -827,7 +837,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
<span className={cn(
|
<span className={cn(
|
||||||
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
|
"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"
|
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")} />
|
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
|
||||||
{keywordDef.label}
|
{keywordDef.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
import type { Mailbox } from "@/lib/jmap/types";
|
import type { Mailbox } from "@/lib/jmap/types";
|
||||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
|
|
||||||
interface FilterRuleModalProps {
|
interface FilterRuleModalProps {
|
||||||
rule?: FilterRule;
|
rule?: FilterRule;
|
||||||
@@ -89,6 +90,7 @@ export function FilterRuleModal({
|
|||||||
const t = useTranslations("settings.filters");
|
const t = useTranslations("settings.filters");
|
||||||
const isEdit = !!rule;
|
const isEdit = !!rule;
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const { tagName } = useKeywordFormat();
|
||||||
|
|
||||||
const [name, setName] = useState(rule?.name || "");
|
const [name, setName] = useState(rule?.name || "");
|
||||||
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all");
|
||||||
@@ -477,7 +479,7 @@ export function FilterRuleModal({
|
|||||||
>
|
>
|
||||||
<option value="">{t("label_placeholder")}</option>
|
<option value="">{t("label_placeholder")}</option>
|
||||||
{emailKeywords.map((kw) => (
|
{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>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+116
-35
@@ -38,6 +38,9 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
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 { isEditableEventTarget } from "@/lib/keyboard";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
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 { useUIStore } from "@/stores/ui-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useVacationStore } from "@/stores/vacation-store";
|
import { useVacationStore } from "@/stores/vacation-store";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
@@ -241,6 +244,9 @@ function SidebarRowCounts({
|
|||||||
interface SidebarRowProps {
|
interface SidebarRowProps {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
label: string;
|
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;
|
depth?: number;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isVirtual?: boolean;
|
isVirtual?: boolean;
|
||||||
@@ -266,6 +272,7 @@ interface SidebarRowProps {
|
|||||||
function SidebarRow({
|
function SidebarRow({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
labelCandidates,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
isVirtual = false,
|
isVirtual = false,
|
||||||
@@ -288,6 +295,7 @@ function SidebarRow({
|
|||||||
}: SidebarRowProps) {
|
}: SidebarRowProps) {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||||
|
const [labelRef, shortenedLabel] = useShortenedText(labelCandidates ?? [label]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -353,7 +361,7 @@ function SidebarRow({
|
|||||||
</span>
|
</span>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 truncate">{label}</span>
|
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
|
||||||
<SidebarRowCounts
|
<SidebarRowCounts
|
||||||
unread={unread}
|
unread={unread}
|
||||||
total={total}
|
total={total}
|
||||||
@@ -559,42 +567,54 @@ const TAG_ICON_COLOR: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function TagItem({
|
function TagItem({
|
||||||
kw,
|
node,
|
||||||
isSelected,
|
selectedKeyword,
|
||||||
|
expandedTags,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onTagSelect,
|
onTagSelect,
|
||||||
totalCount,
|
onToggleExpand,
|
||||||
unreadCount,
|
tagCounts,
|
||||||
colorful,
|
colorful,
|
||||||
}: {
|
}: {
|
||||||
kw: KeywordDefinition;
|
node: KeywordNode;
|
||||||
isSelected: boolean;
|
selectedKeyword: string | null;
|
||||||
|
expandedTags: Set<string>;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onTagSelect?: (keywordId: string | null) => void;
|
onTagSelect?: (keywordId: string | null) => void;
|
||||||
totalCount: number;
|
onToggleExpand: (keywordId: string) => void;
|
||||||
unreadCount: number;
|
tagCounts: Record<string, { total: number; unread: number }>;
|
||||||
colorful: boolean;
|
colorful: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations('notifications');
|
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 { isDragging: globalDragging } = useDragDropContext();
|
||||||
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
const { dropHandlers, isValidDropTarget } = useTagDrop({
|
||||||
tagId: kw.id,
|
tagId: node.id,
|
||||||
onSuccess: (count, _tagLabel) => {
|
onSuccess: (count) => {
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
toast.success(t('email_tagged'), kw.label);
|
toast.success(t('email_tagged'), label);
|
||||||
} else {
|
} else {
|
||||||
toast.success(t('emails_tagged', { count }), kw.label);
|
toast.success(t('emails_tagged', { count }), label);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t('tag_failed'), kw.label);
|
toast.error(t('tag_failed'), label);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const tagIcon = colorful ? (
|
const tagIcon = colorful ? (
|
||||||
<Tag
|
<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"
|
fill="currentColor"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -602,18 +622,38 @@ function TagItem({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarRow
|
<>
|
||||||
icon={tagIcon}
|
<SidebarRow
|
||||||
label={kw.label}
|
icon={tagIcon}
|
||||||
depth={0}
|
label={label}
|
||||||
isSelected={isSelected}
|
labelCandidates={labelCandidates}
|
||||||
unread={unreadCount}
|
depth={node.depth}
|
||||||
total={totalCount}
|
isSelected={isSelected}
|
||||||
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
|
unread={tagCounts[node.id]?.unread ?? 0}
|
||||||
isCollapsed={isCollapsed}
|
total={tagCounts[node.id]?.total ?? 0}
|
||||||
dropHandlers={globalDragging ? (dropHandlers as Record<string, unknown>) : undefined}
|
onClick={() => onTagSelect?.(isSelected ? null : node.id)}
|
||||||
isValidDropTarget={isValidDropTarget}
|
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 { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||||
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||||
@@ -779,6 +820,7 @@ export function Sidebar({
|
|||||||
return new Set();
|
return new Set();
|
||||||
});
|
});
|
||||||
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
const emailKeywords = useSettingsStore(s => s.emailKeywords);
|
||||||
|
const nestedTags = useSettingsStore(s => s.nestedTags);
|
||||||
const isEmbedded = useIsEmbedded();
|
const isEmbedded = useIsEmbedded();
|
||||||
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
|
// 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
|
// 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
|
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === '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 ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
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
|
// Multi-account mode (Pro shell): render every connected account as its
|
||||||
// own collapsible group. The active account's tree comes from the
|
// own collapsible group. The active account's tree comes from the
|
||||||
// `mailboxes` prop (which is the live email-store value); other accounts
|
// `mailboxes` prop (which is the live email-store value); other accounts
|
||||||
@@ -1265,15 +1345,16 @@ export function Sidebar({
|
|||||||
/>
|
/>
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<>
|
<>
|
||||||
{emailKeywords.map((kw) => (
|
{tagTree.map((node) => (
|
||||||
<TagItem
|
<TagItem
|
||||||
key={kw.id}
|
key={node.id}
|
||||||
kw={kw}
|
node={node}
|
||||||
isSelected={selectedKeyword === kw.id}
|
selectedKeyword={selectedKeyword}
|
||||||
|
expandedTags={expandedTags}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onTagSelect={onTagSelect}
|
onTagSelect={onTagSelect}
|
||||||
totalCount={tagCounts[kw.id]?.total ?? 0}
|
onToggleExpand={handleToggleTagExpand}
|
||||||
unreadCount={tagCounts[kw.id]?.unread ?? 0}
|
tagCounts={tagCounts}
|
||||||
colorful={colorfulSidebarIcons}
|
colorful={colorfulSidebarIcons}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import { KeywordSettings } from '../keyword-settings';
|
import { KeywordSettings } from '../keyword-settings';
|
||||||
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
||||||
|
|
||||||
// Mock SettingsSection to just render children
|
// Mock SettingsSection to just render children, keeping the real controls
|
||||||
vi.mock('../settings-section', () => ({
|
vi.mock('../settings-section', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../settings-section')>()),
|
||||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('KeywordSettings', () => {
|
describe('KeywordSettings', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
|
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders all default keywords', () => {
|
it('renders all default keywords', () => {
|
||||||
@@ -139,4 +140,74 @@ describe('KeywordSettings', () => {
|
|||||||
expect(added.id).toBe('my-custom-tag');
|
expect(added.id).toBe('my-custom-tag');
|
||||||
expect(added.label).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,30 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useEmailStore } from "@/stores/email-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 { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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);
|
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
|
||||||
|
|
||||||
@@ -39,6 +57,8 @@ function KeywordColorPicker({
|
|||||||
|
|
||||||
function KeywordRow({
|
function KeywordRow({
|
||||||
keyword,
|
keyword,
|
||||||
|
keywords,
|
||||||
|
nestedTags,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
@@ -49,6 +69,8 @@ function KeywordRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
}: {
|
}: {
|
||||||
keyword: KeywordDefinition;
|
keyword: KeywordDefinition;
|
||||||
|
keywords: KeywordDefinition[];
|
||||||
|
nestedTags: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onDragStart: () => void;
|
onDragStart: () => void;
|
||||||
@@ -60,6 +82,13 @@ function KeywordRow({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const palette = KEYWORD_PALETTE[keyword.color];
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -76,8 +105,20 @@ function KeywordRow({
|
|||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
|
||||||
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
|
<div className={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
|
||||||
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</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">
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -90,8 +131,9 @@ function KeywordRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
disabled={hasChildren}
|
||||||
title={t("delete")}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -102,37 +144,74 @@ function KeywordRow({
|
|||||||
|
|
||||||
function KeywordEditForm({
|
function KeywordEditForm({
|
||||||
initial,
|
initial,
|
||||||
|
keywords,
|
||||||
existingIds,
|
existingIds,
|
||||||
|
nestedTags,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
initial?: KeywordDefinition;
|
initial?: KeywordDefinition;
|
||||||
|
keywords: KeywordDefinition[];
|
||||||
existingIds: string[];
|
existingIds: string[];
|
||||||
|
nestedTags: boolean;
|
||||||
onSave: (keyword: KeywordDefinition) => void;
|
onSave: (keyword: KeywordDefinition) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const [label, setLabel] = useState(initial?.label || "");
|
const [label, setLabel] = useState(initial?.label || "");
|
||||||
const [color, setColor] = useState(initial?.color || "blue");
|
const [color, setColor] = useState(initial?.color || "blue");
|
||||||
|
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
|
||||||
const isEditing = !!initial;
|
const isEditing = !!initial;
|
||||||
|
|
||||||
const normalizedId = label
|
// Renaming or re-parenting a tag rewrites the keyword on every message below
|
||||||
.trim()
|
// it, and this client only knows about the tags in its own settings - the
|
||||||
.toLowerCase()
|
// server may hold nested keywords created elsewhere. Freeze the identity of a
|
||||||
.replace(/[^a-z0-9_-]/g, "-")
|
// tag that has children and allow the color to change.
|
||||||
.replace(/-+/g, "-")
|
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
|
|
||||||
|
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
|
||||||
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
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 = () => {
|
const handleSave = () => {
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
|
if (isLocked && initial) {
|
||||||
|
onSave({ ...initial, color });
|
||||||
|
return;
|
||||||
|
}
|
||||||
onSave({ id: normalizedId, label: label.trim(), color });
|
onSave({ id: normalizedId, label: label.trim(), color });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
<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>
|
<div>
|
||||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
{t("label_field")}
|
{t("label_field")}
|
||||||
@@ -141,15 +220,29 @@ function KeywordEditForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
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")}
|
placeholder={t("label_placeholder")}
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
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 && (
|
{isDuplicate && (
|
||||||
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
||||||
@@ -182,7 +275,7 @@ function KeywordEditForm({
|
|||||||
|
|
||||||
export function KeywordSettings() {
|
export function KeywordSettings() {
|
||||||
const t = useTranslations("settings.keywords");
|
const t = useTranslations("settings.keywords");
|
||||||
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
|
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
|
||||||
useSettingsStore();
|
useSettingsStore();
|
||||||
const { client } = useAuthStore();
|
const { client } = useAuthStore();
|
||||||
const { fetchTagCounts } = useEmailStore();
|
const { fetchTagCounts } = useEmailStore();
|
||||||
@@ -266,6 +359,13 @@ export function KeywordSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={t("title")} description={t("description")}>
|
<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">
|
<div className="space-y-2">
|
||||||
{isMigrating && (
|
{isMigrating && (
|
||||||
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
|
<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
|
<KeywordEditForm
|
||||||
key={keyword.id}
|
key={keyword.id}
|
||||||
initial={keyword}
|
initial={keyword}
|
||||||
|
keywords={emailKeywords}
|
||||||
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
||||||
|
nestedTags={nestedTags}
|
||||||
onSave={handleEdit}
|
onSave={handleEdit}
|
||||||
onCancel={() => setEditingId(null)}
|
onCancel={() => setEditingId(null)}
|
||||||
/>
|
/>
|
||||||
@@ -286,6 +388,8 @@ export function KeywordSettings() {
|
|||||||
<KeywordRow
|
<KeywordRow
|
||||||
key={keyword.id}
|
key={keyword.id}
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
|
keywords={emailKeywords}
|
||||||
|
nestedTags={nestedTags}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingId(keyword.id);
|
setEditingId(keyword.id);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
@@ -303,7 +407,9 @@ export function KeywordSettings() {
|
|||||||
|
|
||||||
{isAdding ? (
|
{isAdding ? (
|
||||||
<KeywordEditForm
|
<KeywordEditForm
|
||||||
|
keywords={emailKeywords}
|
||||||
existingIds={existingIds}
|
existingIds={existingIds}
|
||||||
|
nestedTags={nestedTags}
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setIsAdding(false)}
|
onCancel={() => setIsAdding(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -111,15 +111,24 @@ interface SelectProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: { value: string; label: string }[];
|
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 (
|
return (
|
||||||
<select
|
<select
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
dir="auto"
|
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) => (
|
{options.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -1032,7 +1032,16 @@
|
|||||||
"add": "Add",
|
"add": "Add",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"migrating": "Updating tag on existing emails…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Test notification sound",
|
"test_sound": "Test notification sound",
|
||||||
|
|||||||
+10
-1
@@ -1029,7 +1029,16 @@
|
|||||||
"add": "Toevoegen",
|
"add": "Toevoegen",
|
||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
"migrating": "Label bijwerken op bestaande e-mails…",
|
"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": {
|
"notifications": {
|
||||||
"test_sound": "Meldingsgeluid testen",
|
"test_sound": "Meldingsgeluid testen",
|
||||||
|
|||||||
@@ -156,4 +156,19 @@ describe('settings-store keywords', () => {
|
|||||||
expect(kw?.label).toBe('Scarlet');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ interface SettingsState {
|
|||||||
|
|
||||||
// Keywords (labels/tags)
|
// Keywords (labels/tags)
|
||||||
emailKeywords: KeywordDefinition[];
|
emailKeywords: KeywordDefinition[];
|
||||||
|
nestedTags: boolean; // Treat "/" in a tag id as a parent/child separator
|
||||||
|
|
||||||
// Attachment Reminder
|
// Attachment Reminder
|
||||||
attachmentReminderEnabled: boolean;
|
attachmentReminderEnabled: boolean;
|
||||||
@@ -485,6 +486,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
|
|
||||||
// Keywords
|
// Keywords
|
||||||
emailKeywords: DEFAULT_KEYWORDS,
|
emailKeywords: DEFAULT_KEYWORDS,
|
||||||
|
nestedTags: false,
|
||||||
|
|
||||||
// Attachment Reminder
|
// Attachment Reminder
|
||||||
attachmentReminderEnabled: true,
|
attachmentReminderEnabled: true,
|
||||||
@@ -661,6 +663,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
showFolderTotalCount: state.showFolderTotalCount,
|
showFolderTotalCount: state.showFolderTotalCount,
|
||||||
folderIcons: state.folderIcons,
|
folderIcons: state.folderIcons,
|
||||||
emailKeywords: state.emailKeywords,
|
emailKeywords: state.emailKeywords,
|
||||||
|
nestedTags: state.nestedTags,
|
||||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||||
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
||||||
|
|||||||
Reference in New Issue
Block a user