diff --git a/FEATURES.md b/FEATURES.md index 3f04221b..2ae105f0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 06a9b662..31070e1d 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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 = { gray: "text-gray-500", }; +function ShowAllTagsRow({ + hiddenCount, + showAll, + onToggle, + isCollapsed, +}: { + hiddenCount: number; + showAll: boolean; + onToggle: () => void; + isCollapsed: boolean; +}) { + const t = useTranslations('sidebar'); + + return ( + } + 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>(new Set()); const [expandedTags, setExpandedTags] = useState>(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) => ( ))} + {(hiddenTagCount > 0 || showAllTags) && ( + setShowAllTags((prev) => !prev)} + isCollapsed={isCollapsed} + /> + )} )} diff --git a/components/settings/__tests__/keyword-settings.test.tsx b/components/settings/__tests__/keyword-settings.test.tsx index 8593c41f..eb3119f3 100644 --- a/components/settings/__tests__/keyword-settings.test.tsx +++ b/components/settings/__tests__/keyword-settings.test.tsx @@ -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(); + + 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(); + + fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } }); + + expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread'); + }); }); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index cd6f408f..9689ab08 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -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 (
{shortenedKeyword} +