feat: allow hidden tags, either permanent or when there are no unread messages
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
- 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
|
- Tags optionally nest: pick a parent when you create one and the sidebar turns them into a tree
|
||||||
|
- Each tag can be configured to show always, only when there are unread mails or always be hidden
|
||||||
- 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
|
||||||
|
|||||||
@@ -35,10 +35,17 @@ import {
|
|||||||
BellOff,
|
BellOff,
|
||||||
Mails,
|
Mails,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
|
MoreHorizontal,
|
||||||
} 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 {
|
||||||
|
buildKeywordTree,
|
||||||
|
countKeywordNodes,
|
||||||
|
filterKeywordTree,
|
||||||
|
hasChildKeywords,
|
||||||
|
type KeywordNode,
|
||||||
|
} from "@/lib/keyword-nesting";
|
||||||
import { useShortenedText } from "@/hooks/use-shortened-text";
|
import { useShortenedText } from "@/hooks/use-shortened-text";
|
||||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||||
@@ -54,7 +61,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 } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE, getKeywordVisibility } 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";
|
||||||
@@ -566,6 +573,30 @@ const TAG_ICON_COLOR: Record<string, string> = {
|
|||||||
gray: "text-gray-500",
|
gray: "text-gray-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ShowAllTagsRow({
|
||||||
|
hiddenCount,
|
||||||
|
showAll,
|
||||||
|
onToggle,
|
||||||
|
isCollapsed,
|
||||||
|
}: {
|
||||||
|
hiddenCount: number;
|
||||||
|
showAll: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('sidebar');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarRow
|
||||||
|
icon={<MoreHorizontal className="w-4 h-4 text-muted-foreground" />}
|
||||||
|
label={showAll ? t('show_fewer_tags') : t('show_all_tags', { count: hiddenCount })}
|
||||||
|
depth={0}
|
||||||
|
onClick={onToggle}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TagItem({
|
function TagItem({
|
||||||
node,
|
node,
|
||||||
selectedKeyword,
|
selectedKeyword,
|
||||||
@@ -778,6 +809,7 @@ export function Sidebar({
|
|||||||
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 [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||||
|
const [showAllTags, setShowAllTags] = useState(false);
|
||||||
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||||
@@ -931,6 +963,19 @@ export function Sidebar({
|
|||||||
? buildKeywordTree(emailKeywords)
|
? buildKeywordTree(emailKeywords)
|
||||||
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
|
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
|
||||||
|
|
||||||
|
// Counts arrive from a separate JMAP round trip; until they land, treat every
|
||||||
|
// "show if unread" tag as visible rather than blanking the section and
|
||||||
|
// filling it back in.
|
||||||
|
const tagCountsLoaded = Object.keys(tagCounts).length > 0;
|
||||||
|
const isTagVisible = (node: KeywordNode) => {
|
||||||
|
if (showAllTags || node.id === selectedKeyword) return true;
|
||||||
|
const visibility = getKeywordVisibility(node);
|
||||||
|
if (visibility === 'hide') return false;
|
||||||
|
if (visibility === 'unread') return !tagCountsLoaded || (tagCounts[node.id]?.unread ?? 0) > 0;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const visibleTagTree = filterKeywordTree(tagTree, isTagVisible);
|
||||||
|
const hiddenTagCount = emailKeywords.length - countKeywordNodes(visibleTagTree);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1345,7 +1390,7 @@ export function Sidebar({
|
|||||||
/>
|
/>
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<>
|
<>
|
||||||
{tagTree.map((node) => (
|
{visibleTagTree.map((node) => (
|
||||||
<TagItem
|
<TagItem
|
||||||
key={node.id}
|
key={node.id}
|
||||||
node={node}
|
node={node}
|
||||||
@@ -1358,6 +1403,14 @@ export function Sidebar({
|
|||||||
colorful={colorfulSidebarIcons}
|
colorful={colorfulSidebarIcons}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{(hiddenTagCount > 0 || showAllTags) && (
|
||||||
|
<ShowAllTagsRow
|
||||||
|
hiddenCount={hiddenTagCount}
|
||||||
|
showAll={showAllTags}
|
||||||
|
onToggle={() => setShowAllTags((prev) => !prev)}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -210,4 +210,20 @@ describe('KeywordSettings', () => {
|
|||||||
expect(screen.getByDisplayValue('Work')).toBeDisabled();
|
expect(screen.getByDisplayValue('Work')).toBeDisabled();
|
||||||
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
|
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults every tag to always visible in the sidebar', () => {
|
||||||
|
render(<KeywordSettings />);
|
||||||
|
|
||||||
|
const pickers = screen.getAllByLabelText('visibility_field');
|
||||||
|
expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length);
|
||||||
|
pickers.forEach((picker) => expect(picker).toHaveValue('show'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the visibility chosen for a tag', () => {
|
||||||
|
render(<KeywordSettings />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } });
|
||||||
|
|
||||||
|
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
KEYWORD_PALETTE,
|
KEYWORD_PALETTE,
|
||||||
DEFAULT_KEYWORDS,
|
DEFAULT_KEYWORDS,
|
||||||
|
getKeywordVisibility,
|
||||||
type KeywordDefinition,
|
type KeywordDefinition,
|
||||||
|
type KeywordVisibility,
|
||||||
} from "@/stores/settings-store";
|
} 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";
|
||||||
@@ -61,6 +63,7 @@ function KeywordRow({
|
|||||||
nestedTags,
|
nestedTags,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onVisibilityChange,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
@@ -73,6 +76,7 @@ function KeywordRow({
|
|||||||
nestedTags: boolean;
|
nestedTags: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onVisibilityChange: (visibility: KeywordVisibility) => void;
|
||||||
onDragStart: () => void;
|
onDragStart: () => void;
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
onDrop: () => void;
|
onDrop: () => void;
|
||||||
@@ -89,6 +93,11 @@ function KeywordRow({
|
|||||||
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
|
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
|
||||||
.map((rendering) => KEYWORD_PREFIX + rendering);
|
.map((rendering) => KEYWORD_PREFIX + rendering);
|
||||||
const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates);
|
const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates);
|
||||||
|
const visibilityOptions = [
|
||||||
|
{ value: "show", label: t("visibility.show") },
|
||||||
|
{ value: "unread", label: t("visibility.unread") },
|
||||||
|
{ value: "hide", label: t("visibility.hide") },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -119,6 +128,13 @@ function KeywordRow({
|
|||||||
>
|
>
|
||||||
{shortenedKeyword}
|
{shortenedKeyword}
|
||||||
</span>
|
</span>
|
||||||
|
<Select
|
||||||
|
value={getKeywordVisibility(keyword)}
|
||||||
|
onChange={(value) => onVisibilityChange(value as KeywordVisibility)}
|
||||||
|
options={visibilityOptions}
|
||||||
|
ariaLabel={t("visibility_field")}
|
||||||
|
className="text-xs py-1"
|
||||||
|
/>
|
||||||
<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"
|
||||||
@@ -353,6 +369,10 @@ export function KeywordSettings() {
|
|||||||
removeKeyword(id);
|
removeKeyword(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
|
||||||
|
updateKeyword(id, { visibility });
|
||||||
|
};
|
||||||
|
|
||||||
const handleResetDefaults = () => {
|
const handleResetDefaults = () => {
|
||||||
reorderKeywords(DEFAULT_KEYWORDS);
|
reorderKeywords(DEFAULT_KEYWORDS);
|
||||||
};
|
};
|
||||||
@@ -395,6 +415,7 @@ export function KeywordSettings() {
|
|||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}}
|
}}
|
||||||
onDelete={() => handleDelete(keyword.id)}
|
onDelete={() => handleDelete(keyword.id)}
|
||||||
|
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
|
||||||
onDragStart={() => handleDragStart(index)}
|
onDragStart={() => handleDragStart(index)}
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
onDrop={() => handleDrop(index)}
|
onDrop={() => handleDrop(index)}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
MAX_KEYWORD_ID_LENGTH,
|
MAX_KEYWORD_ID_LENGTH,
|
||||||
buildKeywordTree,
|
buildKeywordTree,
|
||||||
composeKeywordId,
|
composeKeywordId,
|
||||||
|
countKeywordNodes,
|
||||||
|
filterKeywordTree,
|
||||||
getParentKeywordId,
|
getParentKeywordId,
|
||||||
hasChildKeywords,
|
hasChildKeywords,
|
||||||
isKeywordDescendant,
|
isKeywordDescendant,
|
||||||
@@ -136,3 +138,46 @@ describe("buildKeywordTree", () => {
|
|||||||
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
|
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("filterKeywordTree", () => {
|
||||||
|
const tree = buildKeywordTree(KEYWORDS);
|
||||||
|
|
||||||
|
it("drops the nodes the predicate rejects", () => {
|
||||||
|
const kept = filterKeywordTree(tree, (node) => node.id !== "work/personal");
|
||||||
|
const [work] = kept;
|
||||||
|
|
||||||
|
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a rejected node when a descendant survives, so nothing is stranded", () => {
|
||||||
|
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||||
|
const [work] = kept;
|
||||||
|
|
||||||
|
expect(work.id).toBe("work");
|
||||||
|
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||||
|
expect(work.children[0].children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the depth of a surviving node so its indentation does not shift", () => {
|
||||||
|
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||||
|
|
||||||
|
expect(kept[0].children[0].children[0].depth).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing when the predicate rejects everything", () => {
|
||||||
|
expect(filterKeywordTree(tree, () => false)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the original tree untouched", () => {
|
||||||
|
filterKeywordTree(tree, (node) => node.id === "work");
|
||||||
|
|
||||||
|
expect(countKeywordNodes(tree)).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("countKeywordNodes", () => {
|
||||||
|
it("counts every level, not just the roots", () => {
|
||||||
|
expect(countKeywordNodes(buildKeywordTree(KEYWORDS))).toBe(4);
|
||||||
|
expect(countKeywordNodes([])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -111,3 +111,29 @@ export function buildKeywordTree(keywords: KeywordDefinition[]): KeywordNode[] {
|
|||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prunes a tag tree down to the nodes worth showing.
|
||||||
|
*
|
||||||
|
* A node survives when the predicate accepts it or when any of its descendants
|
||||||
|
* survives, so hiding a parent never strands the children below it. Depths are
|
||||||
|
* left untouched: a kept node keeps the indentation of its original level even
|
||||||
|
* when the level above it is only there to carry it.
|
||||||
|
*/
|
||||||
|
export function filterKeywordTree(
|
||||||
|
nodes: KeywordNode[],
|
||||||
|
isVisible: (node: KeywordNode) => boolean,
|
||||||
|
): KeywordNode[] {
|
||||||
|
const kept: KeywordNode[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
const children = filterKeywordTree(node.children, isVisible);
|
||||||
|
if (children.length > 0 || isVisible(node)) {
|
||||||
|
kept.push({ ...node, children });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total number of nodes in a tag tree, at every level. */
|
||||||
|
export function countKeywordNodes(nodes: KeywordNode[]): number {
|
||||||
|
return nodes.reduce((total, node) => total + 1 + countKeywordNodes(node.children), 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Reset",
|
"demo_reset": "Reset",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"show_all_tags": "Show all ({count})",
|
||||||
|
"show_fewer_tags": "Show less",
|
||||||
"folders": "Folders",
|
"folders": "Folders",
|
||||||
"shared": "Shared",
|
"shared": "Shared",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
@@ -1041,7 +1043,13 @@
|
|||||||
"no_parent": "No parent",
|
"no_parent": "No parent",
|
||||||
"too_long": "This tag path is too long (at most {max} characters)",
|
"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_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"
|
"has_children_delete": "Remove the tags nested under this one first",
|
||||||
|
"visibility_field": "Sidebar visibility",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Show",
|
||||||
|
"unread": "Show if unread",
|
||||||
|
"hide": "Hide"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Test notification sound",
|
"test_sound": "Test notification sound",
|
||||||
|
|||||||
@@ -130,6 +130,8 @@
|
|||||||
"demo_reset": "Resetten",
|
"demo_reset": "Resetten",
|
||||||
"demo_tour": "Rondleiding",
|
"demo_tour": "Rondleiding",
|
||||||
"tags": "Labels",
|
"tags": "Labels",
|
||||||
|
"show_all_tags": "Alles tonen ({count})",
|
||||||
|
"show_fewer_tags": "Minder tonen",
|
||||||
"folders": "Mappen",
|
"folders": "Mappen",
|
||||||
"mail": "E-mail",
|
"mail": "E-mail",
|
||||||
"nav_label": "Navigatie",
|
"nav_label": "Navigatie",
|
||||||
@@ -1038,7 +1040,13 @@
|
|||||||
"no_parent": "Geen bovenliggend label",
|
"no_parent": "Geen bovenliggend label",
|
||||||
"too_long": "Dit labelpad is te lang (maximaal {max} tekens)",
|
"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_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"
|
"has_children_delete": "Verwijder eerst de labels die hieronder vallen",
|
||||||
|
"visibility_field": "Zichtbaarheid in de zijbalk",
|
||||||
|
"visibility": {
|
||||||
|
"show": "Tonen",
|
||||||
|
"unread": "Tonen bij ongelezen",
|
||||||
|
"hide": "Verbergen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"test_sound": "Meldingsgeluid testen",
|
"test_sound": "Meldingsgeluid testen",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE } from '../settings-store';
|
import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, getKeywordVisibility } from '../settings-store';
|
||||||
import type { KeywordDefinition } from '../settings-store';
|
import type { KeywordDefinition } from '../settings-store';
|
||||||
|
|
||||||
describe('settings-store keywords', () => {
|
describe('settings-store keywords', () => {
|
||||||
@@ -157,6 +157,17 @@ describe('settings-store keywords', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getKeywordVisibility', () => {
|
||||||
|
it('treats a tag stored before visibility was configurable as always shown', () => {
|
||||||
|
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red' })).toBe('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the stored choice when there is one', () => {
|
||||||
|
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red', visibility: 'unread' })).toBe('unread');
|
||||||
|
expect(getKeywordVisibility({ id: 'red', label: 'Red', color: 'red', visibility: 'hide' })).toBe('hide');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('nestedTags', () => {
|
describe('nestedTags', () => {
|
||||||
it('is off by default', () => {
|
it('is off by default', () => {
|
||||||
useSettingsStore.getState().resetToDefaults();
|
useSettingsStore.getState().resetToDefaults();
|
||||||
|
|||||||
@@ -101,10 +101,19 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
|
|||||||
{ id: 'contacts', labelKey: 'contacts' },
|
{ id: 'contacts', labelKey: 'contacts' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Whether a tag shows in the sidebar always, only when it has unread mail, or never. */
|
||||||
|
export type KeywordVisibility = 'show' | 'hide' | 'unread';
|
||||||
|
|
||||||
export interface KeywordDefinition {
|
export interface KeywordDefinition {
|
||||||
id: string; // Used as JMAP keyword suffix: $label:<id>
|
id: string; // Used as JMAP keyword suffix: $label:<id>
|
||||||
label: string; // Display name
|
label: string; // Display name
|
||||||
color: string; // Key from KEYWORD_PALETTE
|
color: string; // Key from KEYWORD_PALETTE
|
||||||
|
visibility?: KeywordVisibility; // Absent on tags stored before this was configurable
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the sidebar visibility of a tag, defaulting to always shown. */
|
||||||
|
export function getKeywordVisibility(keyword: KeywordDefinition): KeywordVisibility {
|
||||||
|
return keyword.visibility ?? 'show';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarApp {
|
export interface SidebarApp {
|
||||||
|
|||||||
Reference in New Issue
Block a user