feat: add swipe actions for pinning and moving emails; enhance archive functionality

This commit is contained in:
Linus Rath
2026-05-02 14:27:15 +02:00
parent 49d1fc62a8
commit 329c8a2f0b
11 changed files with 634 additions and 226 deletions
+199
View File
@@ -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<string>();
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 (
<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 {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
onPress={canTarget ? () => onPick(node.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [
styles.moveRow,
pressed && canTarget && styles.moveRowPressed,
{ paddingLeft: spacing.lg + node.depth * 16 },
]}
>
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
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,
},
});
}
+91 -71
View File
@@ -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<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
@@ -33,15 +36,24 @@ const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { 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 (
<Pressable
onPress={() => fireAction(action)}
<View
style={[
styles.band,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
{ backgroundColor: meta.bg },
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
]}
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Animated.View style={[styles.bandInner, { transform: [{ scale: iconScale }] }]}>
<Icon size={22} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</Pressable>
</Animated.View>
</View>
);
};
@@ -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 (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderBand(rightAction, 'left')}
{renderBand(leftAction, 'right')}
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
{renderBand(rightAction, 'left')}
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
{renderBand(leftAction, 'right')}
</Animated.View>
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{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 ? (
<Pressable
style={StyleSheet.absoluteFill}
onPress={close}
/>
) : null}
</Animated.View>
</View>
);
@@ -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' },
+3 -1
View File
@@ -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() {
<SettingItem
label=""
description="Drag past about half a row to commit; lift earlier to cancel."
description="Swipe past the threshold and release to fire the action; release before to cancel."
/>
</SettingsSection>
);
+90 -8
View File
@@ -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<DeleteAction>('trash');
@@ -90,7 +93,8 @@ export function ReadingSettings() {
const [mailAttachmentAction, setMailAttachmentAction] = useState<AttachmentAction>('preview');
const [attachmentPosition, setAttachmentPosition] = useState<AttachmentPosition>('beside-sender');
const [emailAlwaysLightMode, setEmailAlwaysLightMode] = useState(false);
const [archiveMode, setArchiveMode] = useState<ArchiveMode>('single');
const [reorganizing, setReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [attachmentReminderEnabled, setAttachmentReminderEnabled] = useState(true);
const [attachmentReminderKeywords, setAttachmentReminderKeywords] = useState<string[]>(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 (
<SettingsSection title="Email Behavior" description="How your inbox behaves day to day.">
<SettingItem label="Mark as Read" description="When to flag an email as read.">
@@ -148,10 +207,25 @@ export function ReadingSettings() {
]}
/>
{archiveMode !== 'single' && (
<Pressable style={styles.inlineBtn}>
<FolderSync size={14} color={c.text} />
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
</Pressable>
<>
<Pressable
style={[styles.inlineBtn, reorganizing && styles.inlineBtnDisabled]}
onPress={reorganizing ? undefined : handleReorganizeArchive}
disabled={reorganizing}
>
{reorganizing ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<FolderSync size={14} color={c.text} />
)}
<Text style={styles.inlineBtnText}>
{reorganizing ? 'Reorganizing…' : 'Reorganize existing archive'}
</Text>
</Pressable>
{reorganizeResult && (
<Text style={styles.reorganizeResult}>{reorganizeResult}</Text>
)}
</>
)}
<View style={styles.divider} />
</View>
@@ -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,