mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: nest tags under other tags and choose which tags the sidebar shows
This commit is contained in:
@@ -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<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
const [foldersExpanded, setFoldersExpanded] = React.useState(true);
|
||||
const [tagsExpanded, setTagsExpanded] = React.useState(true);
|
||||
const [unifiedExpanded, setUnifiedExpanded] = React.useState(false);
|
||||
const [expandedFolders, setExpandedFolders] = React.useState<Set<string>>(() => 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<Set<string>>(() => 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
|
||||
)}
|
||||
<Text style={styles.sectionHeaderText}>{t('sidebar.tags', 'Tags')}</Text>
|
||||
</Pressable>
|
||||
{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 (
|
||||
<SidebarRow
|
||||
key={kw.id}
|
||||
key={node.id}
|
||||
icon={<Tag size={16} color={dot} fill={dot} />}
|
||||
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) && (
|
||||
<SidebarRow
|
||||
icon={<MoreHorizontal size={16} color={c.textMuted} />}
|
||||
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={() => {}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -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 ? (
|
||||
<Text style={styles.empty}>{t('email_list.no_tags_defined', 'No tags defined. Add tags in Settings.')}</Text>
|
||||
) : (
|
||||
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
|
||||
<Pressable
|
||||
key={kw.id}
|
||||
onPress={() => onToggle(token, !applied)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
style={({ pressed }) => [styles.row, tagRowIndent(kw.depth), pressed && styles.rowPressed]}
|
||||
>
|
||||
<Tag size={16} color={dot} fill={dot} />
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>{kw.label}</Text>
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>{treeRowLabel(kw, keywords, nested)}</Text>
|
||||
{applied && <Check size={16} color={c.primary} />}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<SettingsSection title={t('settings.keywords.title', "Email Tags")} description={t('settings.keywords.description_mobile', "Colored tags to organize your mail.")}>
|
||||
<SettingItem
|
||||
label={t('settings.keywords.nesting.label', 'Nested tags')}
|
||||
description={t('settings.keywords.nesting.description', 'Nest tags underneath other tags and show them as a tree in the sidebar.')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={nested}
|
||||
onChange={setNested}
|
||||
accessibilityLabel={t('settings.keywords.nesting.label', 'Nested tags')}
|
||||
/>
|
||||
</SettingItem>
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
{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 (
|
||||
<View key={kw.id} style={styles.kwRow}>
|
||||
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
|
||||
<Text style={styles.kwLabel}>{kw.label}</Text>
|
||||
<Text style={styles.kwId}>$label:{kw.id}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 2 }}>
|
||||
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })}>
|
||||
<Pencil size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })}>
|
||||
<Trash2 size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
<View key={kw.id} style={styles.kwCard}>
|
||||
<View style={styles.kwCardTop}>
|
||||
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
|
||||
<Text style={styles.kwLabel}>{formatKeyword(kw.id, keywords, nested)}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 2 }}>
|
||||
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })}>
|
||||
<Pencil size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.iconBtn, hasChildren && { opacity: 0.4 }]}
|
||||
onPress={() => 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}
|
||||
>
|
||||
<Trash2 size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.kwCardBottom}>
|
||||
<Text style={[styles.kwId, { flex: 1 }]} numberOfLines={1}>$label:{kw.id}</Text>
|
||||
<Text style={styles.kwVisibilityLabel}>{t('settings.keywords.visibility_field', 'Sidebar visibility')}</Text>
|
||||
<Select
|
||||
value={getKeywordVisibility(kw)}
|
||||
onChange={(value) => updateKeyword(kw.id, { visibility: value as KeywordVisibility })}
|
||||
options={visibilityOptions}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -134,6 +181,8 @@ export function KeywordSettings() {
|
||||
{isAdding && (
|
||||
<KeywordForm
|
||||
existingIds={keywords.map((k) => k.id)}
|
||||
keywords={keywords}
|
||||
nested={nested}
|
||||
onSave={(k) => saveKeyword(k, false)}
|
||||
onCancel={() => setIsAdding(false)}
|
||||
/>
|
||||
@@ -267,34 +316,53 @@ function SharedTagsSection() {
|
||||
interface KeywordFormProps {
|
||||
initial?: Keyword;
|
||||
existingIds: string[];
|
||||
/** Every tag, for the parent picker. */
|
||||
keywords: Keyword[];
|
||||
/** "Nested tags" is on: a new tag can go under another one. */
|
||||
nested: boolean;
|
||||
onSave: (kw: Keyword) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
|
||||
function KeywordForm({ initial, existingIds, keywords, nested, onSave, onCancel }: KeywordFormProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [label, setLabel] = useState(initial?.label ?? '');
|
||||
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
|
||||
const [parentId, setParentId] = useState('');
|
||||
|
||||
const normalizedId = label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
|
||||
// A new tag takes the id of its name under its parent (webmail
|
||||
// KeywordEditForm). An edited tag keeps its id, which is on the messages.
|
||||
const normalizedId = initial ? initial.id : composeKeywordId(parentId || null, label);
|
||||
const isDuplicate = !initial && normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||
const isTooLong = !initial && normalizedId.length > MAX_KEYWORD_ID_LENGTH;
|
||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
|
||||
const parentOptions = [
|
||||
{ value: '', label: t('settings.keywords.no_parent', 'No parent') },
|
||||
...parentKeywordOptions(keywords).map((o) => ({ value: o.id, label: o.label })),
|
||||
];
|
||||
const initialParent = initial ? getParentKeywordId(initial.id) : null;
|
||||
|
||||
const handleSave = () => {
|
||||
if (!isValid) return;
|
||||
onSave({ id: normalizedId, label: label.trim(), color });
|
||||
onSave({ ...(initial ?? {}), id: normalizedId, label: label.trim(), color });
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.form}>
|
||||
{nested && !initial && keywords.length > 0 && (
|
||||
<View>
|
||||
<Text style={styles.formLabel}>{t('settings.keywords.parent_field', 'Parent tag')}</Text>
|
||||
<Select value={parentId} onChange={setParentId} options={parentOptions} />
|
||||
</View>
|
||||
)}
|
||||
{nested && initial && initialParent && (
|
||||
<View>
|
||||
<Text style={styles.formLabel}>{t('settings.keywords.parent_field', 'Parent tag')}</Text>
|
||||
<Text style={styles.kwLabel}>{formatKeyword(initialParent, keywords, true)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View>
|
||||
<Text style={styles.formLabel}>{t('settings.keywords.label_field', "Display Name")}</Text>
|
||||
<TextInput accessibilityLabel={t('settings.keywords.label_field', "Display Name")}
|
||||
@@ -306,7 +374,13 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
||||
maxLength={30}
|
||||
autoFocus
|
||||
/>
|
||||
{nested && normalizedId.length > 0 && <Text style={[styles.kwId, { marginTop: 4 }]}>$label:{normalizedId}</Text>}
|
||||
{isDuplicate && <Text style={styles.errorText}>{t('settings.keywords.id_exists', "This tag ID already exists")}</Text>}
|
||||
{isTooLong && (
|
||||
<Text style={styles.errorText}>
|
||||
{t('settings.keywords.too_long', 'This tag path is too long (at most {max} characters)', { max: MAX_KEYWORD_ID_LENGTH })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View>
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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:<id>`. 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<KeywordDef, 'id'>[]): 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<string, KeywordNode>();
|
||||
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<string> | 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<KeywordDef, 'visibility'>): 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<KeywordDef, 'id' | 'visibility'>,
|
||||
counts: Readonly<Record<string, { unread: number } | undefined>>,
|
||||
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<Record<string, { unread: number } | undefined>>,
|
||||
options: { showAll?: boolean; selectedId?: string | null; collapsed?: ReadonlySet<string> } = {},
|
||||
): { 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<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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:<id>`; `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<string, SharedTagsEntry>;
|
||||
/**
|
||||
@@ -86,7 +104,7 @@ const sharedReadAt = new Map<string, number>();
|
||||
|
||||
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<KeywordsState>((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<KeywordsState>((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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user