feat: nest tags under other tags and choose which tags the sidebar shows

This commit is contained in:
Linus Rath
2026-10-31 15:20:57 +01:00
parent 21dd5a2405
commit 585c70a5db
12 changed files with 683 additions and 54 deletions
+65 -13
View File
@@ -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>
+18 -4
View File
@@ -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\)/);
});
});
+9 -2
View File
@@ -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;
+111 -26
View File
@@ -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' },
+146
View File
@@ -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);
});
});
+2 -2
View File
@@ -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' },
]);
});
+213
View File
@@ -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 };
}
+6 -1
View File
@@ -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));
}
+6 -3
View File
@@ -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' });
});
});
+34 -3
View File
@@ -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);
}