feat: open the template manager from the composer's template picker

This commit is contained in:
Linus Rath
2026-10-31 04:00:30 +01:00
parent 9fd8099369
commit 2c1f266fba
2 changed files with 88 additions and 2 deletions
+67 -2
View File
@@ -3,7 +3,7 @@ import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
} from 'react-native'; } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { X, Star, FileText, Users } from './icons'; import { X, Star, FileText, Users, Settings, ArrowLeft } 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';
@@ -12,6 +12,7 @@ import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store
import { filterTemplates } from '../lib/template-utils'; import { filterTemplates } from '../lib/template-utils';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout'; import { useSheetInsets } from '../lib/use-layout';
import { TemplateSettings } from './settings/TemplateSettings';
interface TemplateSheetProps { interface TemplateSheetProps {
visible: boolean; visible: boolean;
@@ -24,6 +25,8 @@ interface TemplateSheetProps {
* (webmail template picker): favourites first, then the rest, with a search * (webmail template picker): favourites first, then the rest, with a search
* box over name/subject/category. The templates of the user's group * box over name/subject/category. The templates of the user's group
* mailboxes follow, each team under its name (Stalwart 1.0 and later). * mailboxes follow, each team under its name (Stalwart 1.0 and later).
* "Manage templates" opens the template manager in its place, and back
* returns to the list (webmail template picker, 19676a14f).
*/ */
export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) { export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) {
const c = useColors(); const c = useColors();
@@ -36,6 +39,8 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
const teams = useTemplatesStore((s) => s.teams); const teams = useTemplatesStore((s) => s.teams);
const refresh = useTemplatesStore((s) => s.refresh); const refresh = useTemplatesStore((s) => s.refresh);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
// The template manager, shown in place of the picker.
const [managing, setManaging] = React.useState(false);
const slideY = React.useRef(new Animated.Value(500)).current; const slideY = React.useRef(new Animated.Value(500)).current;
// Centred on a wide window, clear of side insets in landscape. // Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets(); const sheetInsets = useSheetInsets();
@@ -49,6 +54,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
React.useEffect(() => { React.useEffect(() => {
if (visible) { if (visible) {
setQuery(''); setQuery('');
setManaging(false);
// Templates changed in the webmail or on another device, and the team ones. // Templates changed in the webmail or on another device, and the team ones.
void refresh(); void refresh();
Animated.parallel([ Animated.parallel([
@@ -109,7 +115,15 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
); );
return ( return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent> <Modal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="none"
// Back leaves the manager for the list first.
onRequestClose={managing ? () => setManaging(false) : onClose}
statusBarTranslucent
>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}> <Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} /> <Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View> </Animated.View>
@@ -165,7 +179,36 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
</> </>
)} )}
</ScrollView> </ScrollView>
<Pressable
onPress={() => setManaging(true)}
style={({ pressed }) => [styles.manage, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<Settings size={16} color={c.textSecondary} />
<Text style={styles.manageText}>{t('templates.manage', 'Manage templates')}</Text>
</Pressable>
</Animated.View> </Animated.View>
{managing && (
<View style={[styles.manager, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
<View style={styles.managerHeader}>
<Pressable
onPress={() => setManaging(false)}
hitSlop={10}
style={styles.sheetClose}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.managerTitle} numberOfLines={1} accessibilityRole="header">
{t('settings.tabs.templates', 'Templates')}
</Text>
</View>
<ScrollView style={styles.scrollList} contentContainerStyle={styles.managerContent} keyboardShouldPersistTaps="handled">
<TemplateSettings />
</ScrollView>
</View>
)}
</Modal> </Modal>
); );
} }
@@ -173,6 +216,28 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
function makeStyles(c: ThemePalette) { function makeStyles(c: ThemePalette) {
return StyleSheet.create({ return StyleSheet.create({
sheetOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, sheetOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
manage: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
paddingHorizontal: spacing.lg,
borderTopWidth: 1,
borderTopColor: c.border,
},
manageText: { ...typography.body, color: c.textSecondary },
manager: { ...StyleSheet.absoluteFillObject, backgroundColor: c.background },
managerHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
managerTitle: { ...typography.h3, color: c.text, flex: 1 },
managerContent: { padding: spacing.lg },
sheetOverlayPress: { flex: 1 }, sheetOverlayPress: { flex: 1 },
sheet: { sheet: {
position: 'absolute', position: 'absolute',
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// The template picker in the composer (a component the node test environment
// can't render). With no templates it said "No templates yet" and offered
// no way on (webmail 19676a14f: "Manage" in the template picker).
const source = fs.readFileSync(path.resolve(__dirname, '../../components/TemplateSheet.tsx'), 'utf8');
describe('TemplateSheet', () => {
it('offers "Manage templates", which opens the template manager in place of the list', () => {
const manage = source.slice(source.indexOf("t('templates.manage'") - 400, source.indexOf("t('templates.manage'"));
expect(manage).toMatch(/onPress=\{\(\) => setManaging\(true\)\}/);
expect(source).toMatch(/\{managing && \([\s\S]*?<TemplateSettings \/>/);
});
it('leaves the manager for the list on back, and opens on the list', () => {
expect(source).toMatch(/onRequestClose=\{managing \? \(\) => setManaging\(false\) : onClose\}/);
expect(source).toMatch(/if \(visible\) \{\s+setQuery\(''\);\s+setManaging\(false\);/);
});
});