mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: make a new tag from the tag picker and put it on the message right away
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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\)\);/);
|
||||
});
|
||||
});
|
||||
@@ -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,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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user