Add swipe-to-delete functionality for emails

Implement a user-friendly swipe-to-delete feature for emails in the email
list. This addresses the need for quick email deletion with a 2-step process
to prevent accidental deletions.

Key changes:
- Created SwipeableEmailCard component wrapping EmailCard with swipe gestures
- Added delete button that reveals when swiping left or right
- Implemented confirmation dialog before actual deletion
- Added haptic feedback for better user experience
- Added Trash icon to IconSymbolName enum and platform-specific mappings
- Updated email list to use SwipeableEmailCard instead of EmailCard
- Fixed GestureHandlerRootView styling for proper gesture handling

The swipe gesture uses react-native-gesture-handler's Swipeable component
to reveal a red delete button. Tapping the delete button shows a confirmation
dialog before actually deleting the email, ensuring no accidental deletions.

Resolves: GitHub issue requesting swipe-to-delete for email list
This commit is contained in:
Claude
2025-11-04 18:44:52 +00:00
parent 75797fe829
commit 023241eb97
6 changed files with 213 additions and 3 deletions

View File

@@ -13,7 +13,7 @@ import emitter from '@/utils/EventEmitter';
import { useColors } from '@/hooks/useColorScheme';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import { EmailCard } from '@/components/EmailCard';
import { SwipeableEmailCard } from '@/components/SwipeableEmailCard';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedText } from '@/components/themed/ThemedText';
import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader';
@@ -216,7 +216,7 @@ export default function EmailsScreen() : React.ReactNode {
}
return emails.map((email) => (
<EmailCard key={email.id} email={email} />
<SwipeableEmailCard key={email.id} email={email} />
));
};

View File

@@ -187,7 +187,7 @@ export default function RootLayout() : React.ReactNode {
<WebApiProvider>
<AppProvider>
<ClipboardCountdownProvider>
<GestureHandlerRootView>
<GestureHandlerRootView style={{ flex: 1 }}>
<RootLayoutNav />
</GestureHandlerRootView>
</ClipboardCountdownProvider>

View File

@@ -0,0 +1,207 @@
import { useRef } from 'react';
import { StyleSheet, View, Animated, Alert } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import * as Haptics from 'expo-haptics';
import type { MailboxEmail } from '@/utils/dist/shared/models/webapi';
import { useColors } from '@/hooks/useColorScheme';
import { useTranslation } from '@/hooks/useTranslation';
import { useWebApi } from '@/context/WebApiContext';
import { EmailCard } from '@/components/EmailCard';
import { RobustPressable } from '@/components/ui/RobustPressable';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { IconSymbolName } from '@/components/ui/IconSymbolName';
import { emitter } from '@/utils/EventEmitter';
type SwipeableEmailCardProps = {
email: MailboxEmail;
onDelete?: (emailId: string) => void;
};
/**
* Swipeable email card component with swipe-to-delete functionality.
* Swiping left or right reveals a delete button for 2-step deletion.
*/
export function SwipeableEmailCard({ email, onDelete }: SwipeableEmailCardProps): React.ReactNode {
const colors = useColors();
const { t } = useTranslation();
const webApi = useWebApi();
const swipeableRef = useRef<Swipeable>(null);
/**
* Handle email deletion.
*/
const handleDelete = async (): Promise<void> => {
// Trigger haptic feedback
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
// Show confirmation alert
Alert.alert(
t('emails.deleteEmail'),
t('emails.deleteEmailConfirm'),
[
{
text: t('common.cancel'),
style: 'cancel',
onPress: () => {
// Close the swipeable
swipeableRef.current?.close();
},
},
{
text: t('common.delete'),
style: 'destructive',
onPress: async (): Promise<void> => {
try {
// Trigger haptic feedback for deletion
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Delete the email from the server
await webApi.delete(`Email/${email.id}`);
// Close the swipeable
swipeableRef.current?.close();
// Notify parent or refresh the list
if (onDelete) {
onDelete(email.id);
} else {
emitter.emit('refreshEmails');
}
} catch (err) {
// Trigger error haptic feedback
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
// Show error alert
Alert.alert(
t('common.error'),
err instanceof Error ? err.message : t('emails.errors.deleteFailed')
);
// Close the swipeable
swipeableRef.current?.close();
}
},
},
]
);
};
/**
* Render the right action (delete button) when swiping left.
*/
const renderRightActions = (
progress: Animated.AnimatedInterpolation<number>,
dragX: Animated.AnimatedInterpolation<number>
): React.ReactNode => {
const translateX = dragX.interpolate({
inputRange: [-80, 0],
outputRange: [0, 80],
extrapolate: 'clamp',
});
const opacity = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
extrapolate: 'clamp',
});
return (
<Animated.View
style={[
styles.deleteAction,
{
transform: [{ translateX }],
opacity,
},
]}
>
<RobustPressable
style={[styles.deleteButton, { backgroundColor: colors.destructive }]}
onPress={handleDelete}
>
<IconSymbol size={24} name={IconSymbolName.Trash} color="#FFFFFF" />
</RobustPressable>
</Animated.View>
);
};
/**
* Render the left action (delete button) when swiping right.
*/
const renderLeftActions = (
progress: Animated.AnimatedInterpolation<number>,
dragX: Animated.AnimatedInterpolation<number>
): React.ReactNode => {
const translateX = dragX.interpolate({
inputRange: [0, 80],
outputRange: [-80, 0],
extrapolate: 'clamp',
});
const opacity = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
extrapolate: 'clamp',
});
return (
<Animated.View
style={[
styles.deleteAction,
{
transform: [{ translateX }],
opacity,
},
]}
>
<RobustPressable
style={[styles.deleteButton, { backgroundColor: colors.destructive }]}
onPress={handleDelete}
>
<IconSymbol size={24} name={IconSymbolName.Trash} color="#FFFFFF" />
</RobustPressable>
</Animated.View>
);
};
/**
* Handle swipe begin to provide haptic feedback.
*/
const handleSwipeableWillOpen = async (): Promise<void> => {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const styles = StyleSheet.create({
deleteAction: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
marginBottom: 12,
},
deleteButton: {
alignItems: 'center',
borderRadius: 8,
height: '100%',
justifyContent: 'center',
width: 80,
},
});
return (
<Swipeable
ref={swipeableRef}
renderRightActions={renderRightActions}
renderLeftActions={renderLeftActions}
onSwipeableWillOpen={handleSwipeableWillOpen}
overshootRight={false}
overshootLeft={false}
rightThreshold={40}
leftThreshold={40}
>
<EmailCard email={email} />
</Swipeable>
);
}

View File

@@ -15,6 +15,7 @@ const SF_SYMBOLS_MAPPING: Record<IconSymbolName, import('expo-symbols').SymbolVi
[IconSymbolName.Paperplane]: 'paperplane.fill',
[IconSymbolName.ChevronRight]: 'chevron.right',
[IconSymbolName.ChevronLeftRight]: 'chevron.left.forwardslash.chevron.right',
[IconSymbolName.Trash]: 'trash.fill',
};
/**

View File

@@ -18,6 +18,7 @@ const MATERIAL_ICONS_MAPPING: Record<IconSymbolName, React.ComponentProps<typeof
[IconSymbolName.Paperplane]: 'send',
[IconSymbolName.ChevronRight]: 'chevron-right',
[IconSymbolName.ChevronLeftRight]: 'code',
[IconSymbolName.Trash]: 'delete',
};
/**

View File

@@ -12,4 +12,5 @@ export enum IconSymbolName {
Paperplane,
ChevronRight,
ChevronLeftRight,
Trash,
}