display files with cache image

This commit is contained in:
m
2026-07-27 23:02:09 +02:00
parent c5a959a27b
commit e8ceb99a5d
25 changed files with 814 additions and 118 deletions
+2 -1
View File
@@ -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
View File
@@ -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>
);
}}
+11 -4
View File
@@ -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: {
+2
View File
@@ -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) {
+5 -7
View File
@@ -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 };
+5 -11
View File
@@ -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;
}
}, []);
+18 -3
View File
@@ -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();
},
});
}
+14 -9
View File
@@ -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,
+83
View File
@@ -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 };
}
+52 -2
View File
@@ -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,
});
}
+21
View File
@@ -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",
+1
View File
@@ -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",
+98 -34
View File
@@ -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;
},
};
+44
View File
@@ -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);
},
};
+65
View File
@@ -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);
},
};
+1 -1
View File
@@ -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;