diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 84db85dc..16b4cd2e 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -8,7 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star, Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus, - Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag, + Clock, Layers, Users, Tag, Mails, MoreHorizontal, MailOpen, StickyNote, AlarmClock, Flag, CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw, type AppIcon, } from './icons'; @@ -34,6 +34,8 @@ import { markMailboxesAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox, } from '../api/email'; import { useTagCountsStore } from '../stores/tag-counts-store'; +import { sidebarTagRows } from '../lib/keyword-nesting'; +import { tagIdFromKeyword } from '../lib/thread-utils'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/types'; @@ -59,6 +61,7 @@ const STORAGE_KEYS = { expanded: 'sidebar:expandedMailboxes', foldersExpanded: 'sidebar:foldersExpanded', tagsExpanded: 'sidebar:tagsExpanded', + collapsedTags: 'sidebar:collapsedTags', unifiedExpanded: 'sidebar:unifiedExpanded', }; @@ -360,12 +363,18 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side const keywordDefs = useKeywordsStore((s) => s.keywords); const keywordsHydrated = useKeywordsStore((s) => s.hydrated); const hydrateKeywords = useKeywordsStore((s) => s.hydrate); + const nestedTags = useKeywordsStore((s) => s.nested); const navigation = useNavigation>(); const [foldersExpanded, setFoldersExpanded] = React.useState(true); const [tagsExpanded, setTagsExpanded] = React.useState(true); const [unifiedExpanded, setUnifiedExpanded] = React.useState(false); const [expandedFolders, setExpandedFolders] = React.useState>(() => new Set()); + // Parent tags the user folded; every other parent shows its children, as + // the webmail opens all of them until the user folds one. + const [collapsedTags, setCollapsedTags] = React.useState>(() => new Set()); + // "Show all (N)": the tags the visibility settings hide, for now. + const [showAllTags, setShowAllTags] = React.useState(false); const [accountMenuOpen, setAccountMenuOpen] = React.useState(false); const [sheet, setSheet] = React.useState<{ title: string; actions: SheetAction[] } | null>(null); const [prompt, setPrompt] = React.useState<{ @@ -381,12 +390,19 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side React.useEffect(() => { void (async () => { try { - const [rawExp, rawFld, rawTags, rawUnified] = await Promise.all([ + const [rawExp, rawFld, rawTags, rawUnified, rawCollapsedTags] = await Promise.all([ AsyncStorage.getItem(STORAGE_KEYS.expanded), AsyncStorage.getItem(STORAGE_KEYS.foldersExpanded), AsyncStorage.getItem(STORAGE_KEYS.tagsExpanded), AsyncStorage.getItem(STORAGE_KEYS.unifiedExpanded), + AsyncStorage.getItem(STORAGE_KEYS.collapsedTags), ]); + if (rawCollapsedTags) { + try { + const ids = JSON.parse(rawCollapsedTags) as string[]; + if (Array.isArray(ids)) setCollapsedTags(new Set(ids)); + } catch { /* ignore */ } + } if (rawExp) { try { const ids = JSON.parse(rawExp) as string[]; @@ -459,6 +475,15 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side onClose(); }, [selectMailbox, setFilters, onClose]); + const toggleTagExpand = React.useCallback((id: string) => { + setCollapsedTags((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + void AsyncStorage.setItem(STORAGE_KEYS.collapsedTags, JSON.stringify(Array.from(next))).catch(() => {}); + return next; + }); + }, []); + // Tag view (#175): every folder, messages carrying the tag. const selectTag = React.useCallback((id: string) => { setFilters({ keyword: keywordToken(id) }); @@ -479,6 +504,16 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts, excluded); }, [visible, tagsExpanded, keywordDefs, tagAccountsKey, mailboxes, activeAccountId, tagCountsGeneration, ensureTagCounts]); + // The tag rows: nested under their parents with "Nested tags" on, and + // without the tags whose visibility hides them (webmail sidebar). + const selectedTagId = filters.keyword ? tagIdFromKeyword(filters.keyword) : null; + const tagRows = React.useMemo( + () => sidebarTagRows(keywordDefs, nestedTags, tagCounts, { + showAll: showAllTags, selectedId: selectedTagId, collapsed: collapsedTags, + }), + [keywordDefs, nestedTags, tagCounts, showAllTags, selectedTagId, collapsedTags], + ); + // ── Folder actions (long-press) ─────────────────────────────────────── const refFor = (mb: Mailbox) => ({ id: mb.originalId ?? mb.id, @@ -1221,27 +1256,44 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side )} {t('sidebar.tags', 'Tags')} - {tagsExpanded && keywordDefs.map((kw) => { - const counts = tagCounts[kw.id]; - const isSelected = filters.keyword === keywordToken(kw.id); - const dot = c.tags[kw.color]?.dot ?? c.textMuted; + {tagsExpanded && tagRows.rows.map(({ node, label, hasChildren, expanded }) => { + const counts = tagCounts[node.id]; + const isSelected = filters.keyword === keywordToken(node.id); + const dot = c.tags[node.color]?.dot ?? c.textMuted; return ( } - label={kw.label} - depth={0} + label={label} + depth={node.depth} isSelected={isSelected} unread={counts?.unread ?? 0} total={counts?.total ?? 0} showTotal={showFolderTotalCount} - hasChildren={false} - isExpanded={false} - onPress={() => selectTag(kw.id)} - onToggleExpand={() => {}} + hasChildren={hasChildren} + isExpanded={expanded} + onPress={() => selectTag(node.id)} + onToggleExpand={() => toggleTagExpand(node.id)} /> ); })} + {tagsExpanded && (tagRows.hiddenCount > 0 || showAllTags) && ( + } + label={showAllTags + ? t('sidebar.show_fewer_tags', 'Show less') + : t('sidebar.show_all_tags', 'Show all ({count})', { count: tagRows.hiddenCount })} + depth={0} + isSelected={false} + unread={0} + total={0} + showTotal={false} + hasChildren={false} + isExpanded={false} + onPress={() => setShowAllTags((prev) => !prev)} + onToggleExpand={() => {}} + /> + )} )} diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index 8e6426f3..8afddb13 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -6,7 +6,8 @@ import { X, Check, Tag } from './icons'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useSheetDrag } from '../lib/use-sheet-drag'; -import { keywordToken, type KeywordDef } from '../stores/keywords-store'; +import { keywordToken, useKeywordsStore, type KeywordDef } from '../stores/keywords-store'; +import { buildKeywordTree, flattenKeywordTree, treeRowLabel } from '../lib/keyword-nesting'; import { useLocaleStore } from '../stores/locale-store'; import { getEmailTagIds } from '../lib/thread-utils'; import type { Email } from '../api/types'; @@ -59,6 +60,14 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle [selectedEmails], ); + // With "Nested tags" on, a tree: children indented under their parent and + // named by their own label, as the webmail tag picker draws it. + const nested = useKeywordsStore((s) => s.nested); + const rows = React.useMemo( + () => flattenKeywordTree(buildKeywordTree(keywords, nested)), + [keywords, nested], + ); + // Tags set on the selection that no local definition explains (set by the // webmail or another device): listed so they can at least be removed. const unknownIds = React.useMemo(() => { @@ -103,7 +112,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle {keywords.length === 0 && unknownIds.length === 0 ? ( {t('email_list.no_tags_defined', 'No tags defined. Add tags in Settings.')} ) : ( - keywords.map((kw) => { + rows.map((kw) => { const token = keywordToken(kw.id); const applied = allHaveToken(token); const dot = c.tags[kw.color]?.dot ?? c.primary; @@ -111,10 +120,10 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle onToggle(token, !applied)} - style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + style={({ pressed }) => [styles.row, tagRowIndent(kw.depth), pressed && styles.rowPressed]} > - {kw.label} + {treeRowLabel(kw, keywords, nested)} {applied && } ); @@ -143,6 +152,11 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle ); } +/** Left padding of a tag row: one step per nesting level. */ +export function tagRowIndent(depth: number): { paddingLeft: number } { + return { paddingLeft: spacing.lg + depth * spacing.lg }; +} + function makeStyles(c: ThemePalette) { return StyleSheet.create({ overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, diff --git a/src/components/__tests__/nested-tags-wiring.test.ts b/src/components/__tests__/nested-tags-wiring.test.ts new file mode 100644 index 00000000..02004be4 --- /dev/null +++ b/src/components/__tests__/nested-tags-wiring.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Checked on the source: the suite has no React Native renderer. The rows +// themselves come from lib/keyword-nesting (keyword-nesting.test.ts); these +// check that the screens draw them. +const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', file), 'utf8'); + +describe('nested tags and tag visibility on screen', () => { + it('draws the sidebar tags as a tree with the visibility settings applied', () => { + const source = read('components/SidebarDrawer.tsx'); + expect(source).toMatch(/sidebarTagRows\(keywordDefs, nestedTags, tagCounts/); + expect(source).toMatch(/depth=\{node\.depth\}/); + expect(source).toMatch(/onToggleExpand=\{\(\) => toggleTagExpand\(node\.id\)\}/); + expect(source).toMatch(/sidebar\.show_all_tags/); + }); + + it('indents nested tags in the tag sheet', () => { + const source = read('components/TagSheet.tsx'); + expect(source).toMatch(/flattenKeywordTree\(buildKeywordTree\(keywords, nested\)\)/); + expect(source).toMatch(/tagRowIndent\(kw\.depth\)/); + }); + + it('offers the nesting switch, a parent and the sidebar visibility in Settings > Tags', () => { + const source = read('components/settings/KeywordSettings.tsx'); + expect(source).toMatch(/settings\.keywords\.nesting\.label/); + expect(source).toMatch(/onChange=\{setNested\}/); + expect(source).toMatch(/composeKeywordId\(parentId \|\| null, label\)/); + expect(source).toMatch(/updateKeyword\(kw\.id, \{ visibility: value as KeywordVisibility \}\)/); + }); + + it('names a nested tag by its path in the list and the reader', () => { + expect(read('screens/EmailListScreen.tsx')).toMatch(/formatKeyword\(id, keywordDefs, nestedTags\)/); + expect(read('components/email/MessageHeader.tsx')).toMatch(/formatKeyword\(kw\.id, keywordDefs, nestedTags\)/); + }); +}); diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index fa5101af..19423319 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -12,7 +12,8 @@ import { useColors } from '../../theme/colors'; import SenderAvatar from '../SenderAvatar'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; -import { useAccountKeywordDefs } from '../../stores/keywords-store'; +import { useAccountKeywordDefs, useKeywordsStore } from '../../stores/keywords-store'; +import { formatKeyword } from '../../lib/keyword-nesting'; import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date'; import { deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, viaIdentityNameShown, @@ -154,7 +155,13 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA return null; }, [spoofed, identities, from?.email, email]); - const tags = React.useMemo(() => resolveEmailTags(email.keywords, keywordDefs), [keywordDefs, email.keywords]); + // A nested tag is named by its path, "Work/Clients" (webmail tag badge). + const nestedTags = useKeywordsStore((s) => s.nested); + const tags = React.useMemo( + () => resolveEmailTags(email.keywords, keywordDefs) + .map((kw) => ({ ...kw, label: formatKeyword(kw.id, keywordDefs, nestedTags) })), + [keywordDefs, email.keywords, nestedTags], + ); const important = !!email.keywords?.$important; const answered = !!email.keywords?.$answered; const forwarded = !!email.keywords?.$forwarded; diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx index ba045087..7ac2dceb 100644 --- a/src/components/settings/KeywordSettings.tsx +++ b/src/components/settings/KeywordSettings.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native'; import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons'; -import { SettingsSection } from './settings-section'; +import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../../stores/keywords-store'; +import { useKeywordsStore, currentTagsLogin, type KeywordDef, type KeywordVisibility } from '../../stores/keywords-store'; import { useEmailStore } from '../../stores/email-store'; import { supportsBulwarkMetadata } from '../../api/bulwark-metadata'; import { DARK_COLORS } from '../../theme/tokens'; @@ -13,6 +13,10 @@ import { discoverKeywords } from '../../api/keyword-discovery'; import { findUnrecognizedKeywords, type UnrecognizedKeyword } from '../../lib/keyword-discovery'; import { jmapClient } from '../../api/jmap-client'; import { deleteKeywordWithUndo } from '../../lib/settings-undo'; +import { composeKeywordId, MAX_KEYWORD_ID_LENGTH } from '../../lib/new-tag'; +import { + formatKeyword, getKeywordVisibility, getParentKeywordId, hasChildKeywords, parentKeywordOptions, +} from '../../lib/keyword-nesting'; type Keyword = KeywordDef; @@ -30,6 +34,8 @@ export function KeywordSettings() { const resetDefaults = useKeywordsStore((s) => s.resetDefaults); const hydrated = useKeywordsStore((s) => s.hydrated); const hydrate = useKeywordsStore((s) => s.hydrate); + const nested = useKeywordsStore((s) => s.nested); + const setNested = useKeywordsStore((s) => s.setNested); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); @@ -38,8 +44,9 @@ export function KeywordSettings() { const saveKeyword = (kw: Keyword, editing: boolean) => { if (editing && editingId) { - const { id, ...patch } = kw; - updateKeyword(editingId, patch); + // The id stays: it is on the messages, and this app does not move + // them to a new keyword (webmail migrateKeyword). + updateKeyword(editingId, { label: kw.label, color: kw.color }); setEditingId(null); } else { addKeyword(kw); @@ -98,8 +105,25 @@ export function KeywordSettings() { setScan((s) => ({ ...s, found: s.found.filter((f) => f.id !== kw.id) })); }; + // Sidebar visibility (webmail keyword-settings.tsx). + const visibilityOptions = [ + { value: 'show', label: t('settings.keywords.visibility.show', 'Show') }, + { value: 'unread', label: t('settings.keywords.visibility.unread', 'Show if unread') }, + { value: 'hide', label: t('settings.keywords.visibility.hide', 'Hide') }, + ]; + return ( + + + {keywords.map((kw) => { if (editingId === kw.id) { @@ -108,24 +132,47 @@ export function KeywordSettings() { key={kw.id} initial={kw} existingIds={keywords.filter((k) => k.id !== kw.id).map((k) => k.id)} + keywords={keywords} + nested={nested} onSave={(k) => saveKeyword(k, true)} onCancel={() => setEditingId(null)} /> ); } const palette = (c.tagsAsPicked ?? c.tags)[kw.color]; + // A tag with tags nested under it can't go first (webmail + // has_children_delete): its children would lose their parent. + const hasChildren = hasChildKeywords(kw.id, keywords); return ( - - - {kw.label} - $label:{kw.id} - - setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })}> - - - deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })}> - - + + + + {formatKeyword(kw.id, keywords, nested)} + + setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })}> + + + deleteKeyword(kw.id)} + disabled={hasChildren} + accessibilityRole="button" + accessibilityState={{ disabled: hasChildren }} + accessibilityLabel={t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })} + accessibilityHint={hasChildren ? t('settings.keywords.has_children_delete', 'Remove the tags nested under this one first') : undefined} + > + + + + + + $label:{kw.id} + {t('settings.keywords.visibility_field', 'Sidebar visibility')} + + + )} + {nested && initial && initialParent && ( + + {t('settings.keywords.parent_field', 'Parent tag')} + {formatKeyword(initialParent, keywords, true)} + + )} {t('settings.keywords.label_field', "Display Name")} + {nested && normalizedId.length > 0 && $label:{normalizedId}} {isDuplicate && {t('settings.keywords.id_exists', "This tag ID already exists")}} + {isTooLong && ( + + {t('settings.keywords.too_long', 'This tag path is too long (at most {max} characters)', { max: MAX_KEYWORD_ID_LENGTH })} + + )} @@ -364,6 +438,17 @@ function makeStyles(c: ThemePalette) { borderColor: c.border, backgroundColor: c.background, }, + kwCard: { + gap: spacing.xs, + padding: spacing.md, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.background, + }, + kwCardTop: { flexDirection: 'row', alignItems: 'center', gap: spacing.md }, + kwCardBottom: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' }, + kwVisibilityLabel: { ...typography.caption, color: c.mutedForeground }, kwDot: { width: 20, height: 20, borderRadius: 10 }, kwLabel: { ...typography.bodyMedium, color: c.text, flex: 1 }, kwId: { fontSize: 10, color: c.mutedForeground, fontFamily: 'monospace' }, diff --git a/src/lib/__tests__/keyword-nesting.test.ts b/src/lib/__tests__/keyword-nesting.test.ts new file mode 100644 index 00000000..f112689f --- /dev/null +++ b/src/lib/__tests__/keyword-nesting.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest'; +import type { KeywordDef } from '../../stores/keywords-store'; +import { + buildKeywordTree, + filterKeywordTree, + flattenKeywordTree, + formatKeyword, + getParentKeywordId, + hasChildKeywords, + isTagVisibleInSidebar, + parentKeywordOptions, + sidebarTagRows, + treeRowLabel, +} from '../keyword-nesting'; + +// The webmail's DEV_KEYWORDS (stores/settings-store.ts). +const TAGS: KeywordDef[] = [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'teal' }, + { id: 'work/clients/acme', label: 'Acme', color: 'green' }, + { id: 'personal', label: 'Personal', color: 'purple' }, + { id: 'personal/finance', label: 'Finance', color: 'amber' }, + { id: 'receipts', label: 'Receipts', color: 'gray' }, +]; + +describe('buildKeywordTree', () => { + it('nests tags under the tag their id names as parent, keeping the order', () => { + const tree = buildKeywordTree(TAGS); + expect(tree.map((n) => n.id)).toEqual(['work', 'personal', 'receipts']); + expect(tree[0].children.map((n) => n.id)).toEqual(['work/clients']); + expect(tree[0].children[0].children[0]).toMatchObject({ id: 'work/clients/acme', depth: 2 }); + }); + + it('keeps a tag whose parent is not defined at the root', () => { + const tree = buildKeywordTree([{ id: 'team/urgent', label: 'Urgent', color: 'red' }]); + expect(tree).toHaveLength(1); + expect(tree[0].depth).toBe(0); + }); + + it('makes every tag a root with nesting off', () => { + const tree = buildKeywordTree(TAGS, false); + expect(tree).toHaveLength(TAGS.length); + expect(tree.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true); + }); +}); + +describe('names', () => { + it('spells a nested tag out level by level with nesting on, by its own label with it off', () => { + expect(formatKeyword('work/clients/acme', TAGS, true)).toBe('Work/Clients/Acme'); + expect(formatKeyword('work/clients/acme', TAGS, false)).toBe('Acme'); + }); + + it('falls back to the raw level where a level has no definition', () => { + expect(formatKeyword('work/other', TAGS, true)).toBe('Work/other'); + expect(formatKeyword('team/urgent', TAGS, false)).toBe('team/urgent'); + }); + + it('names a root by its path and a child by its own label in a tree', () => { + const all: KeywordDef[] = [...TAGS, { id: 'team/urgent', label: 'Urgent', color: 'red' }]; + const rows = flattenKeywordTree(buildKeywordTree(all)); + expect(treeRowLabel(rows.find((r) => r.id === 'work/clients')!, all, true)).toBe('Clients'); + expect(treeRowLabel(rows.find((r) => r.id === 'team/urgent')!, all, true)).toBe('team/Urgent'); + }); +}); + +describe('hierarchy helpers', () => { + it('finds the parent and children of a tag', () => { + expect(getParentKeywordId('work/clients/acme')).toBe('work/clients'); + expect(getParentKeywordId('work')).toBeNull(); + expect(hasChildKeywords('work', TAGS)).toBe(true); + expect(hasChildKeywords('receipts', TAGS)).toBe(false); + // `work-old` is not under `work`. + expect(hasChildKeywords('work', [{ id: 'work-old' }])).toBe(false); + }); + + it('offers every tag as a parent by its full path, in tree order', () => { + expect(parentKeywordOptions(TAGS).map((o) => o.label)).toEqual([ + 'Work', 'Work/Clients', 'Work/Clients/Acme', 'Personal', 'Personal/Finance', 'Receipts', + ]); + expect(parentKeywordOptions(TAGS, 'work').map((o) => o.id)).toEqual(['personal', 'personal/finance', 'receipts']); + }); + + it('keeps a hidden parent when a child below it stays', () => { + const kept = filterKeywordTree(buildKeywordTree(TAGS), (n) => n.id === 'work/clients/acme'); + expect(kept.map((n) => n.id)).toEqual(['work']); + expect(flattenKeywordTree(kept).map((n) => n.id)).toEqual(['work', 'work/clients', 'work/clients/acme']); + }); +}); + +describe('sidebar visibility', () => { + it('hides a tag set to Hide, and one set to Show if unread once it has no unread mail', () => { + const hide = { id: 'a', visibility: 'hide' as const }; + const unread = { id: 'b', visibility: 'unread' as const }; + expect(isTagVisibleInSidebar(hide, {})).toBe(false); + expect(isTagVisibleInSidebar(unread, { b: { unread: 0 } })).toBe(false); + expect(isTagVisibleInSidebar(unread, { b: { unread: 2 } })).toBe(true); + // No count yet: shown rather than blinking out and back in. + expect(isTagVisibleInSidebar(unread, {})).toBe(true); + expect(isTagVisibleInSidebar({ id: 'c' }, {})).toBe(true); + }); + + it('shows a hidden tag while its tag view is open or "Show all" is on', () => { + const hide = { id: 'a', visibility: 'hide' as const }; + expect(isTagVisibleInSidebar(hide, {}, { selectedId: 'a' })).toBe(true); + expect(isTagVisibleInSidebar(hide, {}, { showAll: true })).toBe(true); + }); +}); + +describe('sidebarTagRows', () => { + it('draws nested rows at their depth, and leaves out the children of a folded parent', () => { + const { rows } = sidebarTagRows(TAGS, true, {}); + expect(rows.map((r) => [r.node.id, r.node.depth, r.label])).toEqual([ + ['work', 0, 'Work'], + ['work/clients', 1, 'Clients'], + ['work/clients/acme', 2, 'Acme'], + ['personal', 0, 'Personal'], + ['personal/finance', 1, 'Finance'], + ['receipts', 0, 'Receipts'], + ]); + expect(rows[0]).toMatchObject({ hasChildren: true, expanded: true }); + const folded = sidebarTagRows(TAGS, true, {}, { collapsed: new Set(['work']) }); + expect(folded.rows.map((r) => r.node.id)).toEqual(['work', 'personal', 'personal/finance', 'receipts']); + expect(folded.rows[0]).toMatchObject({ hasChildren: true, expanded: false }); + }); + + it('draws a flat list named by label with nesting off', () => { + const { rows } = sidebarTagRows(TAGS, false, {}); + expect(rows.every((r) => r.node.depth === 0 && !r.hasChildren)).toBe(true); + expect(rows.map((r) => r.label)).toContain('Acme'); + }); + + it('counts the tags the visibility settings hide, for "Show all (N)"', () => { + const tags: KeywordDef[] = [ + ...TAGS.filter((t) => t.id !== 'receipts'), + { id: 'receipts', label: 'Receipts', color: 'gray', visibility: 'hide' }, + { id: 'news', label: 'News', color: 'gray', visibility: 'unread' }, + ]; + const { rows, hiddenCount } = sidebarTagRows(tags, true, { news: { unread: 0 } }); + expect(rows.map((r) => r.node.id)).not.toContain('receipts'); + expect(rows.map((r) => r.node.id)).not.toContain('news'); + expect(hiddenCount).toBe(2); + const all = sidebarTagRows(tags, true, { news: { unread: 0 } }, { showAll: true }); + expect(all.hiddenCount).toBe(0); + expect(all.rows).toHaveLength(tags.length); + }); +}); diff --git a/src/lib/__tests__/shared-tags.test.ts b/src/lib/__tests__/shared-tags.test.ts index e45b5598..b19c5408 100644 --- a/src/lib/__tests__/shared-tags.test.ts +++ b/src/lib/__tests__/shared-tags.test.ts @@ -3,7 +3,7 @@ import { keywordColorFromPalette, mergeSharedTags, parseSharedTags } from '../sh describe('parseSharedTags', () => { // The map shape the webmail writes (lib/shared-tags.ts). - it('reads the map by tag id, sorted by name, keeping unknown fields out', () => { + it('reads the map by tag id, sorted by name, with the sidebar visibility and without unknown fields', () => { expect(parseSharedTags({ tags: { urgent: { label: 'Urgent', color: 'red-dark', visibility: 'show', by: 'x' }, @@ -14,7 +14,7 @@ describe('parseSharedTags', () => { })).toEqual([ { id: 'billing', label: 'Billing', color: 'teal' }, { id: 'unnamed', label: 'unnamed', color: 'blue' }, - { id: 'urgent', label: 'Urgent', color: 'red' }, + { id: 'urgent', label: 'Urgent', color: 'red', visibility: 'show' }, ]); }); diff --git a/src/lib/keyword-nesting.ts b/src/lib/keyword-nesting.ts new file mode 100644 index 00000000..4a6a5f66 --- /dev/null +++ b/src/lib/keyword-nesting.ts @@ -0,0 +1,213 @@ +import type { KeywordDef, KeywordVisibility } from '../stores/keywords-store'; + +// Tag nesting and sidebar visibility, ported from the webmail's +// lib/keyword-nesting.ts and lib/keyword-format.ts. +// +// A tag is the JMAP keyword `$label:`. Nesting reuses that single id: +// levels are joined with a slash, so `$label:work/clients` is the child of +// `$label:work`. The hierarchy lives in the keyword itself, so the webmail +// and the app agree on it without sharing their tag lists. Ids are made by +// composeKeywordId (lib/new-tag.ts). + +/** Separates parent from child inside a tag id. */ +export const KEYWORD_SEPARATOR = '/'; + +/** A tag definition placed in the hierarchy its id describes. */ +export interface KeywordNode extends KeywordDef { + children: KeywordNode[]; + depth: number; +} + +/** 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: readonly Pick[]): boolean { + return keywords.some((keyword) => isKeywordDescendant(keyword.id, id)); +} + +/** + * The tag definitions as the tree their ids describe, keeping the user's + * order within each level. A tag whose direct parent is not defined stays at + * the root (callers name such a root in full). With nesting off every tag is + * its own root, so one code path draws both. + */ +export function buildKeywordTree(keywords: readonly KeywordDef[], nested = true): KeywordNode[] { + if (!nested) return keywords.map((kw) => ({ ...kw, children: [], depth: 0 })); + const nodes = new Map(); + 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 !== node) 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; +} + +/** + * Prunes a tag tree to the nodes worth showing. A node stays when the + * predicate accepts it or any of its descendants stays, so hiding a parent + * never strands the children below it. Depths stay as they were. + */ +export function filterKeywordTree( + nodes: readonly 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: readonly KeywordNode[]): number { + return nodes.reduce((total, node) => total + 1 + countKeywordNodes(node.children), 0); +} + +/** + * The rows of a tag tree in drawing order, depth first. Children of a node + * whose id is not in `expanded` are left out; pass null to show every level. + */ +export function flattenKeywordTree( + nodes: readonly KeywordNode[], + expanded: ReadonlySet | null = null, +): KeywordNode[] { + const out: KeywordNode[] = []; + const walk = (list: readonly KeywordNode[]) => { + for (const node of list) { + out.push(node); + if (node.children.length > 0 && (expanded === null || expanded.has(node.id))) walk(node.children); + } + }; + walk(nodes); + return out; +} + +/** + * A tag's display name, one entry per level. With nesting off a slash means + * nothing and the tag is named by its own label. With nesting on each level + * takes that tag's label, or the raw level when it has no definition. + */ +export function formatKeywordLabels(id: string, keywords: readonly KeywordDef[], nested: boolean): string[] { + const label = (levelId: string) => keywords.find((keyword) => keyword.id === levelId)?.label; + if (!nested) return [label(id) ?? id]; + const levels = keywordLevels(id); + if (levels.length === 0) return [label(id) ?? id]; + return levels.map((level, index) => label(levels.slice(0, index + 1).join(KEYWORD_SEPARATOR)) ?? level); +} + +/** `Work/Clients/Acme` for a nested tag, its own name otherwise. */ +export function formatKeyword(id: string, keywords: readonly KeywordDef[], nested: boolean): string { + return formatKeywordLabels(id, keywords, nested).join(KEYWORD_SEPARATOR); +} + +/** + * The label of a row in a tree: a nested row is placed by its indentation and + * shows its own name; a root spells out its path, which matters when a level + * above it is not defined here. + */ +export function treeRowLabel(node: KeywordNode, keywords: readonly KeywordDef[], nested: boolean): string { + return node.depth === 0 ? formatKeyword(node.id, keywords, nested) : node.label; +} + +/** Sidebar visibility of a tag, always shown when it was never set. */ +export function getKeywordVisibility(keyword: Pick): KeywordVisibility { + return keyword.visibility ?? 'show'; +} + +/** + * Whether a tag row shows in the sidebar (webmail sidebar isTagVisible). + * "Show if unread" keeps a tag whose count has not arrived yet, rather than + * blanking it and filling it back in; a count of zero unread hides it. The + * open tag view and "Show all" always show the tag. + */ +export function isTagVisibleInSidebar( + keyword: Pick, + counts: Readonly>, + options: { showAll?: boolean; selectedId?: string | null } = {}, +): boolean { + if (options.showAll || keyword.id === options.selectedId) return true; + const visibility = getKeywordVisibility(keyword); + if (visibility === 'hide') return false; + if (visibility === 'unread') { + const count = counts[keyword.id]; + return !count || count.unread > 0; + } + return true; +} + +/** + * The parents a tag being added can go under: every tag, in tree order, + * named by its full path (webmail KeywordEditForm parentOptions). An edited + * tag and the tags below it are left out. + */ +export function parentKeywordOptions( + keywords: readonly KeywordDef[], + excludeId?: string, +): { id: string; label: string; depth: number }[] { + return flattenKeywordTree(buildKeywordTree(keywords)) + .filter((node) => !excludeId || (node.id !== excludeId && !isKeywordDescendant(node.id, excludeId))) + .map((node) => ({ id: node.id, label: formatKeyword(node.id, keywords, true), depth: node.depth })); +} + +/** One tag row of the sidebar. */ +export interface SidebarTagRow { + node: KeywordNode; + label: string; + hasChildren: boolean; + expanded: boolean; +} + +/** + * The sidebar's tag rows and how many tags the visibility settings keep out + * of sight (the "Show all (N)" row), as the webmail sidebar builds them: + * the tree (or a flat list with nesting off), pruned by visibility, with the + * children of collapsed parents left out. + */ +export function sidebarTagRows( + keywords: readonly KeywordDef[], + nested: boolean, + counts: Readonly>, + options: { showAll?: boolean; selectedId?: string | null; collapsed?: ReadonlySet } = {}, +): { rows: SidebarTagRow[]; hiddenCount: number } { + const tree = buildKeywordTree(keywords, nested); + const visible = filterKeywordTree(tree, (node) => isTagVisibleInSidebar(node, counts, options)); + const hiddenCount = keywords.length - countKeywordNodes(visible); + const collapsed = options.collapsed ?? new Set(); + const rows: SidebarTagRow[] = []; + const walk = (nodes: readonly KeywordNode[]) => { + for (const node of nodes) { + const hasChildren = node.children.length > 0; + const expanded = hasChildren && !collapsed.has(node.id); + rows.push({ node, label: treeRowLabel(node, keywords, nested), hasChildren, expanded }); + if (expanded) walk(node.children); + } + }; + walk(visible); + return { rows, hiddenCount }; +} diff --git a/src/lib/shared-tags.ts b/src/lib/shared-tags.ts index 6fcd4d6f..105fe7ea 100644 --- a/src/lib/shared-tags.ts +++ b/src/lib/shared-tags.ts @@ -80,7 +80,12 @@ export function parseSharedTags(value: unknown): KeywordDef[] { return Object.entries(entriesOf(value)) .map(([id, entry]) => { const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : ''; - return { id, label: label || id, color: keywordColorFromPalette(entry.color) }; + const def: KeywordDef = { id, label: label || id, color: keywordColorFromPalette(entry.color) }; + // The sidebar visibility a member set for the group (webmail readSharedTags). + if (entry.visibility === 'show' || entry.visibility === 'hide' || entry.visibility === 'unread') { + def.visibility = entry.visibility; + } + return def; }) .sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id)); } diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index ebbc5682..35b61b4d 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -41,6 +41,7 @@ import { } from '../stores/email-store'; import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store'; import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../stores/keywords-store'; +import { formatKeyword } from '../lib/keyword-nesting'; import { mergeSharedTags } from '../lib/shared-tags'; import { useBackWhileFocused } from '../lib/use-back-while-focused'; import { listBackStep } from '../lib/list-back'; @@ -196,17 +197,19 @@ const EmailRow = React.memo(function EmailRow({ const unread = isUnread(item); const starred = isStarred(item); const pinned = isPinned(item); + // A nested tag is named by its path, "Work/Clients" (webmail tag badge). + const nestedTags = useKeywordsStore((s) => s.nested); const tags = React.useMemo(() => { if (!tagIds) return []; return tagIds.split(',').map((id) => { const def = keywordDefs.find((k) => k.id === id); return def - ? { id, label: def.label, dot: c.tags[def.color]?.dot ?? c.textMuted, text: c.tags[def.color]?.text ?? c.textSecondary, bg: c.tags[def.color]?.bg ?? c.muted } + ? { id, label: formatKeyword(id, keywordDefs, nestedTags), dot: c.tags[def.color]?.dot ?? c.textMuted, text: c.tags[def.color]?.text ?? c.textSecondary, bg: c.tags[def.color]?.bg ?? c.muted } // A tag no local definition explains (set by another client): grey, // with the raw id so it is at least visible and removable. - : { id, label: id, dot: c.tags.gray.dot, text: c.tags.gray.text, bg: c.tags.gray.bg }; + : { id, label: formatKeyword(id, keywordDefs, nestedTags), dot: c.tags.gray.dot, text: c.tags.gray.text, bg: c.tags.gray.bg }; }); - }, [tagIds, keywordDefs, c]); + }, [tagIds, keywordDefs, nestedTags, c]); // The row's key (`rowKeyOf`): ids repeat across the accounts of a list // spanning accounts (#1082). diff --git a/src/stores/__tests__/keywords-store-nesting.test.ts b/src/stores/__tests__/keywords-store-nesting.test.ts new file mode 100644 index 00000000..d5818fcd --- /dev/null +++ b/src/stores/__tests__/keywords-store-nesting.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +vi.mock('../../api/jmap-client', () => ({ jmapClient: { username: null, serverUrl: null, connectedAccountId: null } })); +vi.mock('../../api/bulwark-metadata', () => ({ getBulwarkMetadata: vi.fn() })); + +import { useKeywordsStore, DEFAULT_KEYWORDS } from '../keywords-store'; + +beforeEach(async () => { + await AsyncStorage.clear(); + useKeywordsStore.setState({ keywords: DEFAULT_KEYWORDS, nested: false, hydrated: false }); +}); + +describe('nested tags and visibility in the tag store', () => { + it('keeps "Nested tags" across a restart', async () => { + useKeywordsStore.getState().setNested(true); + expect(useKeywordsStore.getState().nested).toBe(true); + await Promise.resolve(); + useKeywordsStore.setState({ nested: false, hydrated: false }); + await useKeywordsStore.getState().hydrate(); + expect(useKeywordsStore.getState().nested).toBe(true); + }); + + it('starts with nesting off, as the webmail does', async () => { + await useKeywordsStore.getState().hydrate(); + expect(useKeywordsStore.getState().nested).toBe(false); + }); + + it("stores a tag's sidebar visibility with the tag, in the webmail's shape", async () => { + useKeywordsStore.getState().add({ id: 'work/clients', label: 'Clients', color: 'teal' }); + useKeywordsStore.getState().update('work/clients', { visibility: 'unread' }); + await Promise.resolve(); + const stored = JSON.parse((await AsyncStorage.getItem('webmail:keywords:v1')) ?? '[]'); + expect(stored).toContainEqual({ id: 'work/clients', label: 'Clients', color: 'teal', visibility: 'unread' }); + }); +}); diff --git a/src/stores/keywords-store.ts b/src/stores/keywords-store.ts index ab27669b..ac92aa3a 100644 --- a/src/stores/keywords-store.ts +++ b/src/stores/keywords-store.ts @@ -10,12 +10,24 @@ const STORAGE_KEY = 'webmail:keywords:v1'; export type KeywordColor = keyof typeof colors.tags; +/** + * Whether a tag shows in the sidebar always, only while it has unread mail, + * or never (webmail KeywordVisibility). + */ +export type KeywordVisibility = 'show' | 'hide' | 'unread'; + export interface KeywordDef { + /** JMAP keyword suffix, `$label:`; `parent/child` when nested. */ id: string; label: string; color: KeywordColor; + /** Absent on tags stored before this was configurable: shown. */ + visibility?: KeywordVisibility; } +/** "Nested tags" (webmail nestedTags): a slash in a tag id is a level. */ +const NESTED_STORAGE_KEY = 'webmail:keywords:nested:v1'; + // Same ids/labels/colours as the webmail's DEFAULT_KEYWORDS // (stores/settings-store.ts): the default tags are named after their colour, // so a message tagged `$label:blue` on one client is the same tag on the @@ -55,6 +67,12 @@ export const SHARED_TAGS_STALE_MS = 2 * 60_000; interface KeywordsState { keywords: KeywordDef[]; hydrated: boolean; + /** + * Nested tags on: `work/clients` is drawn under `work` in the sidebar, + * the tag sheet and Settings. Off by default, as in the webmail. + */ + nested: boolean; + setNested: (nested: boolean) => void; /** Shared tag definitions of the group accounts, by JMAP account id. */ sharedTags: Record; /** @@ -86,7 +104,7 @@ const sharedReadAt = new Map(); function sameTags(a: KeywordDef[], b: KeywordDef[]): boolean { return a.length === b.length - && a.every((t, i) => t.id === b[i].id && t.label === b[i].label && t.color === b[i].color); + && a.every((t, i) => t.id === b[i].id && t.label === b[i].label && t.color === b[i].color && t.visibility === b[i].visibility); } function persist(keywords: KeywordDef[]): void { @@ -98,8 +116,16 @@ function persist(keywords: KeywordDef[]): void { export const useKeywordsStore = create((set, get) => ({ keywords: DEFAULT_KEYWORDS, hydrated: false, + nested: false, sharedTags: {}, + setNested: (nested) => { + set({ nested }); + void AsyncStorage.setItem(NESTED_STORAGE_KEY, nested ? 'true' : 'false').catch((err) => { + console.warn('[keywords-store] persist nested failed', err); + }); + }, + loadSharedTags: (accountId, options) => { const login = currentTagsLogin(); if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve(); @@ -137,14 +163,19 @@ export const useKeywordsStore = create((set, get) => ({ hydrate: async () => { if (get().hydrated) return; try { - const raw = await AsyncStorage.getItem(STORAGE_KEY); + const [raw, rawNested] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEY), + AsyncStorage.getItem(NESTED_STORAGE_KEY), + ]); + const nested = rawNested === 'true'; if (raw) { const parsed = JSON.parse(raw) as KeywordDef[]; if (Array.isArray(parsed)) { - set({ keywords: parsed, hydrated: true }); + set({ keywords: parsed, nested, hydrated: true }); return; } } + set({ nested }); } catch (err) { console.warn('[keywords-store] hydrate failed', err); }