diff --git a/apps/mobile-app/app/(tabs)/emails/index.tsx b/apps/mobile-app/app/(tabs)/emails/index.tsx index 5cc218699..e7c8d385c 100644 --- a/apps/mobile-app/app/(tabs)/emails/index.tsx +++ b/apps/mobile-app/app/(tabs)/emails/index.tsx @@ -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) => ( - + )); }; diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx index b4be22443..f05743898 100644 --- a/apps/mobile-app/app/_layout.tsx +++ b/apps/mobile-app/app/_layout.tsx @@ -187,7 +187,7 @@ export default function RootLayout() : React.ReactNode { - + diff --git a/apps/mobile-app/components/SwipeableEmailCard.tsx b/apps/mobile-app/components/SwipeableEmailCard.tsx new file mode 100644 index 000000000..bdac85c3f --- /dev/null +++ b/apps/mobile-app/components/SwipeableEmailCard.tsx @@ -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(null); + + /** + * Handle email deletion. + */ + const handleDelete = async (): Promise => { + // 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 => { + 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, + dragX: Animated.AnimatedInterpolation + ): 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 ( + + + + + + ); + }; + + /** + * Render the left action (delete button) when swiping right. + */ + const renderLeftActions = ( + progress: Animated.AnimatedInterpolation, + dragX: Animated.AnimatedInterpolation + ): 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 ( + + + + + + ); + }; + + /** + * Handle swipe begin to provide haptic feedback. + */ + const handleSwipeableWillOpen = async (): Promise => { + 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 ( + + + + ); +} diff --git a/apps/mobile-app/components/ui/IconSymbol.ios.tsx b/apps/mobile-app/components/ui/IconSymbol.ios.tsx index b87900e2e..de5da40c7 100644 --- a/apps/mobile-app/components/ui/IconSymbol.ios.tsx +++ b/apps/mobile-app/components/ui/IconSymbol.ios.tsx @@ -15,6 +15,7 @@ const SF_SYMBOLS_MAPPING: Record