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. */}
+
+
);