display files with cache image
This commit is contained in:
+2
-1
@@ -44,7 +44,8 @@
|
||||
"photosPermission": "VaultDrop a besoin d'accéder à vos photos pour les afficher et les organiser.",
|
||||
"videosPermission": "VaultDrop a besoin d'accéder à vos vidéos pour les afficher et les organiser."
|
||||
}
|
||||
]
|
||||
],
|
||||
"expo-image"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+31
-18
@@ -4,7 +4,7 @@ import { useNavigation } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useDeleteFile, useAddTags, useMoveFiles, useFolders, useFileImage } from '../hooks/useFiles';
|
||||
import { useDeleteFile, useAddTags, useMoveFiles, useFolders } from '../hooks/useFiles';
|
||||
import { useUnifiedFiles, useFreeLocalSpace } from '../hooks/useUnifiedFiles';
|
||||
import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
|
||||
import { FileItem, isFolder } from '../types';
|
||||
@@ -84,9 +84,7 @@ function formatDateLabel(key: string): string {
|
||||
return key.charAt(0).toUpperCase() + key.slice(1);
|
||||
}
|
||||
|
||||
function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
|
||||
const { data, isLoading } = useFileImage(file.id);
|
||||
|
||||
const FileGridItem = React.memo(function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.gridItem, selected && styles.gridItemSelected]}
|
||||
@@ -96,12 +94,11 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedF
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FileThumbnail
|
||||
uri={data?.data?.url ?? file.localUri}
|
||||
uri={file.url ?? file.localUri}
|
||||
thumbnailUrl={file.thumbnailUrl}
|
||||
mimeType={file.mimeType}
|
||||
fileName={file.name}
|
||||
size={ITEM_SIZE}
|
||||
isLoading={isLoading}
|
||||
syncStatus={file.syncStatus}
|
||||
/>
|
||||
{selected && (
|
||||
@@ -114,7 +111,28 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedF
|
||||
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const FileGroup = React.memo(function FileGroup({ groupFiles, selectedIds, onItemPress, onItemLongPress }: {
|
||||
groupFiles: UnifiedFileItem[];
|
||||
selectedIds: Set<string>;
|
||||
onItemPress: (file: UnifiedFileItem) => void;
|
||||
onItemLongPress: (file: UnifiedFileItem) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.grid}>
|
||||
{groupFiles.map((file) => (
|
||||
<FileGridItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
selected={selectedIds.has(file.id)}
|
||||
onPress={() => onItemPress(file)}
|
||||
onLongPress={() => onItemLongPress(file)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean {
|
||||
if (!query) return true;
|
||||
@@ -447,17 +465,12 @@ export function HomeScreen() {
|
||||
<Text style={styles.sectionTitle}>{label}</Text>
|
||||
<Text style={styles.sectionCount}>{groupFiles.length}</Text>
|
||||
</View>
|
||||
<View style={styles.grid}>
|
||||
{groupFiles.map((file) => (
|
||||
<FileGridItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
selected={selectedIds.has(file.id)}
|
||||
onPress={() => handleItemPress(file)}
|
||||
onLongPress={() => handleItemLongPress(file)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<FileGroup
|
||||
groupFiles={groupFiles}
|
||||
selectedIds={selectedIds}
|
||||
onItemPress={handleItemPress}
|
||||
onItemLongPress={handleItemLongPress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Image, ActivityIndicator, StyleSheet } from 'react-native';
|
||||
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { SyncStatusBadge } from './SyncStatusBadge';
|
||||
@@ -40,7 +41,7 @@ interface FileThumbnailProps {
|
||||
syncStatus?: SyncStatus;
|
||||
}
|
||||
|
||||
export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading, syncStatus }: FileThumbnailProps) {
|
||||
export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading, syncStatus }: FileThumbnailProps) {
|
||||
const info = getFileInfo(mimeType, fileName);
|
||||
const ext = getExtension(fileName);
|
||||
|
||||
@@ -57,7 +58,13 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL
|
||||
if (imageUri) {
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
<Image source={{ uri: imageUri }} style={[styles.image, { width: size, height: size }]} />
|
||||
<Image
|
||||
source={imageUri}
|
||||
style={[styles.image, { width: size, height: size }]}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
||||
</View>
|
||||
);
|
||||
@@ -72,7 +79,7 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL
|
||||
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
|
||||
@@ -10,8 +10,10 @@ interface SyncStatusBadgeProps {
|
||||
|
||||
const STATUS_CONFIG: Record<SyncStatus, { icon: string; color: string; bg: string }> = {
|
||||
local: { icon: 'phone-android', color: '#757575', bg: 'rgba(245,245,245,0.9)' },
|
||||
syncing: { icon: 'sync', color: '#FF9800', bg: 'rgba(255,243,224,0.9)' },
|
||||
synced: { icon: 'sync', color: '#4CAF50', bg: 'rgba(232,245,233,0.9)' },
|
||||
cloud: { icon: 'cloud', color: '#1976D2', bg: 'rgba(227,242,253,0.9)' },
|
||||
conflict: { icon: 'warning', color: '#E53935', bg: 'rgba(255,235,238,0.9)' },
|
||||
};
|
||||
|
||||
export function SyncStatusBadge({ status, size = 16 }: SyncStatusBadgeProps) {
|
||||
|
||||
@@ -91,7 +91,6 @@ export function useAutoSync() {
|
||||
|
||||
if (pendingFiles.length === 0) return;
|
||||
|
||||
console.log(`[useAutoSync] mode=${globalMode}, upload de ${pendingFiles.length} fichier(s)`);
|
||||
setIsSyncing(true);
|
||||
|
||||
for (const entry of pendingFiles) {
|
||||
@@ -107,15 +106,12 @@ export function useAutoSync() {
|
||||
backendFileId: uploaded.id,
|
||||
syncStatus: 'synced',
|
||||
});
|
||||
|
||||
console.log(`[useAutoSync] "${entry.name}" uploadé → id=${uploaded.id}`);
|
||||
} catch (err) {
|
||||
console.error(`[useAutoSync] échec upload "${entry.name}":`, err);
|
||||
// upload failed, will retry on next cycle
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
console.log(`[useAutoSync] sync terminé`);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
isRunning.current = false;
|
||||
@@ -123,9 +119,11 @@ export function useAutoSync() {
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
checkAndSync();
|
||||
}, 5_000);
|
||||
const interval = setInterval(checkAndSync, 30_000);
|
||||
checkAndSync();
|
||||
return () => clearInterval(interval);
|
||||
return () => { clearTimeout(timeout); clearInterval(interval); };
|
||||
}, [checkAndSync]);
|
||||
|
||||
return { triggerSync: checkAndSync };
|
||||
|
||||
@@ -74,10 +74,9 @@ async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
|
||||
});
|
||||
}
|
||||
return files;
|
||||
} catch (err) {
|
||||
console.error(`[useDeviceFiles] SAF scan error for folder "${folder.name}":`, err);
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeviceFiles() {
|
||||
@@ -121,7 +120,6 @@ export function useDeviceFiles() {
|
||||
|
||||
setFiles(deviceFiles);
|
||||
} catch (err) {
|
||||
console.error('[useDeviceFiles] scan error:', err);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -132,11 +130,8 @@ export function useDeviceFiles() {
|
||||
const visibleFolders = safDirectory.getVisibleFolders();
|
||||
if (visibleFolders.length === 0) return;
|
||||
|
||||
const safFiles: DeviceFile[] = [];
|
||||
for (const folder of visibleFolders) {
|
||||
const folderFiles = await scanSafFolder(folder);
|
||||
safFiles.push(...folderFiles);
|
||||
}
|
||||
const results = await Promise.all(visibleFolders.map((folder) => scanSafFolder(folder)));
|
||||
const safFiles = results.flat();
|
||||
|
||||
setFiles((prev) => {
|
||||
const existing = new Set(prev.filter((f) => !f.folderId).map((f) => f.id));
|
||||
@@ -191,7 +186,6 @@ export function useDeviceFiles() {
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[useDeviceFiles] pickDirectory error:', err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -2,14 +2,26 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import { FileItem, PaginatedResponse } from '../types';
|
||||
import { metadataCache } from '../services/metadataCache';
|
||||
|
||||
export function useFiles(page: number = 1, limit: number = 20) {
|
||||
return useQuery({
|
||||
queryKey: ['files', page, limit],
|
||||
queryFn: () =>
|
||||
apiClient.get<PaginatedResponse<FileItem>>(
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<PaginatedResponse<FileItem>>(
|
||||
`${ENDPOINTS.FILES}?page=${page}&limit=${limit}&thumbnail=thumbnail`
|
||||
),
|
||||
);
|
||||
metadataCache.setFiles(res.data, res.meta.page, res.meta.total);
|
||||
return res;
|
||||
},
|
||||
initialData: () => {
|
||||
const cached = metadataCache.getFiles();
|
||||
if (cached && cached.page === page) {
|
||||
return { data: cached.files, meta: { page: cached.page, total: cached.total } };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +48,7 @@ export function useDeleteFile() {
|
||||
mutationFn: (id: string) => apiClient.delete(`${ENDPOINTS.FILES}/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
metadataCache.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -48,6 +61,7 @@ export function useAddTags() {
|
||||
apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags, tag_type: tagType }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
metadataCache.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -60,6 +74,7 @@ export function useMoveFiles() {
|
||||
apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
metadataCache.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ import { LocalFileEntry } from '../types';
|
||||
|
||||
export function useLocalFiles() {
|
||||
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles();
|
||||
|
||||
const registryEntries = useMemo(() => localFileRegistry.getAll(), []);
|
||||
const lastDeviceCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceFiles.length === 0) return;
|
||||
if (deviceFiles.length === lastDeviceCount.current) return;
|
||||
lastDeviceCount.current = deviceFiles.length;
|
||||
|
||||
const newEntries: LocalFileEntry[] = [];
|
||||
for (const df of deviceFiles) {
|
||||
if (localFileRegistry.get(df.id)) continue;
|
||||
const entry: LocalFileEntry = {
|
||||
newEntries.push({
|
||||
id: df.id,
|
||||
localUri: df.uri,
|
||||
name: df.name,
|
||||
@@ -20,12 +24,15 @@ export function useLocalFiles() {
|
||||
syncStatus: 'local',
|
||||
createdAt: df.createdAt,
|
||||
folderId: df.folderId,
|
||||
};
|
||||
localFileRegistry.register(entry);
|
||||
});
|
||||
}
|
||||
if (newEntries.length > 0) {
|
||||
localFileRegistry.registerBatch(newEntries);
|
||||
}
|
||||
}, [deviceFiles]);
|
||||
|
||||
const localFiles = useMemo(() => {
|
||||
const registryEntries = localFileRegistry.getAll();
|
||||
const merged = new Map<string, LocalFileEntry>();
|
||||
|
||||
for (const entry of registryEntries) {
|
||||
@@ -47,10 +54,8 @@ export function useLocalFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
const result = Array.from(merged.values());
|
||||
console.log(`[useLocalFiles] registry=${registryEntries.length} device=${deviceFiles.length} merged=${result.length}`);
|
||||
return result;
|
||||
}, [deviceFiles, registryEntries]);
|
||||
return Array.from(merged.values());
|
||||
}, [deviceFiles]);
|
||||
|
||||
return {
|
||||
localFiles,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy';
|
||||
import { useFiles } from './useFiles';
|
||||
import { localFileRegistry } from '../services/localFileRegistry';
|
||||
import { apiClient } from '../api/client';
|
||||
import { LocalFileEntry } from '../types';
|
||||
import { setIsSyncing } from './useSyncQueue';
|
||||
|
||||
const SYNC_DIR = `${documentDirectory}synced-files/`;
|
||||
|
||||
export function usePullSync() {
|
||||
const queryClient = useQueryClient();
|
||||
const isRunning = useRef(false);
|
||||
|
||||
const pullNewFiles = useCallback(async () => {
|
||||
if (isRunning.current) return { pulled: 0 };
|
||||
isRunning.current = true;
|
||||
|
||||
try {
|
||||
setIsSyncing(true);
|
||||
|
||||
const res = await apiClient.get<{ data: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url?: string;
|
||||
}> }>('/files?page=1&limit=100&thumbnail=thumbnail');
|
||||
|
||||
const backendFiles = res.data ?? [];
|
||||
const registry = localFileRegistry.getAll();
|
||||
const existingBackendIds = new Set(
|
||||
registry.filter((e) => e.backendFileId).map((e) => e.backendFileId)
|
||||
);
|
||||
|
||||
let pulled = 0;
|
||||
|
||||
for (const bf of backendFiles) {
|
||||
if (existingBackendIds.has(bf.id)) continue;
|
||||
if (bf.size === 0) continue;
|
||||
|
||||
try {
|
||||
const detail = await apiClient.get<{ data: { url: string } }>(`/files/${bf.id}`);
|
||||
const downloadUrl = detail.data.url;
|
||||
|
||||
await makeDirectoryAsync(SYNC_DIR, { intermediates: true });
|
||||
const safeName = bf.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileUri = `${SYNC_DIR}${bf.id}_${safeName}`;
|
||||
|
||||
const result = await downloadAsync(downloadUrl, fileUri);
|
||||
|
||||
const entry: LocalFileEntry = {
|
||||
id: `pull_${bf.id}`,
|
||||
backendFileId: bf.id,
|
||||
localUri: result.uri,
|
||||
name: bf.name,
|
||||
mimeType: bf.mimeType,
|
||||
size: bf.size,
|
||||
syncStatus: 'synced',
|
||||
createdAt: bf.createdAt,
|
||||
};
|
||||
localFileRegistry.register(entry);
|
||||
pulled++;
|
||||
} catch {
|
||||
// skip individual file failures
|
||||
}
|
||||
}
|
||||
|
||||
if (pulled > 0) {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
}
|
||||
|
||||
return { pulled };
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
isRunning.current = false;
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
return { pullNewFiles };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { downloadAsync, documentDirectory, makeDirectoryAsync, deleteAsync } fro
|
||||
import { useFiles } from './useFiles';
|
||||
import { useLocalFiles } from './useLocalFiles';
|
||||
import { localFileRegistry } from '../services/localFileRegistry';
|
||||
import { thumbnailCache } from '../services/thumbnailCache';
|
||||
import { apiClient } from '../api/client';
|
||||
import { FileItem, LocalFileEntry, SyncStatus, Tag } from '../types';
|
||||
|
||||
@@ -24,6 +25,7 @@ export interface UnifiedFileItem {
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
isDeviceFile?: boolean;
|
||||
duplicateOf?: string;
|
||||
}
|
||||
|
||||
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
|
||||
@@ -41,6 +43,15 @@ function getExtension(name: string): string {
|
||||
return dot >= 0 ? name.slice(dot) : '';
|
||||
}
|
||||
|
||||
function parseExpiresFromUrl(url: string): number {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const expires = u.searchParams.get('expires');
|
||||
if (expires) return Number(expires) * 1000;
|
||||
} catch {}
|
||||
return Date.now() + 50 * 60 * 1000;
|
||||
}
|
||||
|
||||
export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
|
||||
const { localFiles, isLoading: localLoading, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
|
||||
@@ -57,8 +68,25 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||
}
|
||||
}
|
||||
|
||||
const nameSizeIndex = new Map<string, string>();
|
||||
for (const bf of backendFiles) {
|
||||
if (!bf.isFolder && bf.size > 0) {
|
||||
nameSizeIndex.set(`${bf.name}::${bf.size}`, bf.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bf of backendFiles) {
|
||||
const localEntry = backendIdToLocal.get(bf.id);
|
||||
|
||||
let thumbUrl = bf.thumbnailUrl;
|
||||
if (thumbUrl && bf.thumbnailUrl) {
|
||||
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
|
||||
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
|
||||
} else {
|
||||
const cached = thumbnailCache.get(bf.id);
|
||||
if (cached) thumbUrl = cached;
|
||||
}
|
||||
|
||||
merged.set(bf.id, {
|
||||
id: bf.id,
|
||||
backendFileId: bf.id,
|
||||
@@ -74,7 +102,7 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||
isFolder: bf.isFolder,
|
||||
parentFileId: bf.parentFileId,
|
||||
url: bf.url,
|
||||
thumbnailUrl: bf.thumbnailUrl,
|
||||
thumbnailUrl: thumbUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +110,18 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||
if (lf.backendFileId && merged.has(lf.backendFileId)) continue;
|
||||
if (merged.has(lf.id)) continue;
|
||||
|
||||
if (!lf.folderId && lf.size > 0) {
|
||||
const key = `${lf.name}::${lf.size}`;
|
||||
const matchId = nameSizeIndex.get(key);
|
||||
if (matchId) {
|
||||
const existing = merged.get(matchId);
|
||||
if (existing && !existing.localUri && lf.localUri) {
|
||||
merged.set(matchId, { ...existing, localUri: lf.localUri, syncStatus: 'synced' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
merged.set(lf.id, {
|
||||
id: lf.id,
|
||||
backendFileId: lf.backendFileId,
|
||||
@@ -138,6 +178,16 @@ export function useUnifiedFilesByParent(parentId: string) {
|
||||
|
||||
for (const bf of backendFiles) {
|
||||
const localEntry = backendIdToLocal.get(bf.id);
|
||||
|
||||
let thumbUrl = bf.thumbnailUrl;
|
||||
if (thumbUrl && bf.thumbnailUrl) {
|
||||
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
|
||||
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
|
||||
} else {
|
||||
const cached = thumbnailCache.get(bf.id);
|
||||
if (cached) thumbUrl = cached;
|
||||
}
|
||||
|
||||
merged.set(bf.id, {
|
||||
id: bf.id,
|
||||
backendFileId: bf.id,
|
||||
@@ -153,7 +203,7 @@ export function useUnifiedFilesByParent(parentId: string) {
|
||||
isFolder: bf.isFolder,
|
||||
parentFileId: bf.parentFileId,
|
||||
url: bf.url,
|
||||
thumbnailUrl: bf.thumbnailUrl,
|
||||
thumbnailUrl: thumbUrl,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Generated
+21
@@ -16,6 +16,7 @@
|
||||
"expo": "~57.0.4",
|
||||
"expo-document-picker": "~57.0.0",
|
||||
"expo-file-system": "~57.0.0",
|
||||
"expo-image": "~57.0.1",
|
||||
"expo-image-picker": "~57.0.2",
|
||||
"expo-media-library": "~57.0.3",
|
||||
"expo-print": "~57.0.0",
|
||||
@@ -3082,6 +3083,26 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image": {
|
||||
"version": "57.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.1.tgz",
|
||||
"integrity": "sha512-EP0lisd2bUqtErry4weRcMW9bLMxtKsht/MLLK3/3do5u4ZMiJbWkY5zfYV+WYmeGab7x9G0sjbeFeEagYGjMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sf-symbols-typescript": "^2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-loader": {
|
||||
"version": "57.0.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"expo": "~57.0.4",
|
||||
"expo-document-picker": "~57.0.0",
|
||||
"expo-file-system": "~57.0.0",
|
||||
"expo-image": "~57.0.1",
|
||||
"expo-image-picker": "~57.0.2",
|
||||
"expo-media-library": "~57.0.3",
|
||||
"expo-print": "~57.0.0",
|
||||
|
||||
@@ -3,58 +3,113 @@ import { LocalFileEntry, SyncStatus } from '../types';
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-local-files' });
|
||||
|
||||
const INDEX_KEY = 'local_files_index';
|
||||
const BLOB_KEY = 'local_files_v2';
|
||||
const LEGACY_INDEX_KEY = 'local_files_index';
|
||||
|
||||
function getAllIds(): string[] {
|
||||
const raw = storage.getString(INDEX_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as string[];
|
||||
interface RegistryBlob {
|
||||
entries: Record<string, LocalFileEntry>;
|
||||
backendIndex: Record<string, string>;
|
||||
}
|
||||
|
||||
function setAllIds(ids: string[]) {
|
||||
storage.set(INDEX_KEY, JSON.stringify(ids));
|
||||
let memoryCache: RegistryBlob | null = null;
|
||||
|
||||
function loadBlob(): RegistryBlob {
|
||||
if (memoryCache) return memoryCache;
|
||||
|
||||
const raw = storage.getString(BLOB_KEY);
|
||||
if (raw) {
|
||||
memoryCache = JSON.parse(raw) as RegistryBlob;
|
||||
return memoryCache;
|
||||
}
|
||||
|
||||
memoryCache = migrateFromLegacy();
|
||||
saveBlob(memoryCache);
|
||||
return memoryCache;
|
||||
}
|
||||
|
||||
function entryKey(id: string): string {
|
||||
return `local_file_${id}`;
|
||||
function saveBlob(blob: RegistryBlob) {
|
||||
memoryCache = blob;
|
||||
storage.set(BLOB_KEY, JSON.stringify(blob));
|
||||
}
|
||||
|
||||
function migrateFromLegacy(): RegistryBlob {
|
||||
const blob: RegistryBlob = { entries: {}, backendIndex: {} };
|
||||
|
||||
const rawIndex = storage.getString(LEGACY_INDEX_KEY);
|
||||
if (!rawIndex) return blob;
|
||||
|
||||
const ids: string[] = JSON.parse(rawIndex);
|
||||
for (const id of ids) {
|
||||
const raw = storage.getString(`local_file_${id}`);
|
||||
if (!raw) continue;
|
||||
const entry: LocalFileEntry = JSON.parse(raw);
|
||||
blob.entries[entry.id] = entry;
|
||||
if (entry.backendFileId) {
|
||||
blob.backendIndex[entry.backendFileId] = entry.id;
|
||||
}
|
||||
}
|
||||
|
||||
storage.remove(LEGACY_INDEX_KEY);
|
||||
for (const id of ids) {
|
||||
storage.remove(`local_file_${id}`);
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
export const localFileRegistry = {
|
||||
register(entry: LocalFileEntry) {
|
||||
storage.set(entryKey(entry.id), JSON.stringify(entry));
|
||||
const ids = getAllIds();
|
||||
if (!ids.includes(entry.id)) {
|
||||
setAllIds([entry.id, ...ids]);
|
||||
const blob = loadBlob();
|
||||
blob.entries[entry.id] = entry;
|
||||
if (entry.backendFileId) {
|
||||
blob.backendIndex[entry.backendFileId] = entry.id;
|
||||
}
|
||||
saveBlob(blob);
|
||||
},
|
||||
|
||||
registerBatch(entries: LocalFileEntry[]) {
|
||||
if (entries.length === 0) return;
|
||||
const blob = loadBlob();
|
||||
for (const entry of entries) {
|
||||
blob.entries[entry.id] = entry;
|
||||
if (entry.backendFileId) {
|
||||
blob.backendIndex[entry.backendFileId] = entry.id;
|
||||
}
|
||||
}
|
||||
saveBlob(blob);
|
||||
},
|
||||
|
||||
get(id: string): LocalFileEntry | undefined {
|
||||
const raw = storage.getString(entryKey(id));
|
||||
if (!raw) return undefined;
|
||||
return JSON.parse(raw) as LocalFileEntry;
|
||||
return loadBlob().entries[id];
|
||||
},
|
||||
|
||||
getByBackendId(backendId: string): LocalFileEntry | undefined {
|
||||
const ids = getAllIds();
|
||||
for (const id of ids) {
|
||||
const entry = this.get(id);
|
||||
if (entry?.backendFileId === backendId) return entry;
|
||||
}
|
||||
return undefined;
|
||||
const blob = loadBlob();
|
||||
const entryId = blob.backendIndex[backendId];
|
||||
if (!entryId) return undefined;
|
||||
return blob.entries[entryId];
|
||||
},
|
||||
|
||||
getAll(): LocalFileEntry[] {
|
||||
const ids = getAllIds();
|
||||
return ids
|
||||
.map((id) => this.get(id))
|
||||
.filter((e): e is LocalFileEntry => e !== undefined);
|
||||
const blob = loadBlob();
|
||||
return Object.values(blob.entries);
|
||||
},
|
||||
|
||||
update(id: string, updates: Partial<LocalFileEntry>) {
|
||||
const existing = this.get(id);
|
||||
const blob = loadBlob();
|
||||
const existing = blob.entries[id];
|
||||
if (!existing) return;
|
||||
|
||||
if (existing.backendFileId && updates.backendFileId === undefined && updates.syncStatus === 'cloud') {
|
||||
delete blob.backendIndex[existing.backendFileId];
|
||||
}
|
||||
|
||||
const updated = { ...existing, ...updates };
|
||||
storage.set(entryKey(id), JSON.stringify(updated));
|
||||
blob.entries[id] = updated;
|
||||
if (updated.backendFileId) {
|
||||
blob.backendIndex[updated.backendFileId] = id;
|
||||
}
|
||||
saveBlob(blob);
|
||||
},
|
||||
|
||||
updateSyncStatus(id: string, syncStatus: SyncStatus) {
|
||||
@@ -66,17 +121,26 @@ export const localFileRegistry = {
|
||||
},
|
||||
|
||||
remove(id: string) {
|
||||
storage.remove(entryKey(id));
|
||||
const ids = getAllIds().filter((i) => i !== id);
|
||||
setAllIds(ids);
|
||||
const blob = loadBlob();
|
||||
const entry = blob.entries[id];
|
||||
if (entry?.backendFileId) {
|
||||
delete blob.backendIndex[entry.backendFileId];
|
||||
}
|
||||
delete blob.entries[id];
|
||||
saveBlob(blob);
|
||||
},
|
||||
|
||||
removeByBackendId(backendId: string) {
|
||||
const entry = this.getByBackendId(backendId);
|
||||
if (entry) this.remove(entry.id);
|
||||
const blob = loadBlob();
|
||||
const entryId = blob.backendIndex[backendId];
|
||||
if (entryId) {
|
||||
delete blob.entries[entryId];
|
||||
delete blob.backendIndex[backendId];
|
||||
saveBlob(blob);
|
||||
}
|
||||
},
|
||||
|
||||
count(): number {
|
||||
return getAllIds().length;
|
||||
return Object.keys(loadBlob().entries).length;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
import { FileItem } from '../types';
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-metadata' });
|
||||
|
||||
const FILES_KEY = 'backend_files_cache';
|
||||
const UPDATED_AT_KEY = 'cache_updated_at';
|
||||
const STALE_MS = 5 * 60 * 1000;
|
||||
|
||||
interface CachedFiles {
|
||||
files: FileItem[];
|
||||
page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const metadataCache = {
|
||||
getFiles(): CachedFiles | null {
|
||||
const raw = storage.getString(FILES_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as CachedFiles;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setFiles(files: FileItem[], page: number, total: number) {
|
||||
const data: CachedFiles = { files, page, total };
|
||||
storage.set(FILES_KEY, JSON.stringify(data));
|
||||
storage.set(UPDATED_AT_KEY, Date.now());
|
||||
},
|
||||
|
||||
isStale(): boolean {
|
||||
const raw = storage.getString(UPDATED_AT_KEY);
|
||||
if (!raw) return true;
|
||||
const updatedAt = Number(raw);
|
||||
return Date.now() - updatedAt > STALE_MS;
|
||||
},
|
||||
|
||||
clear() {
|
||||
storage.remove(FILES_KEY);
|
||||
storage.remove(UPDATED_AT_KEY);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-thumbnails' });
|
||||
|
||||
const BLOB_KEY = 'thumbnail_urls';
|
||||
const STALE_MS = 50 * 60 * 1000;
|
||||
|
||||
interface ThumbnailCacheEntry {
|
||||
url: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let memoryCache: Record<string, ThumbnailCacheEntry> | null = null;
|
||||
|
||||
function loadCache(): Record<string, ThumbnailCacheEntry> {
|
||||
if (memoryCache) return memoryCache;
|
||||
const raw = storage.getString(BLOB_KEY);
|
||||
memoryCache = raw ? JSON.parse(raw) : {};
|
||||
return memoryCache!;
|
||||
}
|
||||
|
||||
function saveCache(cache: Record<string, ThumbnailCacheEntry>) {
|
||||
memoryCache = cache;
|
||||
storage.set(BLOB_KEY, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
export const thumbnailCache = {
|
||||
get(fileId: string): string | null {
|
||||
const cache = loadCache();
|
||||
const entry = cache[fileId];
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
delete cache[fileId];
|
||||
saveCache(cache);
|
||||
return null;
|
||||
}
|
||||
return entry.url;
|
||||
},
|
||||
|
||||
set(fileId: string, url: string, expiresAt: number) {
|
||||
const cache = loadCache();
|
||||
cache[fileId] = { url, expiresAt };
|
||||
saveCache(cache);
|
||||
},
|
||||
|
||||
setBatch(entries: Array<{ fileId: string; url: string; expiresAt: number }>) {
|
||||
if (entries.length === 0) return;
|
||||
const cache = loadCache();
|
||||
for (const e of entries) {
|
||||
cache[e.fileId] = { url: e.url, expiresAt: e.expiresAt };
|
||||
}
|
||||
saveCache(cache);
|
||||
},
|
||||
|
||||
remove(fileId: string) {
|
||||
const cache = loadCache();
|
||||
delete cache[fileId];
|
||||
saveCache(cache);
|
||||
},
|
||||
|
||||
clear() {
|
||||
memoryCache = {};
|
||||
storage.remove(BLOB_KEY);
|
||||
},
|
||||
};
|
||||
@@ -117,7 +117,7 @@ export interface RefreshResponse {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export type SyncStatus = 'local' | 'synced' | 'cloud';
|
||||
export type SyncStatus = 'local' | 'syncing' | 'synced' | 'cloud' | 'conflict';
|
||||
|
||||
export interface LocalFileEntry {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user