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) => {