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
|
||||
- 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
|
||||
- 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
|
||||
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
|
||||
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
|
||||
|
||||
@@ -35,10 +35,17 @@ import {
|
||||
BellOff,
|
||||
Mails,
|
||||
MailOpen,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { buildKeywordTree, hasChildKeywords, type KeywordNode } from "@/lib/keyword-nesting";
|
||||
import {
|
||||
buildKeywordTree,
|
||||
countKeywordNodes,
|
||||
filterKeywordTree,
|
||||
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";
|
||||
@@ -54,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
import { useSettingsStore, KEYWORD_PALETTE, getKeywordVisibility } from "@/stores/settings-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -566,6 +573,30 @@ const TAG_ICON_COLOR: Record<string, string> = {
|
||||
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({
|
||||
node,
|
||||
selectedKeyword,
|
||||
@@ -778,6 +809,7 @@ export function Sidebar({
|
||||
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
const [showAllTags, setShowAllTags] = useState(false);
|
||||
const [foldersExpanded, setFoldersExpanded] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sidebarFoldersExpanded');
|
||||
@@ -931,6 +963,19 @@ export function Sidebar({
|
||||
? buildKeywordTree(emailKeywords)
|
||||
: 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
|
||||
// own collapsible group. The active account's tree comes from the
|
||||
@@ -1345,7 +1390,7 @@ export function Sidebar({
|
||||
/>
|
||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<>
|
||||
{tagTree.map((node) => (
|
||||
{visibleTagTree.map((node) => (
|
||||
<TagItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
@@ -1358,6 +1403,14 @@ export function Sidebar({
|
||||
colorful={colorfulSidebarIcons}
|
||||
/>
|
||||
))}
|
||||
{(hiddenTagCount > 0 || showAllTags) && (
|
||||
<ShowAllTagsRow
|
||||
hiddenCount={hiddenTagCount}
|
||||
showAll={showAllTags}
|
||||
onToggle={() => setShowAllTags((prev) => !prev)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -210,4 +210,20 @@ describe('KeywordSettings', () => {
|
||||
expect(screen.getByDisplayValue('Work')).toBeDisabled();
|
||||
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,
|
||||
KEYWORD_PALETTE,
|
||||
DEFAULT_KEYWORDS,
|
||||
getKeywordVisibility,
|
||||
type KeywordDefinition,
|
||||
type KeywordVisibility,
|
||||
} from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -61,6 +63,7 @@ function KeywordRow({
|
||||
nestedTags,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onVisibilityChange,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
@@ -73,6 +76,7 @@ function KeywordRow({
|
||||
nestedTags: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onVisibilityChange: (visibility: KeywordVisibility) => void;
|
||||
onDragStart: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: () => void;
|
||||
@@ -89,6 +93,11 @@ function KeywordRow({
|
||||
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
|
||||
.map((rendering) => KEYWORD_PREFIX + rendering);
|
||||
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 (
|
||||
<div
|
||||
@@ -119,6 +128,13 @@ function KeywordRow({
|
||||
>
|
||||
{shortenedKeyword}
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -353,6 +369,10 @@ export function KeywordSettings() {
|
||||
removeKeyword(id);
|
||||
};
|
||||
|
||||
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
|
||||
updateKeyword(id, { visibility });
|
||||
};
|
||||
|
||||
const handleResetDefaults = () => {
|
||||
reorderKeywords(DEFAULT_KEYWORDS);
|
||||
};
|
||||
@@ -395,6 +415,7 @@ export function KeywordSettings() {
|
||||
setIsAdding(false);
|
||||
}}
|
||||
onDelete={() => handleDelete(keyword.id)}
|
||||
onVisibilityChange={(visibility) => handleVisibilityChange(keyword.id, visibility)}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
MAX_KEYWORD_ID_LENGTH,
|
||||
buildKeywordTree,
|
||||
composeKeywordId,
|
||||
countKeywordNodes,
|
||||
filterKeywordTree,
|
||||
getParentKeywordId,
|
||||
hasChildKeywords,
|
||||
isKeywordDescendant,
|
||||
@@ -136,3 +138,46 @@ describe("buildKeywordTree", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_tour": "Tour",
|
||||
"tags": "Tags",
|
||||
"show_all_tags": "Show all ({count})",
|
||||
"show_fewer_tags": "Show less",
|
||||
"folders": "Folders",
|
||||
"shared": "Shared",
|
||||
"mail": "Mail",
|
||||
@@ -1041,7 +1043,13 @@
|
||||
"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"
|
||||
"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": {
|
||||
"test_sound": "Test notification sound",
|
||||
|
||||
@@ -130,6 +130,8 @@
|
||||
"demo_reset": "Resetten",
|
||||
"demo_tour": "Rondleiding",
|
||||
"tags": "Labels",
|
||||
"show_all_tags": "Alles tonen ({count})",
|
||||
"show_fewer_tags": "Minder tonen",
|
||||
"folders": "Mappen",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navigatie",
|
||||
@@ -1038,7 +1040,13 @@
|
||||
"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"
|
||||
"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": {
|
||||
"test_sound": "Meldingsgeluid testen",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
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', () => {
|
||||
it('is off by default', () => {
|
||||
useSettingsStore.getState().resetToDefaults();
|
||||
|
||||
@@ -101,10 +101,19 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
|
||||
{ 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 {
|
||||
id: string; // Used as JMAP keyword suffix: $label:<id>
|
||||
label: string; // Display name
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user