diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx new file mode 100644 index 00000000..7587e544 --- /dev/null +++ b/src/components/SwipeableRow.tsx @@ -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, { 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 ( + + + + {label} + + + ); + }; + + const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => { + widthRef.current = e.nativeEvent.layout.width; + }; + + return ( + + {renderBand(rightAction, 'left')} + {renderBand(leftAction, 'right')} + + {children} + + + ); +} + +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 }, +}); diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx new file mode 100644 index 00000000..4ff43f7f --- /dev/null +++ b/src/components/settings/ComposingSettings.tsx @@ -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 ( + + + + + + + + + + {attachmentReminderEnabled && ( + + Trigger keywords + + Words that, when found in the body or subject without an attachment, trigger the reminder. + + + + {attachmentReminderKeywords.map((kw) => ( + + {kw} + removeKeyword(kw)} hitSlop={6}> + + + + ))} + + + + + + + + )} + + ); +} + +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, + }, +}); diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx index a02f3f6c..3eca29af 100644 --- a/src/components/settings/KeywordSettings.tsx +++ b/src/components/settings/KeywordSettings.tsx @@ -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(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(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 ( diff --git a/src/components/settings/LayoutSettings.tsx b/src/components/settings/LayoutSettings.tsx new file mode 100644 index 00000000..da56bd0a --- /dev/null +++ b/src/components/settings/LayoutSettings.tsx @@ -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 ( + + + + + Swipe right (left → right) + + + Action when you drag a row from its left edge towards the right. + + setSwipeRightAction(v as SwipeAction)} + options={SWIPE_OPTIONS} + /> + + + + + + Swipe left (right → left) + + + Action when you drag a row from its right edge towards the left. + + setSwipeLeftAction(v as SwipeAction)} + options={SWIPE_OPTIONS} + /> + + + + + ); +} + +const styles = StyleSheet.create({ + row: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + rowLabel: { ...typography.bodyMedium, color: colors.text }, + rowDescription: { ...typography.caption, color: colors.mutedForeground }, +}); diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 3d0acd0d..06e9825f 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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>(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 }) => ( - + handleSwipeAction(item.id, action)} + > + + ), - [selectedIds, selectionMode, handleRowPress, toggleSelect], + [selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction], ); const clearSelection = React.useCallback(() => { diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 1ebfb85b..e2f5e68b 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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(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} /> + + setTagMenuOpen(false)} + keywords={keywordDefs} + activeKeywords={email?.keywords ?? {}} + onToggle={onToggleKeyword} + /> ); } @@ -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={} /> )} + {canTag && ( + } + label="Tag…" + onPress={onTag} + trailing={} + /> + )} {showSpam && ( void; + keywords: KeywordDef[]; + activeKeywords: Record; + 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 ( + + + + + + + + Tags + + + + + {keywords.length === 0 ? ( + + No tags yet. Add some in Settings → Keywords & Labels. + + ) : ( + keywords.map((kw) => { + const token = keywordToken(kw.id); + const active = !!activeKeywords[token]; + const palette = colors.tags[kw.color]; + return ( + } + label={kw.label} + onPress={() => onToggle(token)} + trailing={active ? : null} + /> + ); + }) + )} + + + ); +} + interface MoveMenuSheetProps { visible: boolean; onClose: () => void; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 6d642f63..ffd72b06 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -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>> = { language: LanguageSettings, notifications: NotificationSettings, appearance: AppearanceSettings, + layout: LayoutSettings, reading: ReadingSettings, + composing: ComposingSettings, identities: IdentitySettings, vacation: VacationSettings, filters: FilterSettings, diff --git a/src/stores/keywords-store.ts b/src/stores/keywords-store.ts new file mode 100644 index 00000000..00835f53 --- /dev/null +++ b/src/stores/keywords-store.ts @@ -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:`. + */ +export function keywordToken(id: string): string { + return `$label:${id}`; +} + +interface KeywordsState { + keywords: KeywordDef[]; + hydrated: boolean; + hydrate: () => Promise; + add: (kw: KeywordDef) => void; + update: (id: string, patch: Partial>) => 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((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); + }, +})); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 0644141f..0d2ce02a 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -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((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((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();