feat: implement keyword management and layout settings for email interactions

This commit is contained in:
Linus Rath
2026-04-27 00:16:25 +02:00
parent b36e065ebe
commit a95783723a
9 changed files with 670 additions and 35 deletions
+145
View File
@@ -0,0 +1,145 @@
import React, { useRef } from 'react';
import {
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, MailOpen, Mail, Star,
type LucideIcon,
} from 'lucide-react-native';
import { colors, typography } from '../theme/tokens';
import type { SwipeAction } from '../stores/settings-store';
interface SwipeableRowProps {
children: React.ReactNode;
/** Right-to-left swipe action (revealed under a leftward drag). */
leftAction: SwipeAction;
/** Left-to-right swipe action (revealed under a rightward drag). */
rightAction: SwipeAction;
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean };
onAction: (action: SwipeAction) => void;
}
const ACTIVATION_THRESHOLD = 0.4; // fraction of row width
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor to claim the gesture
const MIN_DX_TO_CLAIM = 6;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' },
star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' },
};
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean }): string {
if (action === 'read') return context.unread ? 'Read' : 'Unread';
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
if (action === 'none') return '';
return ACTION_META[action].defaultLabel;
}
export function SwipeableRow({
children, leftAction, rightAction, context, onAction,
}: SwipeableRowProps) {
const dx = useRef(new Animated.Value(0)).current;
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
const responder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
const max = widthRef.current;
const clamped = Math.max(-max, Math.min(max, g.dx));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const threshold = widthRef.current * ACTIVATION_THRESHOLD;
const action = g.dx > 0 ? rightAction : leftAction;
const triggered = Math.abs(g.dx) > threshold && action !== 'none';
if (triggered) {
Animated.timing(dx, {
toValue: g.dx > 0 ? widthRef.current : -widthRef.current,
duration: 160,
useNativeDriver: true,
}).start(() => {
onAction(action);
dx.setValue(0);
});
} else {
Animated.spring(dx, {
toValue: 0,
useNativeDriver: true,
speed: 24,
bounciness: 4,
}).start();
}
claimed.current = false;
},
onPanResponderTerminate: () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
).current;
const renderBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
return (
<View
style={[
styles.band,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg },
]}
pointerEvents="none"
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</View>
);
};
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
};
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderBand(rightAction, 'left')}
{renderBand(leftAction, 'right')}
<Animated.View style={{ transform: [{ translateX: dx }] }}>
{children}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
wrap: { position: 'relative' },
band: {
position: 'absolute',
top: 0,
bottom: 0,
width: '100%',
justifyContent: 'center',
paddingHorizontal: 24,
},
bandInner: { flexDirection: 'row', alignItems: 'center', gap: 8 },
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
});
@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { X, Plus } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useSettingsStore } from '../../stores/settings-store';
export function ComposingSettings() {
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
const setAttachmentReminderEnabled = useSettingsStore((s) => s.setAttachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const [newKeyword, setNewKeyword] = useState('');
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const addKeyword = () => {
const trimmed = newKeyword.trim().toLowerCase();
if (!trimmed || attachmentReminderKeywords.includes(trimmed)) {
setNewKeyword('');
return;
}
setAttachmentReminderKeywords([...attachmentReminderKeywords, trimmed]);
setNewKeyword('');
};
const removeKeyword = (kw: string) => {
setAttachmentReminderKeywords(attachmentReminderKeywords.filter((k) => k !== kw));
};
return (
<SettingsSection
title="Composing"
description="How the composer behaves when you reply, forward, or send."
>
<SettingItem
label="Auto-select reply identity"
description="When replying, pick the identity that received the original message."
>
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
<SettingItem
label="Attachment reminder"
description="Warn before sending if the message mentions an attachment but none is attached."
>
<ToggleSwitch
checked={attachmentReminderEnabled}
onChange={setAttachmentReminderEnabled}
/>
</SettingItem>
{attachmentReminderEnabled && (
<View style={styles.subBlock}>
<Text style={styles.subLabel}>Trigger keywords</Text>
<Text style={styles.subDescription}>
Words that, when found in the body or subject without an attachment, trigger the reminder.
</Text>
<View style={styles.chips}>
{attachmentReminderKeywords.map((kw) => (
<View key={kw} style={styles.chip}>
<Text style={styles.chipText}>{kw}</Text>
<Pressable onPress={() => removeKeyword(kw)} hitSlop={6}>
<X size={12} color={colors.textSecondary} />
</Pressable>
</View>
))}
</View>
<View style={styles.addRow}>
<TextInput
value={newKeyword}
onChangeText={setNewKeyword}
placeholder="e.g. attached"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
onSubmitEditing={addKeyword}
returnKeyType="done"
/>
<Button
variant="default"
size="sm"
onPress={addKeyword}
disabled={!newKeyword.trim()}
icon={<Plus size={14} color={colors.primaryForeground} />}
>
Add
</Button>
</View>
</View>
)}
</SettingsSection>
);
}
const styles = StyleSheet.create({
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: colors.text },
subDescription: { ...typography.caption, color: colors.mutedForeground },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: radius.full,
backgroundColor: colors.muted,
},
chipText: { ...typography.caption, color: colors.text },
addRow: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
input: {
flex: 1,
backgroundColor: colors.background,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: 8,
color: colors.text,
...typography.body,
},
});
+18 -22
View File
@@ -1,45 +1,41 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import { colors, spacing, radius, typography } from '../../theme/tokens';
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
interface Keyword {
id: string;
label: string;
color: keyof typeof colors.tags;
}
type Keyword = KeywordDef;
const PALETTE_KEYS = Object.keys(colors.tags) as (keyof typeof colors.tags)[];
const DEFAULT_KEYWORDS: Keyword[] = [
{ id: 'important', label: 'Important', color: 'red' },
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'personal', label: 'Personal', color: 'green' },
{ id: 'todo', label: 'Todo', color: 'amber' },
];
export function KeywordSettings() {
const [keywords, setKeywords] = useState<Keyword[]>(DEFAULT_KEYWORDS);
const keywords = useKeywordsStore((s) => s.keywords);
const addKeyword = useKeywordsStore((s) => s.add);
const updateKeyword = useKeywordsStore((s) => s.update);
const removeKeyword = useKeywordsStore((s) => s.remove);
const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
const hydrated = useKeywordsStore((s) => s.hydrated);
const hydrate = useKeywordsStore((s) => s.hydrate);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false);
const saveKeyword = (kw: Keyword, editing: boolean) => {
if (editing) {
setKeywords((ks) => ks.map((k) => k.id === editingId ? kw : k));
if (editing && editingId) {
const { id, ...patch } = kw;
updateKeyword(editingId, patch);
setEditingId(null);
} else {
setKeywords((ks) => [...ks, kw]);
addKeyword(kw);
setIsAdding(false);
}
};
const deleteKeyword = (id: string) => {
setKeywords((ks) => ks.filter((k) => k.id !== id));
};
const resetDefaults = () => {
setKeywords(DEFAULT_KEYWORDS);
removeKeyword(id);
};
return (
@@ -0,0 +1,71 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
import { colors, spacing, typography } from '../../theme/tokens';
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'archive', label: 'Archive' },
{ value: 'delete', label: 'Delete' },
{ value: 'spam', label: 'Spam' },
{ value: 'read', label: 'Read/Unread' },
{ value: 'star', label: 'Star' },
];
export function LayoutSettings() {
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
return (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
<View style={styles.row}>
<ArrowRight size={14} color={colors.mutedForeground} />
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
</View>
<Text style={styles.rowDescription}>
Action when you drag a row from its left edge towards the right.
</Text>
<RadioGroup
value={swipeRightAction}
onChange={(v) => setSwipeRightAction(v as SwipeAction)}
options={SWIPE_OPTIONS}
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<ArrowLeft size={14} color={colors.mutedForeground} />
<Text style={styles.rowLabel}>Swipe left (right → left)</Text>
</View>
<Text style={styles.rowDescription}>
Action when you drag a row from its right edge towards the left.
</Text>
<RadioGroup
value={swipeLeftAction}
onChange={(v) => setSwipeLeftAction(v as SwipeAction)}
options={SWIPE_OPTIONS}
/>
</View>
<SettingItem
label=""
description="Drag past about half a row to commit; lift earlier to cancel."
/>
</SettingsSection>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
rowLabel: { ...typography.bodyMedium, color: colors.text },
rowDescription: { ...typography.caption, color: colors.mutedForeground },
});
+67 -8
View File
@@ -9,7 +9,9 @@ import {
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import { useEmailStore, type EmailFilters } from '../stores/email-store';
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import type { Email } from '../api/types';
function getSenderName(email: Email): string {
@@ -152,6 +154,23 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const markUnread = useEmailStore((s) => s.markUnread);
const toggleStar = useEmailStore((s) => s.toggleStar);
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const archiveMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
[mailboxes],
);
const trashMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'trash')?.id ?? null,
[mailboxes],
);
const junkMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null,
[mailboxes],
);
// Selection state
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
@@ -187,17 +206,57 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}
}, [toggleSelect]);
const handleSwipeAction = React.useCallback((id: string, action: SwipeAction) => {
if (action === 'none') return;
const email = emailsRef.current.find((e) => e.id === id);
if (!email || !currentMailboxId) return;
switch (action) {
case 'archive':
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
void moveToMailboxAction(id, currentMailboxId, archiveMailboxId);
}
break;
case 'delete':
if (trashMailboxId) {
void deleteEmailAction(id, trashMailboxId, currentMailboxId);
}
break;
case 'spam':
if (junkMailboxId && currentMailboxId !== junkMailboxId) {
void moveToMailboxAction(id, currentMailboxId, junkMailboxId);
}
break;
case 'read':
if (isUnread(email)) void markRead(id);
else void markUnread(id);
break;
case 'star':
void toggleStar(id);
break;
}
}, [
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
moveToMailboxAction, deleteEmailAction, markRead, markUnread, toggleStar,
]);
const renderEmailRow = React.useCallback(
({ item }: { item: Email }) => (
<EmailRow
item={item}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={handleRowPress}
onLongPress={toggleSelect}
/>
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
rightAction={selectionMode ? 'none' : swipeRightAction}
context={{ unread: isUnread(item), starred: isStarred(item) }}
onAction={(action) => handleSwipeAction(item.id, action)}
>
<EmailRow
item={item}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={handleRowPress}
onLongPress={toggleSelect}
/>
</SwipeableRow>
),
[selectedIds, selectionMode, handleRowPress, toggleSelect],
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
);
const clearSelection = React.useCallback(() => {
+108 -3
View File
@@ -9,7 +9,7 @@ import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
FolderInput, ShieldAlert, ShieldCheck, X, Check,
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download,
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download, Tag,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
@@ -18,6 +18,7 @@ import SenderAvatar from '../components/SenderAvatar';
import { useEmailStore } from '../stores/email-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
@@ -86,6 +87,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const [error, setError] = React.useState<string | null>(null);
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
React.useEffect(() => {
let cancelled = false;
@@ -131,6 +137,15 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
setEmail((prev) => (prev ? { ...prev, keywords: next } : prev));
};
const onToggleKeyword = (token: string) => {
if (!email) return;
const next = { ...email.keywords };
if (next[token]) delete next[token];
else next[token] = true;
updateLocalKeywords(next);
void setEmailKeywords(email.id, next);
};
const onToggleStar = () => {
if (!email) return;
const next = { ...email.keywords };
@@ -394,9 +409,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
isInJunk={isInJunk}
canViewSource={!!email?.blobId}
canExport={!!email?.blobId}
canTag={keywordDefs.length > 0}
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
onTag={() => { setMoreMenuOpen(false); setTagMenuOpen(true); }}
onToggleSpam={onToggleSpam}
onViewSource={() => {
setMoreMenuOpen(false);
@@ -426,6 +443,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
currentMailboxId={currentMailboxId}
onPick={onMoveToMailbox}
/>
<TagMenuSheet
visible={tagMenuOpen}
onClose={() => setTagMenuOpen(false)}
keywords={keywordDefs}
activeKeywords={email?.keywords ?? {}}
onToggle={onToggleKeyword}
/>
</SafeAreaView>
);
}
@@ -437,6 +462,7 @@ interface MoreMenuSheetProps {
canArchive: boolean;
canMarkUnread: boolean;
canMove: boolean;
canTag: boolean;
showSpam: boolean;
isInJunk: boolean;
canViewSource: boolean;
@@ -444,15 +470,16 @@ interface MoreMenuSheetProps {
onArchive: () => void;
onToggleUnread: () => void;
onMove: () => void;
onTag: () => void;
onToggleSpam: () => void;
onViewSource: () => void;
onExport: () => void;
}
function MoreMenuSheet({
visible, onClose, unread, canArchive, canMarkUnread, canMove,
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
showSpam, isInJunk, canViewSource, canExport,
onArchive, onToggleUnread, onMove, onToggleSpam, onViewSource, onExport,
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onViewSource, onExport,
}: MoreMenuSheetProps) {
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(400)).current;
@@ -518,6 +545,14 @@ function MoreMenuSheet({
trailing={<ChevronRight size={16} color={colors.textMuted} />}
/>
)}
{canTag && (
<MoreMenuItem
icon={<Tag size={18} color={colors.textSecondary} />}
label="Tag…"
onPress={onTag}
trailing={<ChevronRight size={16} color={colors.textMuted} />}
/>
)}
{showSpam && (
<MoreMenuItem
icon={
@@ -550,6 +585,76 @@ function MoreMenuSheet({
);
}
interface TagMenuSheetProps {
visible: boolean;
onClose: () => void;
keywords: KeywordDef[];
activeKeywords: Record<string, boolean>;
onToggle: (token: string) => void;
}
function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) {
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View style={styles.sheetHandle} />
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Tags</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={colors.textSecondary} />
</Pressable>
</View>
{keywords.length === 0 ? (
<Text style={{ ...typography.body, color: colors.textMuted, paddingVertical: spacing.lg, paddingHorizontal: spacing.lg }}>
No tags yet. Add some in Settings → Keywords & Labels.
</Text>
) : (
keywords.map((kw) => {
const token = keywordToken(kw.id);
const active = !!activeKeywords[token];
const palette = colors.tags[kw.color];
return (
<MoreMenuItem
key={kw.id}
icon={<View style={{ width: 18, height: 18, borderRadius: 9, backgroundColor: palette.dot }} />}
label={kw.label}
onPress={() => onToggle(token)}
trailing={active ? <Check size={16} color={colors.primary} /> : null}
/>
);
})
)}
</Animated.View>
</Modal>
);
}
interface MoveMenuSheetProps {
visible: boolean;
onClose: () => void;
+6 -2
View File
@@ -32,6 +32,8 @@ import { ThemesSettings } from '../components/settings/ThemesSettings';
import { PluginsSettings } from '../components/settings/PluginsSettings';
import { ContentSendersSettings } from '../components/settings/ContentSendersSettings';
import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
type Tab =
| 'account' | 'language' | 'notifications'
@@ -72,11 +74,11 @@ const TABS: TabDef[] = [
// Appearance
{ id: 'appearance', label: 'Appearance', icon: Palette, group: 'appearance', implemented: true },
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: false },
{ id: 'layout', label: 'Layout', icon: LayoutGrid, group: 'appearance', implemented: true },
// Mail
{ id: 'reading', label: 'Reading', icon: BookOpen, group: 'mail', implemented: true },
{ id: 'composing', label: 'Composing', icon: PenLine, group: 'mail', implemented: false },
{ id: 'composing', label: 'Composing', icon: PenLine, group: 'mail', implemented: true },
{ id: 'identities', label: 'Identities', icon: UserPen, group: 'mail', implemented: true },
{ id: 'vacation', label: 'Vacation Responder', icon: Palmtree, group: 'mail', implemented: true },
{ id: 'filters', label: 'Filters & Rules', icon: Filter, group: 'mail', implemented: true },
@@ -108,7 +110,9 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
language: LanguageSettings,
notifications: NotificationSettings,
appearance: AppearanceSettings,
layout: LayoutSettings,
reading: ReadingSettings,
composing: ComposingSettings,
identities: IdentitySettings,
vacation: VacationSettings,
filters: FilterSettings,
+89
View File
@@ -0,0 +1,89 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors } from '../theme/tokens';
const STORAGE_KEY = 'webmail:keywords:v1';
export type KeywordColor = keyof typeof colors.tags;
export interface KeywordDef {
id: string;
label: string;
color: KeywordColor;
}
const DEFAULT_KEYWORDS: KeywordDef[] = [
{ id: 'important', label: 'Important', color: 'red' },
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'personal', label: 'Personal', color: 'green' },
{ id: 'todo', label: 'Todo', color: 'amber' },
];
/**
* JMAP keyword token used on emails for a given keyword id.
* Matches webmail convention: `$label:<id>`.
*/
export function keywordToken(id: string): string {
return `$label:${id}`;
}
interface KeywordsState {
keywords: KeywordDef[];
hydrated: boolean;
hydrate: () => Promise<void>;
add: (kw: KeywordDef) => void;
update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void;
remove: (id: string) => void;
resetDefaults: () => void;
}
function persist(keywords: KeywordDef[]): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keywords)).catch((err) => {
console.warn('[keywords-store] persist failed', err);
});
}
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
keywords: DEFAULT_KEYWORDS,
hydrated: false,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as KeywordDef[];
if (Array.isArray(parsed)) {
set({ keywords: parsed, hydrated: true });
return;
}
}
} catch (err) {
console.warn('[keywords-store] hydrate failed', err);
}
set({ hydrated: true });
},
add: (kw) => {
const next = [...get().keywords, kw];
set({ keywords: next });
persist(next);
},
update: (id, patch) => {
const next = get().keywords.map((k) => (k.id === id ? { ...k, ...patch } : k));
set({ keywords: next });
persist(next);
},
remove: (id) => {
const next = get().keywords.filter((k) => k.id !== id);
set({ keywords: next });
persist(next);
},
resetDefaults: () => {
set({ keywords: DEFAULT_KEYWORDS });
persist(DEFAULT_KEYWORDS);
},
}));
+34
View File
@@ -10,6 +10,8 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
const STORAGE_KEY = 'webmail:settings:v1';
export type SwipeAction = 'none' | 'archive' | 'delete' | 'spam' | 'read' | 'star';
interface PersistedSettings {
externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[];
@@ -22,6 +24,13 @@ interface PersistedSettings {
showToolbarLabels: boolean;
animationsEnabled: boolean;
emailAlwaysLightMode: boolean;
// Composing
autoSelectReplyIdentity: boolean;
attachmentReminderEnabled: boolean;
attachmentReminderKeywords: string[];
// Layout / list interactions
swipeLeftAction: SwipeAction;
swipeRightAction: SwipeAction;
}
const DEFAULT_PERSISTED: PersistedSettings = {
@@ -35,6 +44,11 @@ const DEFAULT_PERSISTED: PersistedSettings = {
showToolbarLabels: true,
animationsEnabled: true,
emailAlwaysLightMode: false,
autoSelectReplyIdentity: true,
attachmentReminderEnabled: true,
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
swipeLeftAction: 'archive',
swipeRightAction: 'read',
};
export interface SettingsState extends PersistedSettings {
@@ -54,6 +68,11 @@ export interface SettingsState extends PersistedSettings {
setShowToolbarLabels: (enabled: boolean) => void;
setAnimationsEnabled: (enabled: boolean) => void;
setEmailAlwaysLightMode: (enabled: boolean) => void;
setAutoSelectReplyIdentity: (enabled: boolean) => void;
setAttachmentReminderEnabled: (enabled: boolean) => void;
setAttachmentReminderKeywords: (keywords: string[]) => void;
setSwipeLeftAction: (action: SwipeAction) => void;
setSwipeRightAction: (action: SwipeAction) => void;
addTrustedSender: (email: string) => void;
removeTrustedSender: (email: string) => void;
isSenderTrusted: (email: string) => boolean;
@@ -72,6 +91,11 @@ function snapshot(state: SettingsState): PersistedSettings {
showToolbarLabels: state.showToolbarLabels,
animationsEnabled: state.animationsEnabled,
emailAlwaysLightMode: state.emailAlwaysLightMode,
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
attachmentReminderEnabled: state.attachmentReminderEnabled,
attachmentReminderKeywords: state.attachmentReminderKeywords,
swipeLeftAction: state.swipeLeftAction,
swipeRightAction: state.swipeRightAction,
};
}
@@ -115,6 +139,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
showToolbarLabels: typeof parsed.showToolbarLabels === 'boolean' ? parsed.showToolbarLabels : DEFAULT_PERSISTED.showToolbarLabels,
animationsEnabled: typeof parsed.animationsEnabled === 'boolean' ? parsed.animationsEnabled : DEFAULT_PERSISTED.animationsEnabled,
emailAlwaysLightMode: typeof parsed.emailAlwaysLightMode === 'boolean' ? parsed.emailAlwaysLightMode : DEFAULT_PERSISTED.emailAlwaysLightMode,
autoSelectReplyIdentity: typeof parsed.autoSelectReplyIdentity === 'boolean' ? parsed.autoSelectReplyIdentity : DEFAULT_PERSISTED.autoSelectReplyIdentity,
attachmentReminderEnabled: typeof parsed.attachmentReminderEnabled === 'boolean' ? parsed.attachmentReminderEnabled : DEFAULT_PERSISTED.attachmentReminderEnabled,
attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords,
swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction,
swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction,
hydrated: true,
});
return;
@@ -134,6 +163,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setShowToolbarLabels: (enabled) => { set({ showToolbarLabels: enabled }); persist(snapshot(get())); },
setAnimationsEnabled: (enabled) => { set({ animationsEnabled: enabled }); persist(snapshot(get())); },
setEmailAlwaysLightMode: (enabled) => { set({ emailAlwaysLightMode: enabled }); persist(snapshot(get())); },
setAutoSelectReplyIdentity: (enabled) => { set({ autoSelectReplyIdentity: enabled }); persist(snapshot(get())); },
setAttachmentReminderEnabled: (enabled) => { set({ attachmentReminderEnabled: enabled }); persist(snapshot(get())); },
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); },
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); },
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); },
addTrustedSender: (email) => {
const normalized = email.toLowerCase().trim();