feat: make a new tag from the tag picker and put it on the message right away

This commit is contained in:
Linus Rath
2026-10-31 04:16:56 +01:00
parent 9a4515f905
commit e09cd395a2
5 changed files with 210 additions and 12 deletions
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { Plus } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
import { keywordToken, useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
import { checkNewTag, newTagDefinition } from '../lib/new-tag';
/**
* "New tag" at the end of a tag picker: a name field; the tag gets the next
* colour of the palette, is saved with the other tags and goes onto the
* message(s) straight away (webmail components/email/tag-picker.tsx).
*/
export function NewTagRow({ keywords, onCreated }: {
/** The tags the picker lists: a new id must not repeat one of them. */
keywords: KeywordDef[];
/** Apply the new tag's keyword token. */
onCreated: (token: string) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const addKeyword = useKeywordsStore((s) => s.add);
const [creating, setCreating] = React.useState(false);
const [name, setName] = React.useState('');
const { error } = checkNewTag(name, keywords);
const errorText = error === 'tag_exists'
? t('email_viewer.tag_exists', 'A tag with this name already exists')
: error === 'tag_name_invalid' ? t('email_viewer.tag_name_invalid', 'Choose a shorter name') : null;
const create = () => {
const def = newTagDefinition(name, keywords);
if (!def) return;
addKeyword(def);
setCreating(false);
setName('');
onCreated(keywordToken(def.id));
};
if (!creating) {
return (
<Pressable
onPress={() => setCreating(true)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
testID="tag-picker-new"
>
<Plus size={16} color={c.textMuted} />
<Text style={styles.label}>{t('email_viewer.new_tag', 'New tag')}</Text>
</Pressable>
);
}
return (
<View style={styles.form}>
<TextInput
autoFocus
value={name}
onChangeText={setName}
onSubmitEditing={create}
returnKeyType="done"
placeholder={t('email_viewer.new_tag_placeholder', 'Tag name')}
placeholderTextColor={c.textMuted}
accessibilityLabel={t('email_viewer.new_tag_placeholder', 'Tag name')}
style={[styles.input, errorText ? styles.inputError : null]}
/>
{errorText && <Text style={styles.error} accessibilityRole="alert">{errorText}</Text>}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: c.border,
},
rowPressed: { backgroundColor: c.surfaceHover },
label: { ...typography.body, color: c.textSecondary, flex: 1 },
form: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.xs },
input: {
...typography.body,
color: c.text,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
minHeight: 40,
},
inputError: { borderColor: c.error },
error: { ...typography.caption, color: c.error },
});
}
+2
View File
@@ -13,6 +13,7 @@ import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
import { NewTagRow } from './NewTagRow';
interface TagSheetProps {
visible: boolean;
@@ -132,6 +133,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
</Pressable>
);
})}
<NewTagRow keywords={keywords} onCreated={(token) => onToggle(token, true)} />
</ScrollView>
</Animated.View>
</Modal>
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { checkNewTag, composeKeywordId, MAX_KEYWORD_ID_LENGTH, newTagDefinition } from '../new-tag';
import { DEFAULT_KEYWORDS } from '../../stores/keywords-store';
// The tag pickers listed existing tags only; a new one meant a trip to
// Settings (webmail tag-picker.tsx "New tag", 19676a14f).
describe('new tag from the tag picker', () => {
it('makes the id the way the webmail does', () => {
expect(composeKeywordId(null, ' Client Acme / 2026! ')).toBe('client-acme-2026');
expect(composeKeywordId('work', 'Clients')).toBe('work/clients');
expect(composeKeywordId(null, '!!!')).toBe('');
});
it('refuses an existing id and a name with no id or too long a one', () => {
expect(checkNewTag('Red', DEFAULT_KEYWORDS).error).toBe('tag_exists');
expect(checkNewTag('???', DEFAULT_KEYWORDS).error).toBe('tag_name_invalid');
expect(checkNewTag('x'.repeat(MAX_KEYWORD_ID_LENGTH + 1), DEFAULT_KEYWORDS).error).toBe('tag_name_invalid');
expect(checkNewTag('', DEFAULT_KEYWORDS).error).toBeNull();
expect(newTagDefinition('Red', DEFAULT_KEYWORDS)).toBeNull();
});
it('gives the new tag the next colour of the palette row', () => {
// Seven default tags: index 7 of red, orange, amber, yellow, lime, green, teal, cyan…
expect(newTagDefinition(' Invoices ', DEFAULT_KEYWORDS)).toEqual({ id: 'invoices', label: 'Invoices', color: 'cyan' });
expect(newTagDefinition('First', [])?.color).toBe('red');
});
it('is offered in the list tag sheet and the message tag sheet, and applies the tag', () => {
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8');
expect(src('components/TagSheet.tsx')).toMatch(/<NewTagRow keywords=\{keywords\} onCreated=\{\(token\) => onToggle\(token, true\)\} \/>/);
expect(src('screens/EmailThreadScreen.tsx')).toMatch(/<NewTagRow keywords=\{keywords\} onCreated=\{onToggle\} \/>/);
const row = src('components/NewTagRow.tsx');
expect(row).toMatch(/addKeyword\(def\);/);
expect(row).toMatch(/onCreated\(keywordToken\(def\.id\)\);/);
});
});
+54
View File
@@ -0,0 +1,54 @@
import type { KeywordColor, KeywordDef } from '../stores/keywords-store';
// "New tag" in the tag pickers (webmail components/email/tag-picker.tsx,
// 19676a14f; ids from lib/keyword-nesting.ts).
/**
* Longest keyword Stalwart keeps (1.0 refuses a longer one, 0.16 cuts it),
* less the `$label:` prefix.
*/
export const MAX_KEYWORD_ID_LENGTH = 128 - '$label:'.length;
/** Palette row 1 of the webmail's tag colours: a new tag takes the next one. */
export const NEW_TAG_PALETTE: KeywordColor[] = [
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray',
];
/**
* One level of a tag id from a display name: lowercase, everything outside
* `[a-z0-9_-]` folded to a single dash (a slash typed into the name is part
* of the name, not a level).
*/
export function normalizeKeywordLevel(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/** The id a tag named `name` gets under `parentId` (null = top level). */
export function composeKeywordId(parentId: string | null, name: string): string {
const level = normalizeKeywordLevel(name);
if (!parentId || !level) return level;
return `${parentId}/${level}`;
}
export type NewTagError = 'tag_exists' | 'tag_name_invalid';
/** What a typed tag name would make: its id, and why it can't be made (null = it can). */
export function checkNewTag(name: string, keywords: readonly Pick<KeywordDef, 'id'>[]): { id: string; error: NewTagError | null } {
const id = composeKeywordId(null, name);
if (!name.trim()) return { id, error: null };
if (keywords.some((k) => k.id === id)) return { id, error: 'tag_exists' };
if (!id || id.length > MAX_KEYWORD_ID_LENGTH) return { id, error: 'tag_name_invalid' };
return { id, error: null };
}
/** The definition for a new tag, or null when the name can't make one. */
export function newTagDefinition(name: string, keywords: readonly Pick<KeywordDef, 'id'>[]): KeywordDef | null {
const { id, error } = checkNewTag(name, keywords);
if (!name.trim() || error) return null;
return { id, label: name.trim(), color: NEW_TAG_PALETTE[keywords.length % NEW_TAG_PALETTE.length] };
}
+15 -12
View File
@@ -15,6 +15,7 @@ import {
} from '../components/icons';
import { pickEmailBody } from '../lib/email-body';
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
import { NewTagRow } from '../components/NewTagRow';
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
import { isSieveSupported } from '../api/sieve';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
@@ -693,7 +694,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
label: t('email_viewer.tag', 'Tag'),
icon: (s, col) => <Tag size={s} color={col} />,
onPress: () => setTagMenuOpen(true),
available: keywordDefs.length > 0,
// Always: the tag sheet can make a new tag.
available: true,
},
};
@@ -898,7 +900,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
isInJunk={isInJunk}
canViewSource={!!email?.blobId}
canExport={!!email?.blobId}
canTag={keywordDefs.length > 0}
canTag
renderDark={activeRenderDark}
plainView={activeHasHtml ? activePlainView : null}
onTogglePlainView={() => {
@@ -1595,14 +1597,14 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
</Pressable>
</View>
</View>
{keywords.length === 0 ? (
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
{t('email_viewer.tag_no_matches', 'No matching tags')}
</Text>
) : (
// Scrolls like the More sheet: a long tag list (or a landscape
// window) ran past the screen with no way to reach the last tags.
<ScrollView style={styles.sheetScroll} bounces={false}>
{/* Scrolls like the More sheet: a long tag list (or a landscape
window) ran past the screen with no way to reach the last tags. */}
<ScrollView style={styles.sheetScroll} bounces={false} keyboardShouldPersistTaps="handled">
{keywords.length === 0 && (
<Text style={{ ...typography.body, color: c.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
{t('email_viewer.tag_no_matches', 'No matching tags')}
</Text>
)}
{keywords.map((kw) => {
const token = keywordToken(kw.id);
const active = !!activeKeywords[token];
@@ -1617,8 +1619,9 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
/>
);
})}
</ScrollView>
)}
{/* "New tag" (webmail 19676a14f): made, saved and put on the message. */}
<NewTagRow keywords={keywords} onCreated={onToggle} />
</ScrollView>
</Animated.View>
</Modal>
);