From e09cd395a2d66a44a7c64a1b66cd7553f7ca55c1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:16:56 +0100 Subject: [PATCH] feat: make a new tag from the tag picker and put it on the message right away --- src/components/NewTagRow.tsx | 100 ++++++++++++++++++++++++++++++ src/components/TagSheet.tsx | 2 + src/lib/__tests__/new-tag.test.ts | 39 ++++++++++++ src/lib/new-tag.ts | 54 ++++++++++++++++ src/screens/EmailThreadScreen.tsx | 27 ++++---- 5 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 src/components/NewTagRow.tsx create mode 100644 src/lib/__tests__/new-tag.test.ts create mode 100644 src/lib/new-tag.ts diff --git a/src/components/NewTagRow.tsx b/src/components/NewTagRow.tsx new file mode 100644 index 00000000..6043f72f --- /dev/null +++ b/src/components/NewTagRow.tsx @@ -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 ( + setCreating(true)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + testID="tag-picker-new" + > + + {t('email_viewer.new_tag', 'New tag')} + + ); + } + return ( + + + {errorText && {errorText}} + + ); +} + +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 }, + }); +} diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index 1cd7f105..7c08467f 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -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 ); })} + onToggle(token, true)} /> diff --git a/src/lib/__tests__/new-tag.test.ts b/src/lib/__tests__/new-tag.test.ts new file mode 100644 index 00000000..893b25c2 --- /dev/null +++ b/src/lib/__tests__/new-tag.test.ts @@ -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(/ onToggle\(token, true\)\} \/>/); + expect(src('screens/EmailThreadScreen.tsx')).toMatch(//); + const row = src('components/NewTagRow.tsx'); + expect(row).toMatch(/addKeyword\(def\);/); + expect(row).toMatch(/onCreated\(keywordToken\(def\.id\)\);/); + }); +}); diff --git a/src/lib/new-tag.ts b/src/lib/new-tag.ts new file mode 100644 index 00000000..b4e4d052 --- /dev/null +++ b/src/lib/new-tag.ts @@ -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[]): { 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 | 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] }; +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 4b0b166b..f4ef59e6 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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) => , 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 }: - {keywords.length === 0 ? ( - - {t('email_viewer.tag_no_matches', 'No matching tags')} - - ) : ( - // 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. - + {/* 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. */} + + {keywords.length === 0 && ( + + {t('email_viewer.tag_no_matches', 'No matching tags')} + + )} {keywords.map((kw) => { const token = keywordToken(kw.id); const active = !!activeKeywords[token]; @@ -1617,8 +1619,9 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: /> ); })} - - )} + {/* "New tag" (webmail 19676a14f): made, saved and put on the message. */} + + );