feat: allow hidden tags, either permanent or when there are no unread messages

This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:42 +02:00
parent ca0ba818b7
commit 0c1e238223
10 changed files with 204 additions and 6 deletions
+1
View File
@@ -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
+56 -3
View File
@@ -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');
});
}); });
+21
View File
@@ -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)}
+45
View File
@@ -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);
});
});
+26
View File
@@ -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);
}
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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();
+9
View File
@@ -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 {