diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx
index 134b3d9b..f5e76c88 100644
--- a/src/components/SwipeableRow.tsx
+++ b/src/components/SwipeableRow.tsx
@@ -1,6 +1,6 @@
-import React, { useRef } from 'react';
+import React, { useRef, useState } from 'react';
import {
- View, Text, StyleSheet, Animated, PanResponder, Dimensions,
+ View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
@@ -8,7 +8,7 @@ import {
} from 'lucide-react-native';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
-import type { SwipeAction } from '../stores/settings-store';
+import type { SwipeAction, SwipeMode } from '../stores/settings-store';
interface SwipeableRowProps {
children: React.ReactNode;
@@ -19,14 +19,24 @@ interface SwipeableRowProps {
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean; pinned: boolean };
onAction: (action: SwipeAction) => void;
+ /**
+ * 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
+ * 'reveal': swipe past ACTIVATION_THRESHOLD reveals an action band that must be tapped.
+ */
+ mode?: SwipeMode;
}
-// Distance (px) of horizontal travel needed to commit the action on release.
-// Drag less than this and the row snaps back without firing.
+// Instant mode: distance the user must drag past on release to fire the action.
const COMMIT_THRESHOLD = 96;
+// Reveal mode: drag past this on release to snap to the open (revealed) state.
+const ACTIVATION_THRESHOLD = 32;
+// Reveal mode: width of the action band shown when revealed.
+const REVEAL_WIDTH = 88;
+
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
const MIN_DX_TO_CLAIM = 6;
-const MAX_DRAG_OVERSHOOT = 240;
+const MAX_DRAG_OVERSHOOT_INSTANT = 240;
+const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
@@ -55,7 +65,7 @@ function exitsRow(action: SwipeAction): boolean {
}
export function SwipeableRow({
- children, leftAction, rightAction, context, onAction,
+ children, leftAction, rightAction, context, onAction, mode = 'instant',
}: SwipeableRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -63,8 +73,21 @@ export function SwipeableRow({
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
- const snapBack = () => {
+ // Reveal-mode-only state: which side (if any) is currently sitting open.
+ const openSideRef = useRef<'left' | 'right' | null>(null);
+ const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
+
+ const close = () => {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
+ openSideRef.current = null;
+ setOpenSide(null);
+ };
+
+ const openTo = (side: 'left' | 'right') => {
+ const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
+ Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
+ openSideRef.current = side;
+ setOpenSide(side);
};
const fly = (toValue: number, action: SwipeAction) => {
@@ -85,7 +108,19 @@ export function SwipeableRow({
});
} else {
onAction(action);
- snapBack();
+ Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
+ }
+ };
+
+ const fireFromBandTap = (action: SwipeAction) => {
+ close();
+ if (action === 'none') return;
+ if (exitsRow(action)) {
+ // Let the row collapse a frame, then fire so the parent's list update
+ // has a clean starting point.
+ requestAnimationFrame(() => onAction(action));
+ } else {
+ onAction(action);
}
};
@@ -95,40 +130,121 @@ 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 (g.dx > 0 && rightAction === 'none') return false;
- if (g.dx < 0 && leftAction === 'none') return false;
+ // Reveal mode: if a side is already open, allow the gesture so the
+ // user can drag it closed.
+ if (mode === 'reveal' && openSideRef.current === null) {
+ if (g.dx > 0 && rightAction === 'none') return false;
+ if (g.dx < 0 && leftAction === 'none') return false;
+ } else if (mode !== 'reveal') {
+ if (g.dx > 0 && rightAction === 'none') return false;
+ if (g.dx < 0 && leftAction === 'none') return false;
+ }
claimed.current = true;
return true;
},
onPanResponderMove: (_, g) => {
- const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, g.dx));
+ const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
+ const base =
+ mode === 'reveal'
+ ? openSideRef.current === 'right' ? REVEAL_WIDTH
+ : openSideRef.current === 'left' ? -REVEAL_WIDTH
+ : 0
+ : 0;
+ const next = base + g.dx;
+ const clamped = Math.max(-overshoot, Math.min(overshoot, next));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
claimed.current = false;
+ if (mode === 'reveal') {
+ const base =
+ openSideRef.current === 'right' ? REVEAL_WIDTH
+ : openSideRef.current === 'left' ? -REVEAL_WIDTH
+ : 0;
+ const finalDx = base + g.dx;
+ if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
+ openTo('right');
+ } else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
+ openTo('left');
+ } else {
+ close();
+ }
+ return;
+ }
+ // Instant mode: fire on release-past-threshold, no second tap.
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();
+ Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
onPanResponderTerminate: () => {
- snapBack();
+ if (mode === 'reveal') close();
+ else 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') => {
+ const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
+ widthRef.current = e.nativeEvent.layout.width;
+ };
+
+ if (mode === 'reveal') {
+ // Reveal mode: bands sit at the row edges with a fixed REVEAL_WIDTH and
+ // are tap targets. The row slides over them; releasing past the activation
+ // threshold snaps the row to the open position so the band is fully
+ // visible and tappable.
+ const renderRevealBand = (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 (
+ fireFromBandTap(action)}
+ style={[
+ styles.bandReveal,
+ side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
+ { backgroundColor: meta.bg, width: REVEAL_WIDTH },
+ ]}
+ >
+
+
+ {label}
+
+
+ );
+ };
+
+ return (
+
+ {renderRevealBand(rightAction, 'left')}
+ {renderRevealBand(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}
+
+
+ );
+ }
+
+ // Instant mode: bands fill the gap behind the row as it's dragged.
+ const renderInstantBand = (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,
@@ -138,7 +254,7 @@ export function SwipeableRow({
return (
{
- 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],
@@ -171,10 +281,10 @@ export function SwipeableRow({
return (
- {renderBand(rightAction, 'left')}
+ {renderInstantBand(rightAction, 'left')}
- {renderBand(leftAction, 'right')}
+ {renderInstantBand(leftAction, 'right')}
{children}
@@ -185,15 +295,22 @@ export function SwipeableRow({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
- wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
- content: { backgroundColor: c.background },
- band: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 24,
- },
- bandInner: { alignItems: 'center', gap: 4 },
- bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
+ wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
+ content: { backgroundColor: c.background },
+ bandInstant: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 24,
+ },
+ bandReveal: {
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ justifyContent: 'center',
+ paddingHorizontal: 16,
+ },
+ 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 2bbfd117..59929e40 100644
--- a/src/components/settings/LayoutSettings.tsx
+++ b/src/components/settings/LayoutSettings.tsx
@@ -1,10 +1,10 @@
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 { SettingsSection, RadioGroup } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
-import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
+import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -17,6 +17,11 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'move', label: 'Move to folder' },
];
+const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
+ { value: 'instant', label: 'Instant (swipe = action)' },
+ { value: 'reveal', label: 'Reveal (swipe, then tap)' },
+];
+
export function LayoutSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -24,6 +29,8 @@ export function LayoutSettings() {
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
+ const swipeMode = useSettingsStore((s) => s.swipeMode);
+ const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -32,6 +39,19 @@ export function LayoutSettings() {
return (
+ Swipe behavior
+
+ Pick instant (swipe past the threshold to fire the action) or reveal
+ (swipe to expose an action band, then tap to confirm).
+
+ setSwipeMode(v as SwipeMode)}
+ options={SWIPE_MODE_OPTIONS}
+ />
+
+
+
Swipe right (left → right)
@@ -61,10 +81,6 @@ export function LayoutSettings() {
/>
-
);
}
diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx
index 9c935352..1362e98b 100644
--- a/src/screens/ComposeScreen.tsx
+++ b/src/screens/ComposeScreen.tsx
@@ -1,9 +1,9 @@
import React from 'react';
import {
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
- KeyboardAvoidingView, Platform, ActivityIndicator, Alert, Modal,
+ Keyboard, Platform, ActivityIndicator, Alert, Modal,
} from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
+import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
X, Send, Paperclip, ChevronDown, Bold, Italic, Underline, Strikethrough,
@@ -209,6 +209,28 @@ export default function ComposeScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
+ const insets = useSafeAreaInsets();
+ // Track keyboard height so the format bar stays just above the IME.
+ // KeyboardAvoidingView is unreliable on Android edge-to-edge, so we
+ // listen to the Keyboard module directly on both platforms.
+ const [kbHeight, setKbHeight] = React.useState(0);
+ React.useEffect(() => {
+ const showEvt = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
+ const hideEvt = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
+ const showSub = Keyboard.addListener(showEvt, (e) => {
+ setKbHeight(e.endCoordinates?.height ?? 0);
+ });
+ const hideSub = Keyboard.addListener(hideEvt, () => {
+ setKbHeight(0);
+ });
+ return () => {
+ showSub.remove();
+ hideSub.remove();
+ };
+ }, []);
+ // When the keyboard is up it covers the bottom safe area, so we don't
+ // need to add it on top — pad by whichever is larger.
+ const bottomPad = Math.max(kbHeight, insets.bottom);
const replyTo = route.params?.replyTo;
const mode = route.params?.mode ?? 'compose';
const prefillTo = route.params?.prefillTo;
@@ -703,7 +725,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
: 'email_composer.new_message';
return (
-
+
@@ -741,10 +763,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
-
+
editorRef.current?.exec('redo')}
icon={} />
-
+
s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
+ const swipeMode = useSettingsStore((s) => s.swipeMode);
const networkOnline = useNetworkStore((s) => s.online);
const archiveMailboxId = React.useMemo(
@@ -274,6 +275,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
handleSwipeAction(item.id, action)}
>
@@ -286,7 +288,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/>
),
- [selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
+ [selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleSwipeAction],
);
const clearSelection = React.useCallback(() => {
diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts
index d3b35be4..b060d8cc 100644
--- a/src/stores/settings-store.ts
+++ b/src/stores/settings-store.ts
@@ -19,6 +19,7 @@ export type SwipeAction =
| 'star'
| 'pin'
| 'move';
+export type SwipeMode = 'instant' | 'reveal';
export type ArchiveMode = 'single' | 'year' | 'month';
interface PersistedSettings {
@@ -40,6 +41,7 @@ interface PersistedSettings {
// Layout / list interactions
swipeLeftAction: SwipeAction;
swipeRightAction: SwipeAction;
+ swipeMode: SwipeMode;
// Archive
archiveMode: ArchiveMode;
}
@@ -60,6 +62,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
swipeLeftAction: 'archive',
swipeRightAction: 'read',
+ swipeMode: 'instant',
archiveMode: 'single',
};
@@ -85,6 +88,7 @@ export interface SettingsState extends PersistedSettings {
setAttachmentReminderKeywords: (keywords: string[]) => void;
setSwipeLeftAction: (action: SwipeAction) => void;
setSwipeRightAction: (action: SwipeAction) => void;
+ setSwipeMode: (mode: SwipeMode) => void;
setArchiveMode: (mode: ArchiveMode) => void;
addTrustedSender: (email: string) => void;
removeTrustedSender: (email: string) => void;
@@ -109,6 +113,7 @@ function snapshot(state: SettingsState): PersistedSettings {
attachmentReminderKeywords: state.attachmentReminderKeywords,
swipeLeftAction: state.swipeLeftAction,
swipeRightAction: state.swipeRightAction,
+ swipeMode: state.swipeMode,
archiveMode: state.archiveMode,
};
}
@@ -158,6 +163,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,
+ swipeMode: parsed.swipeMode ?? DEFAULT_PERSISTED.swipeMode,
archiveMode: parsed.archiveMode ?? DEFAULT_PERSISTED.archiveMode,
hydrated: true,
});
@@ -183,6 +189,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())); },
+ setSwipeMode: (mode) => { set({ swipeMode: mode }); persist(snapshot(get())); },
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); },
addTrustedSender: (email) => {