mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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 {
|
import {
|
||||||
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
|
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
|
||||||
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
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,
|
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
|
||||||
type AppIcon,
|
type AppIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
markMailboxesAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox,
|
markMailboxesAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox,
|
||||||
} from '../api/email';
|
} from '../api/email';
|
||||||
import { useTagCountsStore } from '../stores/tag-counts-store';
|
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 { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
@@ -59,6 +61,7 @@ const STORAGE_KEYS = {
|
|||||||
expanded: 'sidebar:expandedMailboxes',
|
expanded: 'sidebar:expandedMailboxes',
|
||||||
foldersExpanded: 'sidebar:foldersExpanded',
|
foldersExpanded: 'sidebar:foldersExpanded',
|
||||||
tagsExpanded: 'sidebar:tagsExpanded',
|
tagsExpanded: 'sidebar:tagsExpanded',
|
||||||
|
collapsedTags: 'sidebar:collapsedTags',
|
||||||
unifiedExpanded: 'sidebar:unifiedExpanded',
|
unifiedExpanded: 'sidebar:unifiedExpanded',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -360,12 +363,18 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||||
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
||||||
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
||||||
|
const nestedTags = useKeywordsStore((s) => s.nested);
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
const [foldersExpanded, setFoldersExpanded] = React.useState(true);
|
const [foldersExpanded, setFoldersExpanded] = React.useState(true);
|
||||||
const [tagsExpanded, setTagsExpanded] = React.useState(true);
|
const [tagsExpanded, setTagsExpanded] = React.useState(true);
|
||||||
const [unifiedExpanded, setUnifiedExpanded] = React.useState(false);
|
const [unifiedExpanded, setUnifiedExpanded] = React.useState(false);
|
||||||
const [expandedFolders, setExpandedFolders] = React.useState<Set<string>>(() => new Set());
|
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 [accountMenuOpen, setAccountMenuOpen] = React.useState(false);
|
||||||
const [sheet, setSheet] = React.useState<{ title: string; actions: SheetAction[] } | null>(null);
|
const [sheet, setSheet] = React.useState<{ title: string; actions: SheetAction[] } | null>(null);
|
||||||
const [prompt, setPrompt] = React.useState<{
|
const [prompt, setPrompt] = React.useState<{
|
||||||
@@ -381,12 +390,19 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
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.expanded),
|
||||||
AsyncStorage.getItem(STORAGE_KEYS.foldersExpanded),
|
AsyncStorage.getItem(STORAGE_KEYS.foldersExpanded),
|
||||||
AsyncStorage.getItem(STORAGE_KEYS.tagsExpanded),
|
AsyncStorage.getItem(STORAGE_KEYS.tagsExpanded),
|
||||||
AsyncStorage.getItem(STORAGE_KEYS.unifiedExpanded),
|
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) {
|
if (rawExp) {
|
||||||
try {
|
try {
|
||||||
const ids = JSON.parse(rawExp) as string[];
|
const ids = JSON.parse(rawExp) as string[];
|
||||||
@@ -459,6 +475,15 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
|||||||
onClose();
|
onClose();
|
||||||
}, [selectMailbox, setFilters, 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.
|
// Tag view (#175): every folder, messages carrying the tag.
|
||||||
const selectTag = React.useCallback((id: string) => {
|
const selectTag = React.useCallback((id: string) => {
|
||||||
setFilters({ keyword: keywordToken(id) });
|
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);
|
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts, excluded);
|
||||||
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, mailboxes, activeAccountId, tagCountsGeneration, ensureTagCounts]);
|
}, [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) ───────────────────────────────────────
|
// ── Folder actions (long-press) ───────────────────────────────────────
|
||||||
const refFor = (mb: Mailbox) => ({
|
const refFor = (mb: Mailbox) => ({
|
||||||
id: mb.originalId ?? mb.id,
|
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>
|
<Text style={styles.sectionHeaderText}>{t('sidebar.tags', 'Tags')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{tagsExpanded && keywordDefs.map((kw) => {
|
{tagsExpanded && tagRows.rows.map(({ node, label, hasChildren, expanded }) => {
|
||||||
const counts = tagCounts[kw.id];
|
const counts = tagCounts[node.id];
|
||||||
const isSelected = filters.keyword === keywordToken(kw.id);
|
const isSelected = filters.keyword === keywordToken(node.id);
|
||||||
const dot = c.tags[kw.color]?.dot ?? c.textMuted;
|
const dot = c.tags[node.color]?.dot ?? c.textMuted;
|
||||||
return (
|
return (
|
||||||
<SidebarRow
|
<SidebarRow
|
||||||
key={kw.id}
|
key={node.id}
|
||||||
icon={<Tag size={16} color={dot} fill={dot} />}
|
icon={<Tag size={16} color={dot} fill={dot} />}
|
||||||
label={kw.label}
|
label={label}
|
||||||
depth={0}
|
depth={node.depth}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
unread={counts?.unread ?? 0}
|
unread={counts?.unread ?? 0}
|
||||||
total={counts?.total ?? 0}
|
total={counts?.total ?? 0}
|
||||||
showTotal={showFolderTotalCount}
|
showTotal={showFolderTotalCount}
|
||||||
hasChildren={false}
|
hasChildren={hasChildren}
|
||||||
isExpanded={false}
|
isExpanded={expanded}
|
||||||
onPress={() => selectTag(kw.id)}
|
onPress={() => selectTag(node.id)}
|
||||||
onToggleExpand={() => {}}
|
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>
|
</ScrollView>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { X, Check, Tag } from './icons';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
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 { useLocaleStore } from '../stores/locale-store';
|
||||||
import { getEmailTagIds } from '../lib/thread-utils';
|
import { getEmailTagIds } from '../lib/thread-utils';
|
||||||
import type { Email } from '../api/types';
|
import type { Email } from '../api/types';
|
||||||
@@ -59,6 +60,14 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
|
|||||||
[selectedEmails],
|
[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
|
// 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.
|
// webmail or another device): listed so they can at least be removed.
|
||||||
const unknownIds = React.useMemo(() => {
|
const unknownIds = React.useMemo(() => {
|
||||||
@@ -103,7 +112,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
|
|||||||
{keywords.length === 0 && unknownIds.length === 0 ? (
|
{keywords.length === 0 && unknownIds.length === 0 ? (
|
||||||
<Text style={styles.empty}>{t('email_list.no_tags_defined', 'No tags defined. Add tags in Settings.')}</Text>
|
<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 token = keywordToken(kw.id);
|
||||||
const applied = allHaveToken(token);
|
const applied = allHaveToken(token);
|
||||||
const dot = c.tags[kw.color]?.dot ?? c.primary;
|
const dot = c.tags[kw.color]?.dot ?? c.primary;
|
||||||
@@ -111,10 +120,10 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
|
|||||||
<Pressable
|
<Pressable
|
||||||
key={kw.id}
|
key={kw.id}
|
||||||
onPress={() => onToggle(token, !applied)}
|
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} />
|
<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} />}
|
{applied && <Check size={16} color={c.primary} />}
|
||||||
</Pressable>
|
</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) {
|
function makeStyles(c: ThemePalette) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
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 SenderAvatar from '../SenderAvatar';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
import { useLocaleStore } from '../../stores/locale-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 { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
||||||
import {
|
import {
|
||||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, viaIdentityNameShown,
|
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, viaIdentityNameShown,
|
||||||
@@ -154,7 +155,13 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
return null;
|
return null;
|
||||||
}, [spoofed, identities, from?.email, email]);
|
}, [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 important = !!email.keywords?.$important;
|
||||||
const answered = !!email.keywords?.$answered;
|
const answered = !!email.keywords?.$answered;
|
||||||
const forwarded = !!email.keywords?.$forwarded;
|
const forwarded = !!email.keywords?.$forwarded;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native';
|
import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native';
|
||||||
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons';
|
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 { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
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 { useEmailStore } from '../../stores/email-store';
|
||||||
import { supportsBulwarkMetadata } from '../../api/bulwark-metadata';
|
import { supportsBulwarkMetadata } from '../../api/bulwark-metadata';
|
||||||
import { DARK_COLORS } from '../../theme/tokens';
|
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 { findUnrecognizedKeywords, type UnrecognizedKeyword } from '../../lib/keyword-discovery';
|
||||||
import { jmapClient } from '../../api/jmap-client';
|
import { jmapClient } from '../../api/jmap-client';
|
||||||
import { deleteKeywordWithUndo } from '../../lib/settings-undo';
|
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;
|
type Keyword = KeywordDef;
|
||||||
|
|
||||||
@@ -30,6 +34,8 @@ export function KeywordSettings() {
|
|||||||
const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
|
const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
|
||||||
const hydrated = useKeywordsStore((s) => s.hydrated);
|
const hydrated = useKeywordsStore((s) => s.hydrated);
|
||||||
const hydrate = useKeywordsStore((s) => s.hydrate);
|
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]);
|
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||||
|
|
||||||
@@ -38,8 +44,9 @@ export function KeywordSettings() {
|
|||||||
|
|
||||||
const saveKeyword = (kw: Keyword, editing: boolean) => {
|
const saveKeyword = (kw: Keyword, editing: boolean) => {
|
||||||
if (editing && editingId) {
|
if (editing && editingId) {
|
||||||
const { id, ...patch } = kw;
|
// The id stays: it is on the messages, and this app does not move
|
||||||
updateKeyword(editingId, patch);
|
// them to a new keyword (webmail migrateKeyword).
|
||||||
|
updateKeyword(editingId, { label: kw.label, color: kw.color });
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
} else {
|
} else {
|
||||||
addKeyword(kw);
|
addKeyword(kw);
|
||||||
@@ -98,8 +105,25 @@ export function KeywordSettings() {
|
|||||||
setScan((s) => ({ ...s, found: s.found.filter((f) => f.id !== kw.id) }));
|
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 (
|
return (
|
||||||
<SettingsSection title={t('settings.keywords.title', "Email Tags")} description={t('settings.keywords.description_mobile', "Colored tags to organize your mail.")}>
|
<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 }}>
|
<View style={{ gap: spacing.sm }}>
|
||||||
{keywords.map((kw) => {
|
{keywords.map((kw) => {
|
||||||
if (editingId === kw.id) {
|
if (editingId === kw.id) {
|
||||||
@@ -108,24 +132,47 @@ export function KeywordSettings() {
|
|||||||
key={kw.id}
|
key={kw.id}
|
||||||
initial={kw}
|
initial={kw}
|
||||||
existingIds={keywords.filter((k) => k.id !== kw.id).map((k) => k.id)}
|
existingIds={keywords.filter((k) => k.id !== kw.id).map((k) => k.id)}
|
||||||
|
keywords={keywords}
|
||||||
|
nested={nested}
|
||||||
onSave={(k) => saveKeyword(k, true)}
|
onSave={(k) => saveKeyword(k, true)}
|
||||||
onCancel={() => setEditingId(null)}
|
onCancel={() => setEditingId(null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const palette = (c.tagsAsPicked ?? c.tags)[kw.color];
|
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 (
|
return (
|
||||||
<View key={kw.id} style={styles.kwRow}>
|
<View key={kw.id} style={styles.kwCard}>
|
||||||
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
|
<View style={styles.kwCardTop}>
|
||||||
<Text style={styles.kwLabel}>{kw.label}</Text>
|
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
|
||||||
<Text style={styles.kwId}>$label:{kw.id}</Text>
|
<Text style={styles.kwLabel}>{formatKeyword(kw.id, keywords, nested)}</Text>
|
||||||
<View style={{ flexDirection: 'row', gap: 2 }}>
|
<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 })}>
|
<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} />
|
<Pencil size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })}>
|
<Pressable
|
||||||
<Trash2 size={14} color={c.mutedForeground} />
|
style={[styles.iconBtn, hasChildren && { opacity: 0.4 }]}
|
||||||
</Pressable>
|
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>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -134,6 +181,8 @@ export function KeywordSettings() {
|
|||||||
{isAdding && (
|
{isAdding && (
|
||||||
<KeywordForm
|
<KeywordForm
|
||||||
existingIds={keywords.map((k) => k.id)}
|
existingIds={keywords.map((k) => k.id)}
|
||||||
|
keywords={keywords}
|
||||||
|
nested={nested}
|
||||||
onSave={(k) => saveKeyword(k, false)}
|
onSave={(k) => saveKeyword(k, false)}
|
||||||
onCancel={() => setIsAdding(false)}
|
onCancel={() => setIsAdding(false)}
|
||||||
/>
|
/>
|
||||||
@@ -267,34 +316,53 @@ function SharedTagsSection() {
|
|||||||
interface KeywordFormProps {
|
interface KeywordFormProps {
|
||||||
initial?: Keyword;
|
initial?: Keyword;
|
||||||
existingIds: string[];
|
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;
|
onSave: (kw: Keyword) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
|
function KeywordForm({ initial, existingIds, keywords, nested, onSave, onCancel }: KeywordFormProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const [label, setLabel] = useState(initial?.label ?? '');
|
const [label, setLabel] = useState(initial?.label ?? '');
|
||||||
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
|
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
|
||||||
|
const [parentId, setParentId] = useState('');
|
||||||
|
|
||||||
const normalizedId = label
|
// A new tag takes the id of its name under its parent (webmail
|
||||||
.trim()
|
// KeywordEditForm). An edited tag keeps its id, which is on the messages.
|
||||||
.toLowerCase()
|
const normalizedId = initial ? initial.id : composeKeywordId(parentId || null, label);
|
||||||
.replace(/[^a-z0-9_-]/g, '-')
|
const isDuplicate = !initial && normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||||
.replace(/-+/g, '-')
|
const isTooLong = !initial && normalizedId.length > MAX_KEYWORD_ID_LENGTH;
|
||||||
.replace(/^-|-$/g, '');
|
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
|
||||||
|
const parentOptions = [
|
||||||
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
{ value: '', label: t('settings.keywords.no_parent', 'No parent') },
|
||||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
|
...parentKeywordOptions(keywords).map((o) => ({ value: o.id, label: o.label })),
|
||||||
|
];
|
||||||
|
const initialParent = initial ? getParentKeywordId(initial.id) : null;
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
onSave({ id: normalizedId, label: label.trim(), color });
|
onSave({ ...(initial ?? {}), id: normalizedId, label: label.trim(), color });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.form}>
|
<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>
|
<View>
|
||||||
<Text style={styles.formLabel}>{t('settings.keywords.label_field', "Display Name")}</Text>
|
<Text style={styles.formLabel}>{t('settings.keywords.label_field', "Display Name")}</Text>
|
||||||
<TextInput accessibilityLabel={t('settings.keywords.label_field', "Display Name")}
|
<TextInput accessibilityLabel={t('settings.keywords.label_field', "Display Name")}
|
||||||
@@ -306,7 +374,13 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
|
|||||||
maxLength={30}
|
maxLength={30}
|
||||||
autoFocus
|
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>}
|
{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>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
@@ -364,6 +438,17 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderColor: c.border,
|
borderColor: c.border,
|
||||||
backgroundColor: c.background,
|
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 },
|
kwDot: { width: 20, height: 20, borderRadius: 10 },
|
||||||
kwLabel: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
kwLabel: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||||
kwId: { fontSize: 10, color: c.mutedForeground, fontFamily: 'monospace' },
|
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', () => {
|
describe('parseSharedTags', () => {
|
||||||
// The map shape the webmail writes (lib/shared-tags.ts).
|
// 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({
|
expect(parseSharedTags({
|
||||||
tags: {
|
tags: {
|
||||||
urgent: { label: 'Urgent', color: 'red-dark', visibility: 'show', by: 'x' },
|
urgent: { label: 'Urgent', color: 'red-dark', visibility: 'show', by: 'x' },
|
||||||
@@ -14,7 +14,7 @@ describe('parseSharedTags', () => {
|
|||||||
})).toEqual([
|
})).toEqual([
|
||||||
{ id: 'billing', label: 'Billing', color: 'teal' },
|
{ id: 'billing', label: 'Billing', color: 'teal' },
|
||||||
{ id: 'unnamed', label: 'unnamed', color: 'blue' },
|
{ 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))
|
return Object.entries(entriesOf(value))
|
||||||
.map(([id, entry]) => {
|
.map(([id, entry]) => {
|
||||||
const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : '';
|
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));
|
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
} from '../stores/email-store';
|
} from '../stores/email-store';
|
||||||
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store';
|
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store';
|
||||||
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../stores/keywords-store';
|
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../stores/keywords-store';
|
||||||
|
import { formatKeyword } from '../lib/keyword-nesting';
|
||||||
import { mergeSharedTags } from '../lib/shared-tags';
|
import { mergeSharedTags } from '../lib/shared-tags';
|
||||||
import { useBackWhileFocused } from '../lib/use-back-while-focused';
|
import { useBackWhileFocused } from '../lib/use-back-while-focused';
|
||||||
import { listBackStep } from '../lib/list-back';
|
import { listBackStep } from '../lib/list-back';
|
||||||
@@ -196,17 +197,19 @@ const EmailRow = React.memo(function EmailRow({
|
|||||||
const unread = isUnread(item);
|
const unread = isUnread(item);
|
||||||
const starred = isStarred(item);
|
const starred = isStarred(item);
|
||||||
const pinned = isPinned(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(() => {
|
const tags = React.useMemo(() => {
|
||||||
if (!tagIds) return [];
|
if (!tagIds) return [];
|
||||||
return tagIds.split(',').map((id) => {
|
return tagIds.split(',').map((id) => {
|
||||||
const def = keywordDefs.find((k) => k.id === id);
|
const def = keywordDefs.find((k) => k.id === id);
|
||||||
return def
|
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,
|
// A tag no local definition explains (set by another client): grey,
|
||||||
// with the raw id so it is at least visible and removable.
|
// 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
|
// The row's key (`rowKeyOf`): ids repeat across the accounts of a list
|
||||||
// spanning accounts (#1082).
|
// 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;
|
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 {
|
export interface KeywordDef {
|
||||||
|
/** JMAP keyword suffix, `$label:<id>`; `parent/child` when nested. */
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
color: KeywordColor;
|
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
|
// Same ids/labels/colours as the webmail's DEFAULT_KEYWORDS
|
||||||
// (stores/settings-store.ts): the default tags are named after their colour,
|
// (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
|
// 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 {
|
interface KeywordsState {
|
||||||
keywords: KeywordDef[];
|
keywords: KeywordDef[];
|
||||||
hydrated: boolean;
|
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. */
|
/** Shared tag definitions of the group accounts, by JMAP account id. */
|
||||||
sharedTags: Record<string, SharedTagsEntry>;
|
sharedTags: Record<string, SharedTagsEntry>;
|
||||||
/**
|
/**
|
||||||
@@ -86,7 +104,7 @@ const sharedReadAt = new Map<string, number>();
|
|||||||
|
|
||||||
function sameTags(a: KeywordDef[], b: KeywordDef[]): boolean {
|
function sameTags(a: KeywordDef[], b: KeywordDef[]): boolean {
|
||||||
return a.length === b.length
|
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 {
|
function persist(keywords: KeywordDef[]): void {
|
||||||
@@ -98,8 +116,16 @@ function persist(keywords: KeywordDef[]): void {
|
|||||||
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
|
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
|
||||||
keywords: DEFAULT_KEYWORDS,
|
keywords: DEFAULT_KEYWORDS,
|
||||||
hydrated: false,
|
hydrated: false,
|
||||||
|
nested: false,
|
||||||
sharedTags: {},
|
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) => {
|
loadSharedTags: (accountId, options) => {
|
||||||
const login = currentTagsLogin();
|
const login = currentTagsLogin();
|
||||||
if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve();
|
if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve();
|
||||||
@@ -137,14 +163,19 @@ export const useKeywordsStore = create<KeywordsState>((set, get) => ({
|
|||||||
hydrate: async () => {
|
hydrate: async () => {
|
||||||
if (get().hydrated) return;
|
if (get().hydrated) return;
|
||||||
try {
|
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) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw) as KeywordDef[];
|
const parsed = JSON.parse(raw) as KeywordDef[];
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
set({ keywords: parsed, hydrated: true });
|
set({ keywords: parsed, nested, hydrated: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
set({ nested });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[keywords-store] hydrate failed', err);
|
console.warn('[keywords-store] hydrate failed', err);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user