Skip to content

Commit d880cc9

Browse files
committed
fix(mobile): resolve cache sync issues, modularize notes API, and optimize swipe actions
1 parent 6b2c7cf commit d880cc9

File tree

2 files changed

+123
-48
lines changed

2 files changed

+123
-48
lines changed

apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteListItem.tsx

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Ionicons } from '@expo/vector-icons';
22
import { Code2, Network } from 'lucide-react-native';
33
import React, { useMemo, useRef } from 'react';
4-
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
4+
import { ActivityIndicator, Animated, Pressable, StyleSheet, Text, View } from 'react-native';
55
import Swipeable from 'react-native-gesture-handler/ReanimatedSwipeable';
66
import Reanimated, { useAnimatedStyle } from 'react-native-reanimated';
77

@@ -29,6 +29,9 @@ interface NoteListItemProps {
2929
mutedColor: string;
3030
borderColor: string;
3131
backgroundColor: string;
32+
isDeleting?: boolean;
33+
isArchiving?: boolean;
34+
closeSwipeables?: number; // Increment this to close all swipeables
3235
}
3336

3437
const NoteListItemComponent: React.FC<NoteListItemProps> = ({
@@ -48,9 +51,19 @@ const NoteListItemComponent: React.FC<NoteListItemProps> = ({
4851
mutedColor,
4952
borderColor,
5053
backgroundColor,
54+
isDeleting = false,
55+
isArchiving = false,
56+
closeSwipeables = 0,
5157
}) => {
5258
const swipeableRef = useRef<Swipeable>(null);
5359

60+
// Close swipeable when closeSwipeables prop changes
61+
React.useEffect(() => {
62+
if (closeSwipeables > 0) {
63+
swipeableRef.current?.close();
64+
}
65+
}, [closeSwipeables]);
66+
5467
// Detect note type from content with memoization for performance
5568
// MUST be called before any conditional returns (React hooks rules)
5669
const noteType = useMemo(() => detectNoteType(note), [note]);
@@ -127,11 +140,17 @@ const NoteListItemComponent: React.FC<NoteListItemProps> = ({
127140
return (
128141
<Reanimated.View style={[styles.deleteAction, styleAnimation]}>
129142
<Reanimated.View style={[styles.swipeActionContent, iconOpacity]}>
130-
<Ionicons name="trash-outline" size={24} color="#fff" />
131-
</Reanimated.View>
132-
<Reanimated.View style={[styles.swipeActionContent, textOpacity]}>
133-
<Text style={styles.swipeActionText}>Delete</Text>
143+
{isDeleting ? (
144+
<ActivityIndicator size="small" color="#fff" />
145+
) : (
146+
<Ionicons name="trash-outline" size={24} color="#fff" />
147+
)}
134148
</Reanimated.View>
149+
{!isDeleting && (
150+
<Reanimated.View style={[styles.swipeActionContent, textOpacity]}>
151+
<Text style={styles.swipeActionText}>Delete</Text>
152+
</Reanimated.View>
153+
)}
135154
</Reanimated.View>
136155
);
137156
};
@@ -172,11 +191,17 @@ const NoteListItemComponent: React.FC<NoteListItemProps> = ({
172191
return (
173192
<Reanimated.View style={[styles.archiveAction, styleAnimation]}>
174193
<Reanimated.View style={[styles.swipeActionContent, iconOpacity]}>
175-
<Ionicons name="archive-outline" size={24} color="#fff" />
176-
</Reanimated.View>
177-
<Reanimated.View style={[styles.swipeActionContent, textOpacity]}>
178-
<Text style={styles.swipeActionText}>Archive</Text>
194+
{isArchiving ? (
195+
<ActivityIndicator size="small" color="#fff" />
196+
) : (
197+
<Ionicons name="archive-outline" size={24} color="#fff" />
198+
)}
179199
</Reanimated.View>
200+
{!isArchiving && (
201+
<Reanimated.View style={[styles.swipeActionContent, textOpacity]}>
202+
<Text style={styles.swipeActionText}>Archive</Text>
203+
</Reanimated.View>
204+
)}
180205
</Reanimated.View>
181206
);
182207
};
@@ -198,12 +223,10 @@ const NoteListItemComponent: React.FC<NoteListItemProps> = ({
198223
// direction 'right' = swiping right = archive (left actions opening)
199224
if (direction === 'right' && canArchive) {
200225
onArchive?.(note.id);
201-
// Close swipeable after action
202-
setTimeout(() => swipeableRef.current?.close(), 300);
226+
// Don't close swipeable - let the spinner stay visible until note is removed
203227
} else if (direction === 'left') {
204228
onDelete?.(note.id);
205-
// Close swipeable after action
206-
setTimeout(() => swipeableRef.current?.close(), 300);
229+
// Don't close swipeable - let the spinner stay visible until note is removed
207230
}
208231
}}
209232
>
@@ -264,6 +287,7 @@ const NoteListItemComponent: React.FC<NoteListItemProps> = ({
264287
</View>
265288
</Pressable>
266289
</Swipeable>
290+
267291
{!isLastItem && <View style={[styles.noteListDivider, { backgroundColor: borderColor }]} />}
268292
</View>
269293
);
@@ -334,8 +358,7 @@ const styles = StyleSheet.create({
334358
fontWeight: '400',
335359
},
336360
noteListDivider: {
337-
// @ts-ignore - StyleSheet.hairlineWidth is intentionally used for height (ultra-thin divider)
338-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
361+
// StyleSheet.hairlineWidth is intentionally used for height (ultra-thin divider)
339362
height: StyleSheet.hairlineWidth,
340363
marginHorizontal: 16,
341364
marginVertical: 0,

apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx

Lines changed: 85 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@ import { FlashList, type FlashList as FlashListType } from '@shopify/flash-list'
77
import * as Haptics from 'expo-haptics';
88
import { useRouter } from 'expo-router';
99
import React, { useCallback,useEffect, useMemo, useRef, useState } from 'react';
10-
import { ActivityIndicator, Alert, Animated, DeviceEventEmitter, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
10+
import { ActivityIndicator, Alert, Animated, DeviceEventEmitter, InteractionManager, Pressable, RefreshControl, StyleSheet, View } from 'react-native';
1111
import { useSafeAreaInsets } from 'react-native-safe-area-context';
1212

1313
import { type Folder, type Note, useApiService } from '@/src/services/api';
1414
import { useTheme } from '@/src/theme';
15-
import { detectNoteType } from '@/src/utils/noteTypeDetection';
16-
import { stripHtmlTags } from '@/src/utils/noteUtils';
1715

1816
// Constants for FAB scroll behavior
1917
const FAB_SCROLL_THRESHOLD_START = 100; // Start showing FAB when scrolled past this
@@ -58,6 +56,9 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa
5856
const { folderId, viewType, searchQuery } = route?.params || {};
5957

6058
const [refreshing, setRefreshing] = useState(false);
59+
const [deletingNoteId, setDeletingNoteId] = useState<string | null>(null);
60+
const [archivingNoteId, setArchivingNoteId] = useState<string | null>(null);
61+
const [closeSwipeables, setCloseSwipeables] = useState(0);
6162

6263
// Performance tracking
6364
const screenFocusTime = useRef<number>(0);
@@ -203,6 +204,11 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa
203204
if (flatListRef.current) {
204205
flatListRef.current.scrollToOffset({ offset: 0, animated: false });
205206
}
207+
208+
// Cleanup function - close all swipeables when navigating away
209+
return () => {
210+
setCloseSwipeables(prev => prev + 1);
211+
};
206212
// eslint-disable-next-line react-hooks/exhaustive-deps
207213
}, [folderId, viewType, searchQuery])
208214
);
@@ -225,7 +231,18 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa
225231
const onRefresh = async () => {
226232
try {
227233
setRefreshing(true);
234+
235+
// Save current scroll position before refresh
236+
const currentOffset = scrollY._value || 0;
237+
228238
await loadNotes(true);
239+
240+
// Restore scroll position after a small delay to let FlashList settle
241+
if (currentOffset > 0) {
242+
setTimeout(() => {
243+
flatListRef.current?.scrollToOffset({ offset: currentOffset, animated: false });
244+
}, 50);
245+
}
229246
} finally {
230247
setRefreshing(false);
231248
}
@@ -476,40 +493,71 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa
476493
}, [api, notes, setNotes, loadNotes]);
477494

478495
// Handle delete note (move to trash)
479-
const handleDeleteNote = useCallback(async (noteId: string) => {
480-
try {
481-
// Update note as deleted FIRST
482-
await api.updateNote(noteId, { deleted: true });
496+
const handleDeleteNote = useCallback((noteId: string) => {
497+
// Fire haptic feedback immediately
498+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
483499

484-
// Emit event - the event listener will handle removing from UI
485-
DeviceEventEmitter.emit('noteDeleted', noteId);
486-
} catch (error) {
487-
console.error('Failed to delete note:', error);
488-
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
489-
Alert.alert('Error', 'Failed to delete note. Please try again.');
490-
}
500+
// Set deleting state IMMEDIATELY to show spinner
501+
setDeletingNoteId(noteId);
502+
503+
// Run API call after interactions complete (non-blocking)
504+
InteractionManager.runAfterInteractions(async () => {
505+
try {
506+
// Update note as deleted
507+
await api.updateNote(noteId, { deleted: true });
508+
509+
// Success haptic feedback
510+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
511+
512+
// Keep spinner visible briefly before removing note
513+
await new Promise(resolve => setTimeout(resolve, 200));
514+
515+
// Emit event - the event listener will handle removing from UI
516+
DeviceEventEmitter.emit('noteDeleted', noteId);
517+
} catch (error) {
518+
console.error('Failed to delete note:', error);
519+
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
520+
Alert.alert('Error', 'Failed to delete note. Please try again.');
521+
} finally {
522+
setDeletingNoteId(null);
523+
}
524+
});
491525
}, [api]);
492526

493527
// Handle archive note
494-
const handleArchiveNote = useCallback(async (noteId: string) => {
495-
try {
496-
// Update note as archived FIRST
497-
await api.updateNote(noteId, { archived: true });
528+
const handleArchiveNote = useCallback((noteId: string) => {
529+
// Fire haptic feedback immediately
530+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
498531

499-
// Success haptic feedback
500-
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
532+
// Set archiving state IMMEDIATELY to show spinner
533+
setArchivingNoteId(noteId);
501534

502-
// Remove from current view immediately (archived notes don't show in folder/all views)
503-
setNotes(prevNotes => prevNotes.filter(n => n.id !== noteId));
504-
lastOptimisticUpdateRef.current = Date.now();
535+
// Run API call after interactions complete (non-blocking)
536+
InteractionManager.runAfterInteractions(async () => {
537+
try {
538+
// Update note as archived
539+
await api.updateNote(noteId, { archived: true });
505540

506-
// Emit event for other listeners
507-
DeviceEventEmitter.emit('noteUpdated', { id: noteId, archived: true });
508-
} catch (error) {
509-
console.error('Failed to archive note:', error);
510-
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
511-
Alert.alert('Error', 'Failed to archive note. Please try again.');
512-
}
541+
// Success haptic feedback
542+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
543+
544+
// Keep spinner visible briefly before removing note
545+
await new Promise(resolve => setTimeout(resolve, 200));
546+
547+
// Remove from current view immediately (archived notes don't show in folder/all views)
548+
setNotes(prevNotes => prevNotes.filter(n => n.id !== noteId));
549+
lastOptimisticUpdateRef.current = Date.now();
550+
551+
// Emit event for other listeners
552+
DeviceEventEmitter.emit('noteUpdated', { id: noteId, archived: true });
553+
} catch (error) {
554+
console.error('Failed to archive note:', error);
555+
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
556+
Alert.alert('Error', 'Failed to archive note. Please try again.');
557+
} finally {
558+
setArchivingNoteId(null);
559+
}
560+
});
513561
}, [api, setNotes]);
514562

515563
// Render individual note item
@@ -532,9 +580,12 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa
532580
mutedColor={mutedColor}
533581
borderColor={borderColor}
534582
backgroundColor={backgroundColor}
583+
isDeleting={deletingNoteId === note.id}
584+
isArchiving={archivingNoteId === note.id}
585+
closeSwipeables={closeSwipeables}
535586
/>
536587
);
537-
}, [filteredNotes.length, folderId, handleNotePress, handleNoteLongPress, handleDeleteNote, handleArchiveNote, folderPathsMap, foldersMap, skeletonOpacity, notesEnhancedDataCache, foregroundColor, mutedForegroundColor, mutedColor, borderColor, backgroundColor]);
588+
}, [filteredNotes.length, folderId, handleNotePress, handleNoteLongPress, handleDeleteNote, handleArchiveNote, folderPathsMap, foldersMap, skeletonOpacity, notesEnhancedDataCache, foregroundColor, mutedForegroundColor, mutedColor, borderColor, backgroundColor, deletingNoteId, archivingNoteId, closeSwipeables]);
538589

539590
// Render list header (subfolders and create note button)
540591
const renderListHeader = useCallback(() => {
@@ -607,14 +658,15 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa
607658
onRefresh={onRefresh}
608659
tintColor={theme.isDark ? '#666666' : '#000000'}
609660
colors={[theme.isDark ? '#666666' : '#000000']}
661+
progressViewOffset={insets.top}
610662
/>
611663
}
612664
drawDistance={2000}
613-
getItemType={(item) => {
665+
getItemType={() => {
614666
// Help FlashList recycle items better
615667
return 'note';
616668
}}
617-
overrideItemLayout={(layout, item) => {
669+
overrideItemLayout={(layout) => {
618670
// Fixed layout for better scrolling performance
619671
// Padding: 12*2=24, Header: 23+8, Preview: 40+6, Meta: 20, Divider: 1
620672
layout.size = 112;

0 commit comments

Comments
 (0)