From 329c8a2f0b997c8733422c7d0bd2e613e7715f44 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 2 May 2026 14:27:15 +0200 Subject: [PATCH] feat: add swipe actions for pinning and moving emails; enhance archive functionality --- src/api/email.ts | 119 +++++++++++- src/components/MoveSheet.tsx | 199 ++++++++++++++++++++ src/components/SwipeableRow.tsx | 162 +++++++++------- src/components/settings/LayoutSettings.tsx | 4 +- src/components/settings/ReadingSettings.tsx | 98 +++++++++- src/lib/push-notifications.ts | 22 ++- src/screens/EmailListScreen.tsx | 37 +++- src/screens/EmailThreadScreen.tsx | 143 +------------- src/stores/__tests__/email-store.test.ts | 8 + src/stores/email-store.ts | 50 +++++ src/stores/settings-store.ts | 18 +- 11 files changed, 634 insertions(+), 226 deletions(-) create mode 100644 src/components/MoveSheet.tsx diff --git a/src/api/email.ts b/src/api/email.ts index 8a04d7a3..093604ea 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1,6 +1,6 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; -import type { Email, EmailAddress, Mailbox, Thread } from './types'; +import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types'; import { toWildcardQuery } from '../lib/search-utils'; const EMAIL_LIST_PROPERTIES = [ @@ -111,6 +111,123 @@ export async function moveEmail( ]); } +// Archive one or more emails into the archive mailbox, optionally auto-sorting +// into year or year/month subfolders. Mirrors the webmail implementation in +// lib/jmap/client.ts so behavior stays in sync between platforms. +export async function archiveEmails( + emails: Array<{ id: string; receivedAt: string }>, + archiveMailboxId: string, + mode: 'single' | 'year' | 'month', + existingMailboxes: Mailbox[], +): Promise { + if (emails.length === 0) return; + const accountId = jmapClient.accountId; + + if (mode === 'single') { + const updates = Object.fromEntries( + emails.map((e) => [e.id, { mailboxIds: { [archiveMailboxId]: true } }]), + ); + await jmapClient.request([ + ['Email/set', { accountId, update: updates }, '0'], + ]); + return; + } + + type Dest = { year: string; month?: string }; + const destFor = new Map(); + for (const e of emails) { + const d = new Date(e.receivedAt); + const year = d.getFullYear().toString(); + const month = (d.getMonth() + 1).toString().padStart(2, '0'); + destFor.set(e.id, mode === 'year' ? { year } : { year, month }); + } + + // Resolve each destination folder to either an existing id or a creation-id reference ("#"). + const yearIdFor = new Map(); + const monthIdFor = new Map(); + const createEntries: Record> = {}; + + const findExisting = (name: string, parentId: string) => + existingMailboxes.find( + (m) => m.name === name && m.parentId === parentId, + ); + + for (const dest of destFor.values()) { + if (!yearIdFor.has(dest.year)) { + const existing = findExisting(dest.year, archiveMailboxId); + if (existing) { + yearIdFor.set(dest.year, existing.id); + } else { + const cid = `year-${dest.year}`; + createEntries[cid] = { name: dest.year, parentId: archiveMailboxId }; + yearIdFor.set(dest.year, `#${cid}`); + } + } + + if (mode === 'month' && dest.month) { + const monthKey = `${dest.year}/${dest.month}`; + if (!monthIdFor.has(monthKey)) { + const yearRef = yearIdFor.get(dest.year)!; + const existingMonth = yearRef.startsWith('#') + ? undefined + : findExisting(dest.month, yearRef); + if (existingMonth) { + monthIdFor.set(monthKey, existingMonth.id); + } else { + const cid = `month-${dest.year}-${dest.month}`; + createEntries[cid] = { name: dest.month, parentId: yearRef }; + monthIdFor.set(monthKey, `#${cid}`); + } + } + } + } + + const updates: Record }> = {}; + for (const [emailId, dest] of destFor.entries()) { + const destId = mode === 'month' && dest.month + ? monthIdFor.get(`${dest.year}/${dest.month}`)! + : yearIdFor.get(dest.year)!; + updates[emailId] = { mailboxIds: { [destId]: true } }; + } + + const methodCalls: JMAPMethodCall[] = []; + const hasCreates = Object.keys(createEntries).length > 0; + if (hasCreates) { + methodCalls.push(['Mailbox/set', { accountId, create: createEntries }, '0']); + } + methodCalls.push(['Email/set', { accountId, update: updates }, String(methodCalls.length)]); + + const response = await jmapClient.request(methodCalls); + + if (hasCreates) { + const mailboxResult = response.methodResponses?.[0]?.[1]; + const notCreated = mailboxResult?.notCreated as + | Record + | undefined; + const failures = notCreated ? Object.entries(notCreated) : []; + if (failures.length > 0) { + const [cid, err] = failures[0]; + const parts = [err.type || 'unknown']; + if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`); + if (err.description) parts.push(err.description); + throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`); + } + } + + const emailIdx = hasCreates ? 1 : 0; + const emailResult = response.methodResponses?.[emailIdx]?.[1]; + const notUpdated = emailResult?.notUpdated as + | Record + | undefined; + const emailFailures = notUpdated ? Object.entries(notUpdated) : []; + if (emailFailures.length > 0) { + const [id, err] = emailFailures[0]; + throw new Error( + `Failed to archive ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`, + ); + } +} + export async function deleteEmail( emailId: string, trashMailboxId: string, diff --git a/src/components/MoveSheet.tsx b/src/components/MoveSheet.tsx new file mode 100644 index 00000000..5381389f --- /dev/null +++ b/src/components/MoveSheet.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { + View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder, + X, Check, type LucideIcon, +} from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; +import { useColors } from '../theme/colors'; +import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree'; +import { useSheetDrag } from '../lib/use-sheet-drag'; +import type { Mailbox } from '../api/types'; + +function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon { + const lower = name.toLowerCase(); + if (role === 'inbox' || lower.includes('inbox')) return Inbox; + if (role === 'sent' || lower.includes('sent')) return Send; + if (role === 'drafts' || lower.includes('draft')) return FileIcon; + if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2; + if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban; + if (role === 'archive' || lower.includes('archive')) return Archive; + return Folder; +} + +interface MoveSheetProps { + visible: boolean; + onClose: () => void; + mailboxes: Mailbox[]; + /** Folder the email is currently in - shown with a check, not selectable. */ + currentMailboxId: string | null; + onPick: (id: string) => void; +} + +export function MoveSheet({ + visible, onClose, mailboxes, currentMailboxId, onPick, +}: MoveSheetProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + const slideY = React.useRef(new Animated.Value(500)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); + + 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]); + + const visibleNodes = React.useMemo(() => { + const tree = buildMailboxTree(mailboxes); + const expanded = new Set(); + const collect = (nodes: MailboxNode[]) => { + for (const n of nodes) { + if (n.children.length > 0) { + expanded.add(n.id); + collect(n.children); + } + } + }; + collect(tree); + return flattenVisible(tree, expanded); + }, [mailboxes]); + + return ( + + + + + + + + + + + Move to folder + + + + + + + {visibleNodes.map((node) => { + const Icon = moveTargetIcon(node.role, node.name); + const isCurrent = node.id === currentMailboxId; + const canTarget = node.myRights?.mayAddItems !== false && !isCurrent; + return ( + onPick(node.id) : undefined} + disabled={!canTarget} + style={({ pressed }) => [ + styles.moveRow, + pressed && canTarget && styles.moveRowPressed, + { paddingLeft: spacing.lg + node.depth * 16 }, + ]} + > + + + {node.name} + + {isCurrent && } + + ); + })} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + sheetOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + sheetOverlayPress: { flex: 1 }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '75%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + paddingTop: spacing.sm, + }, + sheetHandleHit: { + alignItems: 'center', + paddingTop: spacing.xs, + paddingBottom: spacing.sm, + }, + sheetHandle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: c.border, + }, + sheetHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + sheetTitle: { + ...typography.bodySemibold, + color: c.text, + }, + sheetClose: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.xs, + }, + moveRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingRight: spacing.lg, + paddingVertical: spacing.sm + 2, + minHeight: 44, + }, + moveRowPressed: { backgroundColor: c.surfaceHover }, + moveRowLabel: { + ...typography.body, + color: c.text, + flex: 1, + }, + moveRowLabelDisabled: { + color: c.textMuted, + }, + }); +} diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index 295c50a4..134b3d9b 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -1,9 +1,9 @@ -import React, { useRef, useState } from 'react'; +import React, { useRef } from 'react'; import { - View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions, + View, Text, StyleSheet, Animated, PanResponder, Dimensions, } from 'react-native'; import { - Archive, Trash2, ShieldAlert, MailOpen, Mail, Star, + Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput, type LucideIcon, } from 'lucide-react-native'; import { typography, type ThemePalette } from '../theme/tokens'; @@ -17,15 +17,18 @@ interface SwipeableRowProps { /** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */ rightAction: SwipeAction; /** Pass the row state used to compute action labels (e.g. unread/starred toggling). */ - context: { unread: boolean; starred: boolean }; + context: { unread: boolean; starred: boolean; pinned: boolean }; onAction: (action: SwipeAction) => void; } -const REVEAL_WIDTH = 88; // px shown when fully revealed -const ACTIVATION_THRESHOLD = 32; // px past which release snaps open instead of closing +// Distance (px) of horizontal travel needed to commit the action on release. +// Drag less than this and the row snaps back without firing. +const COMMIT_THRESHOLD = 96; const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor const MIN_DX_TO_CLAIM = 6; -const MAX_DRAG_OVERSHOOT = REVEAL_WIDTH * 1.4; +const MAX_DRAG_OVERSHOOT = 240; +// Distance the row flies off-screen by before the action callback fires. +const EXIT_DISTANCE = 600; const ACTION_META: Record, { icon: LucideIcon; bg: string; defaultLabel: string }> = { archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' }, @@ -33,15 +36,24 @@ const ACTION_META: Record, { icon: LucideIcon; bg: spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' }, read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' }, star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' }, + pin: { icon: Pin, bg: '#7c3aed', defaultLabel: 'Pin' }, + move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' }, }; -function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean }): string { +function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string { if (action === 'read') return context.unread ? 'Read' : 'Unread'; if (action === 'star') return context.starred ? 'Unstar' : 'Star'; + if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin'; if (action === 'none') return ''; return ACTION_META[action].defaultLabel; } +// Actions that visibly remove the row from the list (so the row should fly off +// instead of snapping back). Toggle-style actions stay in place. +function exitsRow(action: SwipeAction): boolean { + return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move'; +} + export function SwipeableRow({ children, leftAction, rightAction, context, onAction, }: SwipeableRowProps) { @@ -50,25 +62,31 @@ export function SwipeableRow({ const dx = useRef(new Animated.Value(0)).current; const claimed = useRef(false); const widthRef = useRef(Dimensions.get('window').width); - const openSideRef = useRef<'left' | 'right' | null>(null); - const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null); - const close = () => { + const snapBack = () => { Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); - openSideRef.current = null; - setOpenSide(null); }; - const openRight = () => { - Animated.spring(dx, { toValue: REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); - openSideRef.current = 'right'; - setOpenSide('right'); - }; - - const openLeft = () => { - Animated.spring(dx, { toValue: -REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); - openSideRef.current = 'left'; - setOpenSide('left'); + const fly = (toValue: number, action: SwipeAction) => { + // For destructive/move actions: race the row off-screen and fire the + // action - the parent will remove the row from the list. For toggle + // actions: fire immediately and snap back so the same row can update in + // place. + if (exitsRow(action)) { + Animated.timing(dx, { + toValue, + duration: 180, + useNativeDriver: true, + }).start(() => { + onAction(action); + // The row is about to be removed; reset translation in case it isn't + // (e.g. the action failed silently) so we don't leave it off-screen. + dx.setValue(0); + }); + } else { + onAction(action); + snapBack(); + } }; const responder = useRef( @@ -77,65 +95,59 @@ export function SwipeableRow({ 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 a side is already open, allow the gesture so the user can drag it closed. - if (openSideRef.current === null) { - if (g.dx > 0 && rightAction === 'none') return false; - if (g.dx < 0 && leftAction === 'none') 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 base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0; - const next = base + g.dx; - const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, next)); + const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, g.dx)); dx.setValue(clamped); }, onPanResponderRelease: (_, g) => { - const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0; - const finalDx = base + g.dx; - - if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') { - openRight(); - } else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') { - openLeft(); - } else { - close(); - } claimed.current = false; + if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') { + fly(widthRef.current || EXIT_DISTANCE, rightAction); + } else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') { + fly(-(widthRef.current || EXIT_DISTANCE), leftAction); + } else { + snapBack(); + } }, onPanResponderTerminate: () => { - close(); + snapBack(); claimed.current = false; }, onPanResponderTerminationRequest: () => !claimed.current, }), ).current; - const fireAction = (action: SwipeAction) => { - close(); - if (action !== 'none') onAction(action); - }; - 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); + // Band stretches to fill the gap behind the row as it's dragged. We + // animate the icon a touch to signal commit-readiness. + const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0]; + const iconScale = dx.interpolate({ + inputRange, + outputRange: side === 'left' ? [0.85, 1.15] : [1.15, 0.85], + extrapolate: 'clamp', + }); return ( - fireAction(action)} + - - + + {label} - - + + ); }; @@ -143,20 +155,29 @@ export function SwipeableRow({ widthRef.current = e.nativeEvent.layout.width; }; + // Show the matching band based on current drag direction. Both bands occupy + // the full row, so we fade the inactive one out to avoid bleed-through. + const rightBandOpacity = dx.interpolate({ + inputRange: [-1, 0, 1], + outputRange: [0, 0, 1], + extrapolate: 'clamp', + }); + const leftBandOpacity = dx.interpolate({ + inputRange: [-1, 0, 1], + outputRange: [1, 0, 0], + extrapolate: 'clamp', + }); + return ( - {renderBand(rightAction, 'left')} - {renderBand(leftAction, 'right')} + + {renderBand(rightAction, 'left')} + + + {renderBand(leftAction, 'right')} + {children} - {/* When an action is revealed, an overlay absorbs taps on the row so a - tap closes the swipe instead of opening the email. */} - {openSide ? ( - - ) : null} ); @@ -164,14 +185,13 @@ export function SwipeableRow({ function makeStyles(c: ThemePalette) { return StyleSheet.create({ - wrap: { position: 'relative', backgroundColor: c.background }, + wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' }, content: { backgroundColor: c.background }, band: { - position: 'absolute', - top: 0, - bottom: 0, - justifyContent: 'center', - paddingHorizontal: 16, + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 24, }, bandInner: { alignItems: 'center', gap: 4 }, bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' }, diff --git a/src/components/settings/LayoutSettings.tsx b/src/components/settings/LayoutSettings.tsx index 3581a110..2bbfd117 100644 --- a/src/components/settings/LayoutSettings.tsx +++ b/src/components/settings/LayoutSettings.tsx @@ -13,6 +13,8 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [ { value: 'spam', label: 'Spam' }, { value: 'read', label: 'Read/Unread' }, { value: 'star', label: 'Star' }, + { value: 'pin', label: 'Pin' }, + { value: 'move', label: 'Move to folder' }, ]; export function LayoutSettings() { @@ -61,7 +63,7 @@ export function LayoutSettings() { ); diff --git a/src/components/settings/ReadingSettings.tsx b/src/components/settings/ReadingSettings.tsx index e0fdc20f..c18af588 100644 --- a/src/components/settings/ReadingSettings.tsx +++ b/src/components/settings/ReadingSettings.tsx @@ -1,14 +1,15 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native'; +import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native'; import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native'; import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store'; +import { useSettingsStore, type ArchiveMode, type ExternalContentPolicy } from '../../stores/settings-store'; +import { useEmailStore } from '../../stores/email-store'; +import { archiveEmails, queryEmails, getEmails } from '../../api/email'; type MailLayout = 'split' | 'focus'; type DeleteAction = 'trash' | 'permanent'; -type ArchiveMode = 'single' | 'year' | 'month'; type AttachmentAction = 'preview' | 'download'; type AttachmentPosition = 'beside-sender' | 'below-header'; @@ -77,6 +78,8 @@ export function ReadingSettings() { const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); + const archiveMode = useSettingsStore((s) => s.archiveMode); + const setArchiveMode = useSettingsStore((s) => s.setArchiveMode); const [markAsReadDelay, setMarkAsReadDelay] = useState('0'); const [deleteAction, setDeleteAction] = useState('trash'); @@ -90,7 +93,8 @@ export function ReadingSettings() { const [mailAttachmentAction, setMailAttachmentAction] = useState('preview'); const [attachmentPosition, setAttachmentPosition] = useState('beside-sender'); const [emailAlwaysLightMode, setEmailAlwaysLightMode] = useState(false); - const [archiveMode, setArchiveMode] = useState('single'); + const [reorganizing, setReorganizing] = useState(false); + const [reorganizeResult, setReorganizeResult] = useState(null); const [attachmentReminderEnabled, setAttachmentReminderEnabled] = useState(true); const [attachmentReminderKeywords, setAttachmentReminderKeywords] = useState(DEFAULT_KEYWORDS); const [newKeyword, setNewKeyword] = useState(''); @@ -102,6 +106,61 @@ export function ReadingSettings() { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); + const handleReorganizeArchive = async () => { + const { mailboxes, fetchMailboxes } = useEmailStore.getState(); + const archiveMailbox = mailboxes.find( + (m) => m.role === 'archive' || m.name.toLowerCase() === 'archive', + ); + if (!archiveMailbox) { + setReorganizeResult('No archive folder found.'); + return; + } + + setReorganizing(true); + setReorganizeResult(null); + + try { + // Drain the archive in pages so we don't OOM on huge mailboxes. + const PAGE = 100; + let position = 0; + let total = 0; + let moved = 0; + + while (true) { + const { ids, total: pageTotal } = await queryEmails(archiveMailbox.id, { + position, + limit: PAGE, + }); + if (position === 0) total = pageTotal; + if (ids.length === 0) break; + + const list = await getEmails(ids); + const refreshed = useEmailStore.getState().mailboxes; + await archiveEmails( + list.map((e) => ({ id: e.id, receivedAt: e.receivedAt })), + archiveMailbox.id, + archiveMode, + refreshed, + ); + moved += list.length; + + // Newly-created year/month folders need to be visible to the next batch + // so we don't try to create the same folder twice. + await fetchMailboxes(); + + // Items just got moved out of the root archive view; the next page + // starts again at position 0 of the now-shorter list. + if (ids.length < PAGE) break; + } + + setReorganizeResult(`Moved ${moved} of ${total} emails.`); + } catch (err) { + setReorganizeResult(err instanceof Error ? err.message : 'Reorganize failed.'); + } finally { + setReorganizing(false); + } + }; + return ( @@ -148,10 +207,25 @@ export function ReadingSettings() { ]} /> {archiveMode !== 'single' && ( - - - Reorganize existing archive - + <> + + {reorganizing ? ( + + ) : ( + + )} + + {reorganizing ? 'Reorganizing…' : 'Reorganize existing archive'} + + + {reorganizeResult && ( + {reorganizeResult} + )} + )} @@ -327,6 +401,14 @@ function makeStyles(c: ThemePalette) { ...typography.body, color: c.text, }, + inlineBtnDisabled: { + opacity: 0.6, + }, + reorganizeResult: { + ...typography.caption, + color: c.mutedForeground, + marginTop: spacing.xs, + }, subLabel: { ...typography.bodyMedium, color: c.text, diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 150c8167..51cc6e47 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -144,7 +144,11 @@ async function pollVerificationCode( relayBaseUrl: string, subscriptionId: string, ): Promise { - const timeoutAt = Date.now() + 20_000; + // Stalwart per-account rate-limits PushVerification posts (default 60s). + // If there are leftover unverified subscriptions on the account, our new + // one queues up behind them - so we wait long enough to clear at least one + // verify window even in the unlucky case. + const timeoutAt = Date.now() + 75_000; let delay = 400; while (Date.now() < timeoutAt) { const res = await fetch( @@ -203,10 +207,10 @@ export async function setupPushNotifications( // Reuse the previous JMAP subscription when the server still has it, but // push the expiry forward so it doesn't time out before the next app start. + const existingSubs = await listPushSubscriptions().catch(() => []); const storedServerId = await AsyncStorage.getItem(SUBSCRIPTION_ID_KEY); if (storedServerId) { - const existing = await listPushSubscriptions().catch(() => []); - const match = existing.find((s) => s.id === storedServerId); + const match = existingSubs.find((s) => s.id === storedServerId); if (match) { const refreshed = await refreshSubscriptionExpires(match); if (refreshed) { @@ -219,6 +223,18 @@ export async function setupPushNotifications( await AsyncStorage.removeItem(SUBSCRIPTION_ID_KEY); } + // Reap any leftover Stalwart subscriptions still bound to this device. + // These pile up when a previous enable attempt failed mid-flow (verify + // race, network blip, app killed). Stalwart per-account rate-limits + // PushVerification posts, so leaving stragglers around blocks the new + // one's verification - the symptom is a confusing "code does not match". + const stragglers = existingSubs.filter( + (s) => s.deviceClientId === deviceClientId && s.id !== storedServerId, + ); + for (const s of stragglers) { + await destroyPushSubscription(s.id).catch(() => undefined); + } + const serverAssignedId = await createPushSubscription({ deviceClientId, url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index b6722540..b8a213bc 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -11,6 +11,7 @@ import { useColors } from '../theme/colors'; import SidebarDrawer from '../components/SidebarDrawer'; import SenderAvatar from '../components/SenderAvatar'; import { SwipeableRow } from '../components/SwipeableRow'; +import { MoveSheet } from '../components/MoveSheet'; import { OfflineBanner } from '../components/OfflineBanner'; import { useNetworkStore } from '../stores/network-store'; import { useEmailStore, type EmailFilters } from '../stores/email-store'; @@ -33,6 +34,10 @@ function isStarred(email: Email): boolean { return !!email.keywords?.$flagged; } +function isPinned(email: Email): boolean { + return !!email.keywords?.$important; +} + function formatRelativeTime(date: Date): string { const now = Date.now(); const diffMs = now - date.getTime(); @@ -164,8 +169,10 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL const markRead = useEmailStore((s) => s.markRead); const markUnread = useEmailStore((s) => s.markUnread); const toggleStar = useEmailStore((s) => s.toggleStar); + const togglePin = useEmailStore((s) => s.togglePin); const deleteEmailAction = useEmailStore((s) => s.deleteEmail); const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox); + const archiveEmailAction = useEmailStore((s) => s.archiveEmail); const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction); const swipeRightAction = useSettingsStore((s) => s.swipeRightAction); @@ -184,6 +191,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL [mailboxes], ); + // Move-to-folder picker triggered by the swipe action. + const [pendingMoveId, setPendingMoveId] = React.useState(null); + // Selection state const [selectedIds, setSelectedIds] = React.useState>(new Set()); const selectionMode = selectedIds.size > 0; @@ -225,7 +235,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL switch (action) { case 'archive': if (archiveMailboxId && currentMailboxId !== archiveMailboxId) { - void moveToMailboxAction(id, currentMailboxId, archiveMailboxId); + void archiveEmailAction(id); } break; case 'delete': @@ -245,10 +255,17 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL case 'star': void toggleStar(id, !isStarred(email)); break; + case 'pin': + void togglePin(id, !isPinned(email)); + break; + case 'move': + setPendingMoveId(id); + break; } }, [ currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId, - moveToMailboxAction, deleteEmailAction, markRead, markUnread, toggleStar, + moveToMailboxAction, archiveEmailAction, deleteEmailAction, + markRead, markUnread, toggleStar, togglePin, ]); const renderEmailRow = React.useCallback( @@ -256,7 +273,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL handleSwipeAction(item.id, action)} > ); })()} + + setPendingMoveId(null)} + mailboxes={mailboxes} + currentMailboxId={currentMailboxId} + onPick={(toId) => { + const id = pendingMoveId; + setPendingMoveId(null); + if (id && currentMailboxId && toId !== currentMailboxId) { + void moveToMailboxAction(id, currentMailboxId, toId); + } + }} + /> ); } diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index d43a6bc2..be672849 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -9,33 +9,21 @@ 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, Tag, + Code, Download, Tag, } from 'lucide-react-native'; -import type { LucideIcon } from 'lucide-react-native'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import EmailBodyView from '../components/EmailBodyView'; import SenderAvatar from '../components/SenderAvatar'; +import { MoveSheet } from '../components/MoveSheet'; import { useEmailStore } from '../stores/email-store'; import { setEmailKeywords } from '../api/email'; import { shareEmailEml, shareAttachment } from '../lib/email-export'; import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store'; -import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree'; import { useSheetDrag } from '../lib/use-sheet-drag'; import type { Email, Mailbox } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; -function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon { - const lower = name.toLowerCase(); - if (role === 'inbox' || lower.includes('inbox')) return Inbox; - if (role === 'sent' || lower.includes('sent')) return Send; - if (role === 'drafts' || lower.includes('draft')) return FileIcon; - if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2; - if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban; - if (role === 'archive' || lower.includes('archive')) return Archive; - return Folder; -} - type Props = NativeStackScreenProps; function formatHeaderDate(iso: string): string { @@ -71,6 +59,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { const markRead = useEmailStore((s) => s.markRead); const deleteEmail = useEmailStore((s) => s.deleteEmail); const moveToMailbox = useEmailStore((s) => s.moveToMailbox); + const archiveEmailAction = useEmailStore((s) => s.archiveEmail); const mailboxes = useEmailStore((s) => s.mailboxes); const currentMailboxId = useEmailStore((s) => s.currentMailboxId); const emails = useEmailStore((s) => s.emails); @@ -200,7 +189,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { const onArchive = () => { if (!email || !currentMailboxId || !archiveMailbox) return; if (currentMailboxId === archiveMailbox.id) return; - void moveToMailbox(email.id, currentMailboxId, archiveMailbox.id); + void archiveEmailAction(email.id); navigation.goBack(); }; @@ -492,7 +481,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { }} /> - setMoveMenuOpen(false)} mailboxes={mailboxes} @@ -725,107 +714,6 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: ); } -interface MoveMenuSheetProps { - visible: boolean; - onClose: () => void; - mailboxes: Mailbox[]; - currentMailboxId: string | null; - onPick: (id: string) => void; -} - -function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) { - const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); - const insets = useSafeAreaInsets(); - const slideY = React.useRef(new Animated.Value(500)).current; - const overlayOpacity = React.useRef(new Animated.Value(0)).current; - const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); - - 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]); - - const visibleNodes = React.useMemo(() => { - const tree = buildMailboxTree(mailboxes); - const expanded = new Set(); - const collect = (nodes: MailboxNode[]) => { - for (const n of nodes) { - if (n.children.length > 0) { - expanded.add(n.id); - collect(n.children); - } - } - }; - collect(tree); - return flattenVisible(tree, expanded); - }, [mailboxes]); - - return ( - - - - - - - - - - - Move to folder - - - - - - - {visibleNodes.map((node) => { - const Icon = moveTargetIcon(node.role, node.name); - const isCurrent = node.id === currentMailboxId; - const canTarget = node.myRights?.mayAddItems !== false && !isCurrent; - return ( - onPick(node.id) : undefined} - disabled={!canTarget} - style={({ pressed }) => [ - styles.moveRow, - pressed && canTarget && styles.moveRowPressed, - { paddingLeft: spacing.lg + node.depth * 16 }, - ]} - > - - - {node.name} - - {isCurrent && } - - ); - })} - - - - ); -} - function MoreMenuItem({ icon, label, onPress, trailing, }: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) { @@ -1106,9 +994,6 @@ function makeStyles(c: ThemePalette) { borderColor: c.border, paddingTop: spacing.sm, }, - sheetTall: { - maxHeight: '75%', - }, sheetHandleHit: { alignItems: 'center', paddingTop: spacing.xs, @@ -1162,23 +1047,5 @@ function makeStyles(c: ThemePalette) { flex: 1, }, - // Move folder row - moveRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingRight: spacing.lg, - paddingVertical: spacing.sm + 2, - minHeight: 44, - }, - moveRowPressed: { backgroundColor: c.surfaceHover }, - moveRowLabel: { - ...typography.body, - color: c.text, - flex: 1, - }, - moveRowLabelDisabled: { - color: c.textMuted, - }, }); } diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index e1848f60..2d8cc934 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -7,10 +7,18 @@ vi.mock('../../api/email', () => ({ getFullEmail: vi.fn(), setEmailKeywords: vi.fn(), moveEmail: vi.fn(), + archiveEmails: vi.fn(), deleteEmail: vi.fn(), searchEmails: vi.fn(), })); +// settings-store transitively pulls in jmap-client / expo-secure-store, which +// trip on react-native's Flow-typed entrypoint under vitest. The store only +// reads archiveMode in archiveEmail, so a minimal stub is enough. +vi.mock('../settings-store', () => ({ + useSettingsStore: { getState: () => ({ archiveMode: 'single' }) }, +})); + import * as emailApi from '../../api/email'; import { useEmailStore } from '../email-store'; diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index e0a1e96b..fd00da3f 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -9,10 +9,12 @@ import { getFullEmail, setEmailKeywords, moveEmail, + archiveEmails as apiArchiveEmails, deleteEmail as apiDeleteEmail, searchEmails as apiSearchEmails, } from '../api/email'; import { toWildcardQuery } from '../lib/search-utils'; +import { useSettingsStore } from './settings-store'; export interface EmailFilters { from?: string; @@ -43,7 +45,9 @@ export interface EmailState { markRead: (emailId: string) => Promise; markUnread: (emailId: string) => Promise; toggleStar: (emailId: string, starred: boolean) => Promise; + togglePin: (emailId: string, pinned: boolean) => Promise; moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise; + archiveEmail: (emailId: string) => Promise; deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise; searchEmails: (query: string) => Promise; setSearchQuery: (query: string) => void; @@ -232,11 +236,57 @@ export const useEmailStore = create()( }); }, + togglePin: async (emailId, pinned) => { + const email = get().emails.find((e) => e.id === emailId); + if (!email) return; + const keywords = { ...email.keywords }; + if (pinned) { + keywords.$important = true; + } else { + delete keywords.$important; + } + await setEmailKeywords(emailId, keywords); + set({ + emails: get().emails.map((e) => + e.id === emailId ? { ...e, keywords } : e, + ), + }); + }, + moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => { await moveEmail(emailId, fromMailboxId, toMailboxId); set({ emails: get().emails.filter((e) => e.id !== emailId) }); }, + archiveEmail: async (emailId) => { + const { emails, mailboxes } = get(); + const email = emails.find((e) => e.id === emailId); + if (!email) return; + + const archiveMailbox = mailboxes.find( + (m) => m.role === 'archive' || m.name.toLowerCase() === 'archive', + ); + if (!archiveMailbox) return; + if (email.mailboxIds?.[archiveMailbox.id]) return; + + const mode = useSettingsStore.getState().archiveMode; + + await apiArchiveEmails( + [{ id: email.id, receivedAt: email.receivedAt }], + archiveMailbox.id, + mode, + mailboxes, + ); + + set({ emails: get().emails.filter((e) => e.id !== emailId) }); + + // Auto-sort modes may have created new year/month folders - refresh the + // mailbox list so the sidebar picks them up on the next render. + if (mode !== 'single') { + void get().fetchMailboxes(); + } + }, + deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => { await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId); set({ emails: get().emails.filter((e) => e.id !== emailId) }); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 0d2ce02a..d3b35be4 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -10,7 +10,16 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; const STORAGE_KEY = 'webmail:settings:v1'; -export type SwipeAction = 'none' | 'archive' | 'delete' | 'spam' | 'read' | 'star'; +export type SwipeAction = + | 'none' + | 'archive' + | 'delete' + | 'spam' + | 'read' + | 'star' + | 'pin' + | 'move'; +export type ArchiveMode = 'single' | 'year' | 'month'; interface PersistedSettings { externalContentPolicy: ExternalContentPolicy; @@ -31,6 +40,8 @@ interface PersistedSettings { // Layout / list interactions swipeLeftAction: SwipeAction; swipeRightAction: SwipeAction; + // Archive + archiveMode: ArchiveMode; } const DEFAULT_PERSISTED: PersistedSettings = { @@ -49,6 +60,7 @@ const DEFAULT_PERSISTED: PersistedSettings = { attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'], swipeLeftAction: 'archive', swipeRightAction: 'read', + archiveMode: 'single', }; export interface SettingsState extends PersistedSettings { @@ -73,6 +85,7 @@ export interface SettingsState extends PersistedSettings { setAttachmentReminderKeywords: (keywords: string[]) => void; setSwipeLeftAction: (action: SwipeAction) => void; setSwipeRightAction: (action: SwipeAction) => void; + setArchiveMode: (mode: ArchiveMode) => void; addTrustedSender: (email: string) => void; removeTrustedSender: (email: string) => void; isSenderTrusted: (email: string) => boolean; @@ -96,6 +109,7 @@ function snapshot(state: SettingsState): PersistedSettings { attachmentReminderKeywords: state.attachmentReminderKeywords, swipeLeftAction: state.swipeLeftAction, swipeRightAction: state.swipeRightAction, + archiveMode: state.archiveMode, }; } @@ -144,6 +158,7 @@ export const useSettingsStore = create((set, get) => ({ attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords, swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction, swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction, + archiveMode: parsed.archiveMode ?? DEFAULT_PERSISTED.archiveMode, hydrated: true, }); return; @@ -168,6 +183,7 @@ export const useSettingsStore = create((set, 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())); }, + setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); }, addTrustedSender: (email) => { const normalized = email.toLowerCase().trim();