From ff2b5eef796f393504f04ab7be3d0baa5a822317 Mon Sep 17 00:00:00 2001 From: SarthakKharche <20240802084@dypiu.ac.in> Date: Fri, 5 Jun 2026 11:39:11 +0530 Subject: [PATCH] Add swipe to delete notifications --- frontend/src/components/NotificationItem.tsx | 289 +++++++++++++------ frontend/src/screens/NotificationScreen.tsx | 199 ++++++++++--- 2 files changed, 359 insertions(+), 129 deletions(-) diff --git a/frontend/src/components/NotificationItem.tsx b/frontend/src/components/NotificationItem.tsx index 7fef0d29c..ddaea1d29 100644 --- a/frontend/src/components/NotificationItem.tsx +++ b/frontend/src/components/NotificationItem.tsx @@ -1,115 +1,204 @@ -import { formatTimeWithDate } from '../helper/dateUtils'; -import React from 'react'; +import {formatTimeWithDate} from '../helper/dateUtils'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import { + PanResponder, Pressable, - View, - Text, StyleSheet, - TouchableOpacity, - Alert, + Text, + View, } from 'react-native'; import {Notification} from '../type'; -import {fp, hp, wp} from '../helper/Metric'; -import MaterialCommunityIcon from '@expo/vector-icons/MaterialCommunityIcons'; -import {BUTTON_COLOR} from '../helper/Theme'; -import { MaterialIcons } from '@expo/vector-icons'; +import {fp, wp} from '../helper/Metric'; +import {MaterialIcons} from '@expo/vector-icons'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +const MIN_REVEAL_WIDTH = 96; +const MAX_REVEAL_WIDTH = 144; +const TRANSITION_DURATION = 220; export default function NotificationItem({ item, handleDeleteAction, handleClick, + isOpen, + onOpenSwipe, + onCloseSwipe, }: { item: Notification; handleDeleteAction: (item: Notification) => void; handleClick: (item: Notification) => void; + isOpen: boolean; + onOpenSwipe: (id: string) => void; + onCloseSwipe: (id: string) => void; }) { + const translateX = useSharedValue(0); + const [cardWidth, setCardWidth] = useState(0); + + const revealWidth = useMemo(() => { + if (!cardWidth) { + return 112; + } + + return Math.min( + MAX_REVEAL_WIDTH, + Math.max(MIN_REVEAL_WIDTH, cardWidth * 0.3), + ); + }, [cardWidth]); + + const fullSwipeDistance = useMemo(() => { + return -(cardWidth || revealWidth * 3); + }, [cardWidth, revealWidth]); + + const animatedCardStyle = useAnimatedStyle(() => ({ + transform: [{translateX: translateX.value}], + })); + + const closeItem = useCallback(() => { + translateX.value = withTiming(0, {duration: TRANSITION_DURATION}); + onCloseSwipe(item._id); + }, [item._id, onCloseSwipe, translateX]); + + const openItem = useCallback(() => { + translateX.value = withTiming(-revealWidth, {duration: TRANSITION_DURATION}); + onOpenSwipe(item._id); + }, [item._id, onOpenSwipe, revealWidth, translateX]); + + useEffect(() => { + if (isOpen) { + translateX.value = withTiming(-revealWidth, {duration: TRANSITION_DURATION}); + } else { + translateX.value = withTiming(0, {duration: TRANSITION_DURATION}); + } + }, [isOpen, revealWidth, translateX]); + + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => { + return ( + Math.abs(gestureState.dx) > 6 && + Math.abs(gestureState.dx) > Math.abs(gestureState.dy) + ); + }, + onPanResponderGrant: () => { + onOpenSwipe(item._id); + }, + onPanResponderMove: (_, gestureState) => { + const baseOffset = isOpen ? -revealWidth : 0; + const maxSwipeDistance = cardWidth || revealWidth * 3; + const nextTranslateX = Math.min( + 0, + Math.max(-maxSwipeDistance, baseOffset + gestureState.dx), + ); + + translateX.value = nextTranslateX; + }, + onPanResponderRelease: (_, gestureState) => { + const baseOffset = isOpen ? -revealWidth : 0; + const draggedDistance = baseOffset + gestureState.dx; + const openThreshold = cardWidth ? cardWidth * 0.3 : revealWidth; + const deleteThreshold = cardWidth ? cardWidth * 0.6 : revealWidth * 2; + + if (draggedDistance <= -deleteThreshold) { + translateX.value = withTiming(fullSwipeDistance, { + duration: TRANSITION_DURATION, + }); + handleDeleteAction(item); + return; + } + + if (draggedDistance <= -openThreshold) { + openItem(); + return; + } + + closeItem(); + }, + onPanResponderTerminate: () => { + closeItem(); + }, + onPanResponderTerminationRequest: () => true, + }), + [cardWidth, closeItem, fullSwipeDistance, handleDeleteAction, isOpen, item, onOpenSwipe, openItem, revealWidth, translateX], + ); + return ( - { - handleClick(item); - }}> - - {/* Share Icon */} - - - {/* title */} - {item?.title} - - - {item?.message} {''} - - - Received at: {''} - {formatTimeWithDate(item?.timestamp)} - - - - setCardWidth(event.nativeEvent.layout.width)}> + + { - Alert.alert( - 'Alert', - 'Are you sure you want to delete this notification.', - [ - { - text: 'Cancel', - onPress: () => console.log('Cancel Pressed'), - style: 'cancel', - }, - { - text: 'OK', - onPress: () => { - // delete notification api - handleDeleteAction(item); - }, - }, - ], - {cancelable: false}, - ); - }}> - - + handleDeleteAction(item); + }} + style={styles.deleteActionButton}> + + Delete + - + + + { + if (isOpen) { + closeItem(); + return; + } + + handleClick(item); + }} + style={styles.cardContainer}> + + {item?.title} + + + {item?.message} {' '} + + + Received at: {' '} + {formatTimeWithDate(item?.timestamp)} + + + + + ); } const styles = StyleSheet.create({ + wrapper: { + width: '100%', + marginVertical: 4, + position: 'relative', + overflow: 'hidden', + borderRadius: 12, + }, + cardShell: { + width: '100%', + backgroundColor: 'white', + borderRadius: 12, + }, cardContainer: { - flex: 0, width: '100%', + minHeight: 92, maxHeight: 360, backgroundColor: 'white', flexDirection: 'row', - marginVertical: 4, overflow: 'hidden', elevation: 4, padding: wp(2.5), - borderRadius: 12, }, - image: { - flex: 0.8, - resizeMode: 'cover', - }, - - likeSaveContainer: { - flexDirection: 'row', - width: '100%', - marginTop: 6, - justifyContent: 'space-between', - }, - - likeSaveChildContainer: { - flexDirection: 'row', - justifyContent: 'flex-start', - marginHorizontal: hp(0), - marginVertical: hp(1), - }, textContainer: { flex: 1, backgroundColor: 'white', @@ -127,31 +216,39 @@ const styles = StyleSheet.create({ fontSize: fp(4), fontWeight: '500', lineHeight: 18, - color: '#121a26', + color: '#121a26', marginBottom: 10, fontFamily: 'monospace', }, footerText: { fontSize: fp(3.3), fontWeight: '600', - color: '#778599', - + color: '#778599', marginBottom: 3, }, - - footerContainer: { - flex: 0, - width: '100%', - flexDirection: 'row', - justifyContent: 'space-between', + deleteActionContainer: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + backgroundColor: '#d64545', + borderRadius: 12, + justifyContent: 'center', + alignItems: 'flex-end', + }, + deleteActionButton: { + minWidth: 44, + minHeight: 44, + paddingHorizontal: 16, + paddingVertical: 12, + justifyContent: 'center', alignItems: 'center', - marginTop: 4, + flexDirection: 'row', }, - shareIconContainer: { - position: 'absolute', - top: 2, - right: 1, - zIndex: 1, + deleteActionText: { + color: '#fff', + fontSize: fp(3.4), + fontWeight: '700', + marginLeft: 8, }, - }); diff --git a/frontend/src/screens/NotificationScreen.tsx b/frontend/src/screens/NotificationScreen.tsx index 25c4864a5..47f0c22a6 100644 --- a/frontend/src/screens/NotificationScreen.tsx +++ b/frontend/src/screens/NotificationScreen.tsx @@ -1,5 +1,5 @@ import {FlatList, StyleSheet, Text, View, Image} from 'react-native'; -import React, {useEffect} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {ON_PRIMARY_COLOR, PRIMARY_COLOR} from '../helper/Theme'; import NotificationItem from '../components/NotificationItem'; import {useDispatch, useSelector} from 'react-redux'; @@ -12,6 +12,14 @@ import {useGetAllNotifications} from '../hooks/useGetAllNotifications'; import {useMarkNotificationAsRead} from '../hooks/useMarkNoticationAsRead'; import {useDeleteNotification} from '../hooks/useDeleteNotification'; +type PendingDelete = { + item: Notification; + index: number; + timer: ReturnType; +}; + +const UNDO_TIMEOUT_MS = 3500; + // PodcastsScreen component displays the list of podcasts and includes a PodcastPlayer const NotificationScreen = ({navigation}: any) => { //const notifications = []; @@ -21,11 +29,14 @@ const NotificationScreen = ({navigation}: any) => { const [totalPages, setTotalPages] = React.useState(0); const {isConnected} = useSelector((state: any) => state.network); const [notificationsData, setNotificationsData] = - React.useState(); + React.useState([]); + const [openSwipeItemId, setOpenSwipeItemId] = useState(null); + const pendingDeletesRef = useRef>(new Map()); + const isMountedRef = useRef(true); const dispatch = useDispatch(); const {mutate: markNotification} = useMarkNotificationAsRead(); - const {mutate: deleteNotification, isPending} = useDeleteNotification(); + const {mutate: deleteNotification} = useDeleteNotification(); const { data: notificationsRes, @@ -53,6 +64,16 @@ const NotificationScreen = ({navigation}: any) => { } }, [notificationsRes, page]); + useEffect(() => { + return () => { + isMountedRef.current = false; + pendingDeletesRef.current.forEach(pendingDelete => { + clearTimeout(pendingDelete.timer); + deleteNotification(pendingDelete.item._id); + }); + pendingDeletesRef.current.clear(); + }; + }, [deleteNotification]); useEffect(() => { if (isConnected) { @@ -86,6 +107,142 @@ const NotificationScreen = ({navigation}: any) => { setRefreshing(false); }; + const restoreDeletedNotification = useCallback( + (snapshot: PendingDelete) => { + setNotificationsData(previous => { + const current = previous ?? []; + + if (current.some(notification => notification._id === snapshot.item._id)) { + return current; + } + + const nextNotifications = [...current]; + const insertionIndex = Math.min(snapshot.index, nextNotifications.length); + nextNotifications.splice(insertionIndex, 0, snapshot.item); + return nextNotifications; + }); + }, + [], + ); + + const clearPendingDelete = useCallback((id: string) => { + const pendingDelete = pendingDeletesRef.current.get(id); + + if (pendingDelete) { + clearTimeout(pendingDelete.timer); + pendingDeletesRef.current.delete(id); + } + }, []); + + const commitDeleteNotification = useCallback( + (snapshot: PendingDelete) => { + deleteNotification(snapshot.item._id, { + onSuccess: () => { + pendingDeletesRef.current.delete(snapshot.item._id); + + if (isMountedRef.current) { + refetch(); + } + }, + + onError: error => { + console.log(error); + pendingDeletesRef.current.delete(snapshot.item._id); + + if (isMountedRef.current) { + restoreDeletedNotification(snapshot); + Snackbar.show({ + text: 'Internal server error, failed to delete notification!', + duration: Snackbar.LENGTH_SHORT, + }); + } + }, + }); + }, + [deleteNotification, refetch, restoreDeletedNotification], + ); + + const handleDeleteAction = useCallback( + (item: Notification) => { + console.log('Notification ID', item?._id); + + if (!isConnected) { + Snackbar.show({ + text: 'Please check your internet connection', + duration: Snackbar.LENGTH_SHORT, + }); + return; + } + + if (pendingDeletesRef.current.has(item._id)) { + return; + } + + setOpenSwipeItemId(previous => (previous === item._id ? null : previous)); + + let snapshot: Omit | null = null; + + setNotificationsData(previous => { + const current = previous ?? []; + const index = current.findIndex(notification => notification._id === item._id); + + if (index === -1) { + return current; + } + + snapshot = { + item, + index, + }; + + return current.filter(notification => notification._id !== item._id); + }); + + if (!snapshot) { + return; + } + + const timer = setTimeout(() => { + const pendingDelete = pendingDeletesRef.current.get(item._id); + + if (!pendingDelete) { + return; + } + + commitDeleteNotification(pendingDelete); + }, UNDO_TIMEOUT_MS); + + pendingDeletesRef.current.set(item._id, { + ...snapshot, + timer, + }); + + Snackbar.show({ + text: 'Notification deleted', + duration: Snackbar.LENGTH_LONG, + action: { + text: 'UNDO', + textColor: '#ffffff', + onPress: () => { + const pendingDelete = pendingDeletesRef.current.get(item._id); + + if (!pendingDelete) { + return; + } + + clearPendingDelete(item._id); + restoreDeletedNotification(pendingDelete); + Snackbar.show({ + text: 'Deletion undone', + duration: Snackbar.LENGTH_SHORT, + }); + }, + }, + }); + }, + [clearPendingDelete, commitDeleteNotification, isConnected, restoreDeletedNotification], + ); + const handleNotificationClick = (item: Notification) => { if ( (item.type === NotificationType.PodcastCommentMention || @@ -170,40 +327,16 @@ const NotificationScreen = ({navigation}: any) => { item={item} handleDeleteAction={handleDeleteAction} handleClick={handleNotificationClick} + isOpen={openSwipeItemId === item._id} + onOpenSwipe={setOpenSwipeItemId} + onCloseSwipe={id => { + setOpenSwipeItemId(previous => (previous === id ? null : previous)); + }} /> ); }; - const handleDeleteAction = (item: Notification) => { - console.log('Notification ID', item?._id); - - if (isConnected) { - deleteNotification(item._id, { - onSuccess: async () => { - refetch(); - Snackbar.show({ - text: 'Notification deleted', - duration: Snackbar.LENGTH_SHORT, - }); - }, - - onError: error => { - console.log(error); - Snackbar.show({ - text: 'Internal server error, failed to delete notification!', - duration: Snackbar.LENGTH_SHORT, - }); - }, - }); - } else { - Snackbar.show({ - text: 'Please check your internet connection', - duration: Snackbar.LENGTH_SHORT, - }); - } - }; - - if (isPending) { + if (isLoading && notificationsData.length === 0) { return ; }