mirror of
https://github.com/aliasvault/aliasvault.git
synced 2025-12-23 22:28:22 -05:00
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:
@@ -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} />
|
||||
));
|
||||
};
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function RootLayout() : React.ReactNode {
|
||||
<WebApiProvider>
|
||||
<AppProvider>
|
||||
<ClipboardCountdownProvider>
|
||||
<GestureHandlerRootView>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<RootLayoutNav />
|
||||
</GestureHandlerRootView>
|
||||
</ClipboardCountdownProvider>
|
||||
|
||||
207
apps/mobile-app/components/SwipeableEmailCard.tsx
Normal file
207
apps/mobile-app/components/SwipeableEmailCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,4 +12,5 @@ export enum IconSymbolName {
|
||||
Paperplane,
|
||||
ChevronRight,
|
||||
ChevronLeftRight,
|
||||
Trash,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user