dpv007 commited on
Commit
b5010b0
·
1 Parent(s): e179c31

Fix storage stats bar and increase MediaLibrary photo fetch limit for WhatsApp

Browse files
keystone-app/app/(tabs)/index.tsx CHANGED
@@ -12,9 +12,10 @@ import {
12
  ActivityIndicator,
13
  Modal,
14
  AppState,
15
- FlatList,
16
  RefreshControl,
 
17
  } from 'react-native';
 
18
  import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
19
  import * as MediaLibrary from 'expo-media-library';
20
  import { Image } from 'expo-image';
@@ -105,6 +106,7 @@ const GalleryRow = React.memo(({
105
  contentFit="cover"
106
  cachePolicy="memory-disk"
107
  transition={200}
 
108
  backgroundColor="transparent"
109
  />
110
  {isVideo && (
@@ -178,7 +180,8 @@ export default function PhotosScreen() {
178
  const [permissionResponse, setPermissionResponse] = useState<any>(null);
179
 
180
  // Data
181
- const [sections, setSections] = useState<SectionData[]>([]);
 
182
  const [allAssets, setAllAssets] = useState<UnifiedAsset[]>([]);
183
  const [isLoading, setIsLoading] = useState(true);
184
  const [isRefreshing, setIsRefreshing] = useState(false);
@@ -267,7 +270,7 @@ export default function PhotosScreen() {
267
  }, []);
268
 
269
  // ── Load media ────────────────────────────────────────────────
270
- const groupAssetsByDate = useCallback((assets: UnifiedAsset[]): SectionData[] => {
271
  const groups: Record<string, UnifiedAsset[]> = {};
272
  for (const asset of assets) {
273
  const date = new Date(asset.creationTime);
@@ -275,12 +278,21 @@ export default function PhotosScreen() {
275
  if (!groups[key]) groups[key] = [];
276
  groups[key].push(asset);
277
  }
278
- return Object.keys(groups).map(key => {
279
- const rows: UnifiedAsset[][] = [];
280
- for (let i = 0; i < groups[key].length; i += NUM_COLUMNS)
281
- rows.push(groups[key].slice(i, i + NUM_COLUMNS));
282
- return { title: key, data: rows };
283
- });
 
 
 
 
 
 
 
 
 
284
  }, []);
285
 
286
  const getTrashedAssets = async (): Promise<UnifiedAsset[]> => {
@@ -301,7 +313,9 @@ export default function PhotosScreen() {
301
 
302
  // Paint cached assets immediately for instant UI
303
  setAllAssets(parsedAssets);
304
- setSections(groupAssetsByDate(parsedAssets));
 
 
305
  setIsLoading(false); // hide spinner right away
306
  hasCached = true;
307
 
@@ -325,7 +339,9 @@ export default function PhotosScreen() {
325
  return asset;
326
  });
327
  setAllAssets(refreshed);
328
- setSections(groupAssetsByDate(refreshed));
 
 
329
 
330
  // Prefetch cloud-only thumbnails in background
331
  const cloudThumbUrls = refreshed
@@ -346,11 +362,22 @@ export default function PhotosScreen() {
346
  const token = await getToken();
347
 
348
  // 1. Fetch Local
349
- const media = await MediaLibrary.getAssetsAsync({
350
- first: 800,
351
- sortBy: [MediaLibrary.SortBy.creationTime],
352
- mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
353
- });
 
 
 
 
 
 
 
 
 
 
 
354
 
355
  // 2. Fetch Cloud
356
  let cloudAssets: any[] = [];
@@ -373,7 +400,7 @@ export default function PhotosScreen() {
373
  const unified: UnifiedAsset[] = [];
374
  const localFilenames = new Set();
375
 
376
- for (const a of media.assets) {
377
  if (trashedIds.includes(a.id)) continue;
378
 
379
  const isSynced = syncedIds.includes(a.id);
@@ -422,7 +449,9 @@ export default function PhotosScreen() {
422
  unified.sort((a, b) => b.creationTime - a.creationTime);
423
 
424
  setAllAssets(unified);
425
- setSections(groupAssetsByDate(unified));
 
 
426
 
427
  // Save to cache silently
428
  AsyncStorage.setItem(CACHED_ASSETS_KEY, JSON.stringify(unified)).catch(() => {});
@@ -443,7 +472,8 @@ export default function PhotosScreen() {
443
  console.error('loadAssets error', e);
444
  setIsLoading(false);
445
  }
446
- }, [groupAssetsByDate]);
 
447
 
448
 
449
 
@@ -607,8 +637,18 @@ export default function PhotosScreen() {
607
  };
608
 
609
  // Select / deselect all assets in a given section title
610
- const toggleDaySelection = useCallback((section: SectionData) => {
611
- const ids = section.data.flat().map(a => a.id);
 
 
 
 
 
 
 
 
 
 
612
  setSelectedIds(prev => {
613
  const next = new Set(prev);
614
  const allSelected = ids.every(id => next.has(id));
@@ -621,10 +661,19 @@ export default function PhotosScreen() {
621
  });
622
  }, []);
623
 
624
- const isDayFullySelected = useCallback((section: SectionData) => {
625
- const ids = section.data.flat().map(a => a.id);
 
 
 
 
 
 
 
 
 
626
  return ids.length > 0 && ids.every(id => selectedIds.has(id));
627
- }, [selectedIds]);
628
 
629
  const openViewer = (asset: UnifiedAsset) => {
630
  if (isSelecting) { toggleSelect(asset.id); return; }
@@ -667,14 +716,14 @@ export default function PhotosScreen() {
667
  />
668
  ), [selectedIds, isSelecting, openViewer, toggleSelect]);
669
 
670
- const renderSectionHeader = useCallback(({ section }: { section: SectionData }) => {
671
- const allDaySelected = isDayFullySelected(section);
672
  return (
673
  <View style={styles.sectionHeader}>
674
- <Text style={styles.sectionTitle}>{section.title}</Text>
675
  {isSelecting && (
676
  <TouchableOpacity
677
- onPress={() => toggleDaySelection(section)}
678
  style={styles.dayCheckBtn}
679
  hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
680
  >
@@ -688,6 +737,19 @@ export default function PhotosScreen() {
688
  </View>
689
  );
690
  }, [isSelecting, isDayFullySelected, toggleDaySelection]);
 
 
 
 
 
 
 
 
 
 
 
 
 
691
 
692
  const renderLoadingSkeleton = () => (
693
  <View style={{ flex: 1, padding: 24, justifyContent: 'center', alignItems: 'center' }}>
@@ -779,29 +841,28 @@ export default function PhotosScreen() {
779
 
780
  {/* ── Gallery ── */}
781
  {isLoading ? renderLoadingSkeleton() : (
782
- <SectionList
783
- sections={sections}
784
- keyExtractor={(item, idx) => `row-${idx}-${item[0]?.id}`}
785
- renderItem={renderRow}
786
- renderSectionHeader={renderSectionHeader}
787
- showsVerticalScrollIndicator={false}
788
- contentContainerStyle={styles.listContent}
789
- stickySectionHeadersEnabled={false}
790
- onScroll={(e) => { listOffsetYRef.current = e.nativeEvent.contentOffset.y; }}
791
- scrollEventThrottle={16}
792
- initialNumToRender={10}
793
- maxToRenderPerBatch={10}
794
- windowSize={5}
795
- removeClippedSubviews={true}
796
- refreshControl={
797
- <RefreshControl
798
- refreshing={isRefreshing}
799
- onRefresh={onRefresh}
800
- tintColor="#1a73e8"
801
- colors={['#1a73e8']}
802
- />
803
- }
804
- />
805
  )}
806
 
807
  {/* ── Full-screen viewer modal ── */}
 
12
  ActivityIndicator,
13
  Modal,
14
  AppState,
 
15
  RefreshControl,
16
+ Animated,
17
  } from 'react-native';
18
+ import { FlashList } from '@shopify/flash-list';
19
  import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
20
  import * as MediaLibrary from 'expo-media-library';
21
  import { Image } from 'expo-image';
 
106
  contentFit="cover"
107
  cachePolicy="memory-disk"
108
  transition={200}
109
+ allowDownscaling={true}
110
  backgroundColor="transparent"
111
  />
112
  {isVideo && (
 
180
  const [permissionResponse, setPermissionResponse] = useState<any>(null);
181
 
182
  // Data
183
+ const [flattenedData, setFlattenedData] = useState<any[]>([]);
184
+ const [stickyIndices, setStickyIndices] = useState<number[]>([]);
185
  const [allAssets, setAllAssets] = useState<UnifiedAsset[]>([]);
186
  const [isLoading, setIsLoading] = useState(true);
187
  const [isRefreshing, setIsRefreshing] = useState(false);
 
270
  }, []);
271
 
272
  // ── Load media ────────────────────────────────────────────────
273
+ const flattenAssets = useCallback((assets: UnifiedAsset[]) => {
274
  const groups: Record<string, UnifiedAsset[]> = {};
275
  for (const asset of assets) {
276
  const date = new Date(asset.creationTime);
 
278
  if (!groups[key]) groups[key] = [];
279
  groups[key].push(asset);
280
  }
281
+
282
+ const flat: any[] = [];
283
+ const indices: number[] = [];
284
+ let currentIndex = 0;
285
+
286
+ for (const key of Object.keys(groups)) {
287
+ flat.push({ type: 'header', title: key });
288
+ indices.push(currentIndex);
289
+ currentIndex++;
290
+ for (let i = 0; i < groups[key].length; i += NUM_COLUMNS) {
291
+ flat.push({ type: 'row', data: groups[key].slice(i, i + NUM_COLUMNS) });
292
+ currentIndex++;
293
+ }
294
+ }
295
+ return { flat, indices };
296
  }, []);
297
 
298
  const getTrashedAssets = async (): Promise<UnifiedAsset[]> => {
 
313
 
314
  // Paint cached assets immediately for instant UI
315
  setAllAssets(parsedAssets);
316
+ const { flat, indices } = flattenAssets(parsedAssets);
317
+ setFlattenedData(flat);
318
+ setStickyIndices(indices);
319
  setIsLoading(false); // hide spinner right away
320
  hasCached = true;
321
 
 
339
  return asset;
340
  });
341
  setAllAssets(refreshed);
342
+ const { flat, indices } = flattenAssets(refreshed);
343
+ setFlattenedData(flat);
344
+ setStickyIndices(indices);
345
 
346
  // Prefetch cloud-only thumbnails in background
347
  const cloudThumbUrls = refreshed
 
362
  const token = await getToken();
363
 
364
  // 1. Fetch Local
365
+ let localAssets: MediaLibrary.Asset[] = [];
366
+ let hasNextPage = true;
367
+ let after: string | undefined = undefined;
368
+
369
+ while (hasNextPage) {
370
+ const mediaPage = await MediaLibrary.getAssetsAsync({
371
+ first: 1500,
372
+ after,
373
+ sortBy: [MediaLibrary.SortBy.creationTime],
374
+ mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
375
+ });
376
+ localAssets.push(...mediaPage.assets);
377
+ hasNextPage = mediaPage.hasNextPage;
378
+ after = mediaPage.endCursor;
379
+ if (localAssets.length >= 10000) break; // Safe limit
380
+ }
381
 
382
  // 2. Fetch Cloud
383
  let cloudAssets: any[] = [];
 
400
  const unified: UnifiedAsset[] = [];
401
  const localFilenames = new Set();
402
 
403
+ for (const a of localAssets) {
404
  if (trashedIds.includes(a.id)) continue;
405
 
406
  const isSynced = syncedIds.includes(a.id);
 
449
  unified.sort((a, b) => b.creationTime - a.creationTime);
450
 
451
  setAllAssets(unified);
452
+ const { flat, indices } = flattenAssets(unified);
453
+ setFlattenedData(flat);
454
+ setStickyIndices(indices);
455
 
456
  // Save to cache silently
457
  AsyncStorage.setItem(CACHED_ASSETS_KEY, JSON.stringify(unified)).catch(() => {});
 
472
  console.error('loadAssets error', e);
473
  setIsLoading(false);
474
  }
475
+ }
476
+ }, [flattenAssets]);
477
 
478
 
479
 
 
637
  };
638
 
639
  // Select / deselect all assets in a given section title
640
+ const toggleDaySelection = useCallback((title: string) => {
641
+ // Find all assets in flattenedData under this header
642
+ const ids: string[] = [];
643
+ let collecting = false;
644
+ for (const item of flattenedData) {
645
+ if (item.type === 'header') {
646
+ if (collecting) break; // Reached next header
647
+ if (item.title === title) collecting = true;
648
+ } else if (item.type === 'row' && collecting) {
649
+ item.data.forEach((a: UnifiedAsset) => ids.push(a.id));
650
+ }
651
+ }
652
  setSelectedIds(prev => {
653
  const next = new Set(prev);
654
  const allSelected = ids.every(id => next.has(id));
 
661
  });
662
  }, []);
663
 
664
+ const isDayFullySelected = useCallback((title: string) => {
665
+ const ids: string[] = [];
666
+ let collecting = false;
667
+ for (const item of flattenedData) {
668
+ if (item.type === 'header') {
669
+ if (collecting) break;
670
+ if (item.title === title) collecting = true;
671
+ } else if (item.type === 'row' && collecting) {
672
+ item.data.forEach((a: UnifiedAsset) => ids.push(a.id));
673
+ }
674
+ }
675
  return ids.length > 0 && ids.every(id => selectedIds.has(id));
676
+ }, [selectedIds, flattenedData]);
677
 
678
  const openViewer = (asset: UnifiedAsset) => {
679
  if (isSelecting) { toggleSelect(asset.id); return; }
 
716
  />
717
  ), [selectedIds, isSelecting, openViewer, toggleSelect]);
718
 
719
+ const renderSectionHeader = useCallback((title: string) => {
720
+ const allDaySelected = isDayFullySelected(title);
721
  return (
722
  <View style={styles.sectionHeader}>
723
+ <Text style={styles.sectionTitle}>{title}</Text>
724
  {isSelecting && (
725
  <TouchableOpacity
726
+ onPress={() => toggleDaySelection(title)}
727
  style={styles.dayCheckBtn}
728
  hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
729
  >
 
737
  </View>
738
  );
739
  }, [isSelecting, isDayFullySelected, toggleDaySelection]);
740
+
741
+ const renderItem = useCallback(({ item }: { item: any }) => {
742
+ if (item.type === 'header') {
743
+ return renderSectionHeader(item.title);
744
+ }
745
+ return <GalleryRow
746
+ row={item.data}
747
+ selectedIds={selectedIds}
748
+ isSelecting={isSelecting}
749
+ openViewer={openViewer}
750
+ toggleSelect={toggleSelect}
751
+ />;
752
+ }, [renderSectionHeader, selectedIds, isSelecting, openViewer, toggleSelect]);
753
 
754
  const renderLoadingSkeleton = () => (
755
  <View style={{ flex: 1, padding: 24, justifyContent: 'center', alignItems: 'center' }}>
 
841
 
842
  {/* ── Gallery ── */}
843
  {isLoading ? renderLoadingSkeleton() : (
844
+ <View style={{ flex: 1 }}>
845
+ <FlashList
846
+ data={flattenedData}
847
+ keyExtractor={(_, idx) => `item-${idx}`}
848
+ renderItem={renderItem}
849
+ getItemType={(item) => item.type}
850
+ stickyHeaderIndices={stickyIndices}
851
+ showsVerticalScrollIndicator={false}
852
+ contentContainerStyle={styles.listContent}
853
+ onScroll={(e) => { listOffsetYRef.current = e.nativeEvent.contentOffset.y; }}
854
+ scrollEventThrottle={16}
855
+ estimatedItemSize={120}
856
+ refreshControl={
857
+ <RefreshControl
858
+ refreshing={isRefreshing}
859
+ onRefresh={onRefresh}
860
+ tintColor="#1a73e8"
861
+ colors={['#1a73e8']}
862
+ />
863
+ }
864
+ />
865
+ </View>
 
866
  )}
867
 
868
  {/* ── Full-screen viewer modal ── */}
keystone-app/app/utils/syncManager.ts CHANGED
@@ -98,11 +98,23 @@ export const runSyncCycle = async (callbacks?: SyncCallbacks) => {
98
  return false;
99
  }
100
 
101
- media = await MediaLibrary.getAssetsAsync({
102
- first: 800,
103
- sortBy: [MediaLibrary.SortBy.creationTime],
104
- mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
105
- });
 
 
 
 
 
 
 
 
 
 
 
 
106
  } catch (e) {
107
  callbacks?.onInterrupted?.('Media Library Error');
108
  return false;
 
98
  return false;
99
  }
100
 
101
+ let localAssets: MediaLibrary.Asset[] = [];
102
+ let hasNextPage = true;
103
+ let after: string | undefined = undefined;
104
+
105
+ while (hasNextPage) {
106
+ const mediaPage = await MediaLibrary.getAssetsAsync({
107
+ first: 1500,
108
+ after,
109
+ sortBy: [MediaLibrary.SortBy.creationTime],
110
+ mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
111
+ });
112
+ localAssets.push(...mediaPage.assets);
113
+ hasNextPage = mediaPage.hasNextPage;
114
+ after = mediaPage.endCursor;
115
+ if (localAssets.length >= 10000) break; // Safe limit
116
+ }
117
+ media = { assets: localAssets } as any;
118
  } catch (e) {
119
  callbacks?.onInterrupted?.('Media Library Error');
120
  return false;
keystone-app/package-lock.json CHANGED
@@ -11,6 +11,7 @@
11
  "@expo/metro-runtime": "~6.1.2",
12
  "@expo/vector-icons": "^15.0.3",
13
  "@react-native-async-storage/async-storage": "2.2.0",
 
14
  "date-fns": "^4.4.0",
15
  "expo": "^54.0.36",
16
  "expo-av": "~16.0.8",
@@ -3706,6 +3707,20 @@
3706
  "nanoid": "^3.3.11"
3707
  }
3708
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3709
  "node_modules/@sinclair/typebox": {
3710
  "version": "0.27.12",
3711
  "license": "MIT"
 
11
  "@expo/metro-runtime": "~6.1.2",
12
  "@expo/vector-icons": "^15.0.3",
13
  "@react-native-async-storage/async-storage": "2.2.0",
14
+ "@shopify/flash-list": "2.0.2",
15
  "date-fns": "^4.4.0",
16
  "expo": "^54.0.36",
17
  "expo-av": "~16.0.8",
 
3707
  "nanoid": "^3.3.11"
3708
  }
3709
  },
3710
+ "node_modules/@shopify/flash-list": {
3711
+ "version": "2.0.2",
3712
+ "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.0.2.tgz",
3713
+ "integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==",
3714
+ "license": "MIT",
3715
+ "dependencies": {
3716
+ "tslib": "2.8.1"
3717
+ },
3718
+ "peerDependencies": {
3719
+ "@babel/runtime": "*",
3720
+ "react": "*",
3721
+ "react-native": "*"
3722
+ }
3723
+ },
3724
  "node_modules/@sinclair/typebox": {
3725
  "version": "0.27.12",
3726
  "license": "MIT"
keystone-app/package.json CHANGED
@@ -6,6 +6,7 @@
6
  "@expo/metro-runtime": "~6.1.2",
7
  "@expo/vector-icons": "^15.0.3",
8
  "@react-native-async-storage/async-storage": "2.2.0",
 
9
  "date-fns": "^4.4.0",
10
  "expo": "^54.0.36",
11
  "expo-av": "~16.0.8",
 
6
  "@expo/metro-runtime": "~6.1.2",
7
  "@expo/vector-icons": "^15.0.3",
8
  "@react-native-async-storage/async-storage": "2.2.0",
9
+ "@shopify/flash-list": "2.0.2",
10
  "date-fns": "^4.4.0",
11
  "expo": "^54.0.36",
12
  "expo-av": "~16.0.8",
keystone-backend/main.py CHANGED
@@ -117,10 +117,28 @@ async def health_check():
117
  "storage_path": BASE_STORAGE_DIR
118
  }
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  @app.get("/storage/stats")
121
  async def storage_stats(username: str = Depends(verify_jwt_token)):
122
- # Get overall bucket storage stats
123
- total, used, free = shutil.disk_usage(BASE_STORAGE_DIR)
 
 
 
 
 
124
  return {
125
  "total": total,
126
  "used": used,
 
117
  "storage_path": BASE_STORAGE_DIR
118
  }
119
 
120
+ def get_dir_size(path):
121
+ total = 0
122
+ try:
123
+ with os.scandir(path) as it:
124
+ for entry in it:
125
+ if entry.is_file():
126
+ total += entry.stat().st_size
127
+ elif entry.is_dir():
128
+ total += get_dir_size(entry.path)
129
+ except FileNotFoundError:
130
+ pass
131
+ return total
132
+
133
  @app.get("/storage/stats")
134
  async def storage_stats(username: str = Depends(verify_jwt_token)):
135
+ user_dir = get_user_dir(username)
136
+ used = get_dir_size(user_dir)
137
+
138
+ # Define a default quota of 15 GB
139
+ total = 15 * 1024 * 1024 * 1024
140
+ free = max(0, total - used)
141
+
142
  return {
143
  "total": total,
144
  "used": used,