from scratch
This commit is contained in:
@@ -1,203 +0,0 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { File, UploadType } from 'expo-file-system';
|
||||
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
|
||||
import { safDirectory } from '../services/safDirectory';
|
||||
import { fileStore } from '../services/fileStore';
|
||||
import { activeUploadUris } from '../services/uploadQueue';
|
||||
import { apiClient } from '../api/client';
|
||||
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
||||
import { ApiError } from '../types';
|
||||
import {
|
||||
setIsSyncing,
|
||||
setSyncProgress,
|
||||
isSyncLoopRunning,
|
||||
setSyncLoopRunning,
|
||||
requestSyncCancel,
|
||||
isSyncCancelRequested,
|
||||
consumeSyncCancel,
|
||||
} from './useSyncQueue';
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
const RETRY_MMKV_ID = 'vaultdrop-sync-retries';
|
||||
|
||||
const retryStorage = createMMKV({ id: RETRY_MMKV_ID });
|
||||
|
||||
function getRetryCount(fileId: string): number {
|
||||
return retryStorage.getNumber(`${fileId}_retries`) ?? 0;
|
||||
}
|
||||
|
||||
function incrementRetry(fileId: string): number {
|
||||
const count = getRetryCount(fileId) + 1;
|
||||
retryStorage.set(`${fileId}_retries`, count);
|
||||
return count;
|
||||
}
|
||||
|
||||
function resetRetry(fileId: string) {
|
||||
retryStorage.remove(`${fileId}_retries`);
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
async function uploadFile(file: { uri: string; type: string; name: string }): Promise<UploadResult> {
|
||||
const fsFile = new File(file.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
const token = apiClient.getAccessToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const result = await fsFile.upload(`${API_BASE_URL}${ENDPOINTS.UPLOAD}`, {
|
||||
httpMethod: 'POST',
|
||||
uploadType: UploadType.MULTIPART,
|
||||
fieldName: 'file',
|
||||
mimeType: file.type,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
let message = 'Upload failed';
|
||||
try {
|
||||
const body: ApiError = JSON.parse(result.body);
|
||||
message = body.error?.message || message;
|
||||
} catch {
|
||||
message = result.body || message;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const body = JSON.parse(result.body);
|
||||
const items = body.data ?? body;
|
||||
const item = Array.isArray(items) ? items[0] : items;
|
||||
return item as UploadResult;
|
||||
}
|
||||
|
||||
function canSyncBasedOnNetwork(netInfo: NetInfoState, cellularAllowed: boolean): boolean {
|
||||
if (!netInfo.isConnected) return false;
|
||||
if (!cellularAllowed && netInfo.type !== 'wifi') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
let autoSyncLoopStarted = false;
|
||||
|
||||
export function useAutoSync() {
|
||||
const queryClient = useQueryClient();
|
||||
const isRunning = useRef(false);
|
||||
|
||||
const getPendingFiles = useCallback((): Array<ReturnType<typeof fileStore.getAllLocal>[number]> => {
|
||||
return fileStore.getAllLocal().filter(
|
||||
(entry) => !entry.backendId && entry.syncStatus === 'local' && entry.localUri
|
||||
);
|
||||
}, []);
|
||||
|
||||
const cancelSync = useCallback(() => {
|
||||
requestSyncCancel();
|
||||
}, []);
|
||||
|
||||
const runPendingSync = useCallback(async (pendingFiles: Array<ReturnType<typeof fileStore.getAllLocal>[number]>) => {
|
||||
if (isRunning.current) return;
|
||||
if (isSyncLoopRunning()) return;
|
||||
|
||||
const globalCellular = safDirectory.getGlobalSyncCellular();
|
||||
const netInfo = await NetInfo.fetch();
|
||||
|
||||
if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return;
|
||||
|
||||
isRunning.current = true;
|
||||
setSyncLoopRunning(true);
|
||||
|
||||
try {
|
||||
setIsSyncing(true);
|
||||
consumeSyncCancel();
|
||||
|
||||
const progressFiles = pendingFiles.map((f) => ({ id: f.id, name: f.name }));
|
||||
|
||||
for (let index = 0; index < pendingFiles.length; index++) {
|
||||
if (isSyncCancelRequested()) break;
|
||||
const entry = pendingFiles[index];
|
||||
setSyncProgress({ files: progressFiles, currentIndex: index });
|
||||
const uri = entry.localUri;
|
||||
if (!uri || activeUploadUris.has(uri)) continue;
|
||||
activeUploadUris.add(uri);
|
||||
try {
|
||||
const uploaded = await uploadFile({
|
||||
uri,
|
||||
type: entry.mimeType,
|
||||
name: entry.name,
|
||||
});
|
||||
|
||||
resetRetry(entry.id);
|
||||
fileStore.updatePartial(entry.id, {
|
||||
backendId: uploaded.id,
|
||||
syncStatus: 'synced',
|
||||
source: 'synced',
|
||||
});
|
||||
} catch (err) {
|
||||
const retries = incrementRetry(entry.id);
|
||||
if (retries >= MAX_RETRIES) {
|
||||
fileStore.updatePartial(entry.id, {
|
||||
syncStatus: 'error',
|
||||
});
|
||||
resetRetry(entry.id);
|
||||
}
|
||||
} finally {
|
||||
activeUploadUris.delete(uri);
|
||||
}
|
||||
}
|
||||
|
||||
if (consumeSyncCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
} finally {
|
||||
setSyncProgress(null);
|
||||
setIsSyncing(false);
|
||||
isRunning.current = false;
|
||||
setSyncLoopRunning(false);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const checkAndSync = useCallback(async () => {
|
||||
const globalMode = safDirectory.getGlobalSyncMode();
|
||||
if (globalMode === 'off') return;
|
||||
|
||||
let pendingFiles = getPendingFiles();
|
||||
|
||||
if (globalMode === 'manual') {
|
||||
const allFolders = safDirectory.getAll();
|
||||
const autoFolderIds = new Set(
|
||||
allFolders.filter((f) => f.syncMode === 'auto').map((f) => f.id)
|
||||
);
|
||||
pendingFiles = pendingFiles.filter(
|
||||
(entry) => entry.parentResourceId && autoFolderIds.has(entry.parentResourceId)
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingFiles.length === 0) return;
|
||||
|
||||
await runPendingSync(pendingFiles);
|
||||
}, [getPendingFiles, runPendingSync]);
|
||||
|
||||
const syncManually = useCallback(async () => {
|
||||
const pendingFiles = getPendingFiles();
|
||||
if (pendingFiles.length === 0) return;
|
||||
await runPendingSync(pendingFiles);
|
||||
}, [getPendingFiles, runPendingSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSyncLoopStarted) return;
|
||||
autoSyncLoopStarted = true;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
checkAndSync();
|
||||
}, 5_000);
|
||||
const interval = setInterval(checkAndSync, 30_000);
|
||||
return () => { clearTimeout(timeout); clearInterval(interval); autoSyncLoopStarted = false; };
|
||||
}, [checkAndSync]);
|
||||
|
||||
return { triggerSync: checkAndSync, syncManually, cancelSync };
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { createMMKV, useMMKVObject } from 'react-native-mmkv';
|
||||
import { Batch } from '../types';
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-batches' });
|
||||
|
||||
export function useBatchStore() {
|
||||
const [batchIds, setBatchIds] = useMMKVObject<string[]>('batch-ids', storage);
|
||||
|
||||
const saveBatch = useCallback((batch: Batch) => {
|
||||
storage.set(`batch_${batch.id}`, JSON.stringify(batch));
|
||||
const current = batchIds ?? [];
|
||||
if (!current.includes(batch.id)) {
|
||||
setBatchIds([batch.id, ...current]);
|
||||
}
|
||||
}, [batchIds, setBatchIds]);
|
||||
|
||||
const getBatch = useCallback((id: string): Batch | undefined => {
|
||||
const raw = storage.getString(`batch_${id}`);
|
||||
if (!raw) return undefined;
|
||||
return JSON.parse(raw) as Batch;
|
||||
}, []);
|
||||
|
||||
const getAllBatches = useCallback((): Batch[] => {
|
||||
return (batchIds ?? [])
|
||||
.map((id) => getBatch(id))
|
||||
.filter((b): b is Batch => b !== undefined);
|
||||
}, [batchIds, getBatch]);
|
||||
|
||||
const addTagToBatch = useCallback((batchId: string, tag: string) => {
|
||||
const batch = getBatch(batchId);
|
||||
if (!batch) return;
|
||||
if (batch.tags.includes(tag)) return;
|
||||
batch.tags = [...batch.tags, tag];
|
||||
storage.set(`batch_${batchId}`, JSON.stringify(batch));
|
||||
}, [getBatch]);
|
||||
|
||||
const removeTagFromBatch = useCallback((batchId: string, tag: string) => {
|
||||
const batch = getBatch(batchId);
|
||||
if (!batch) return;
|
||||
batch.tags = batch.tags.filter((t) => t !== tag);
|
||||
storage.set(`batch_${batchId}`, JSON.stringify(batch));
|
||||
}, [getBatch]);
|
||||
|
||||
const deleteBatch = useCallback((batchId: string) => {
|
||||
storage.set(`batch_${batchId}`, undefined as any);
|
||||
storage.remove(`batch_${batchId}` as any);
|
||||
setBatchIds((batchIds ?? []).filter((id) => id !== batchId));
|
||||
}, [batchIds, setBatchIds]);
|
||||
|
||||
const removePhotoFromBatch = useCallback((batchId: string, photoId: string) => {
|
||||
const batch = getBatch(batchId);
|
||||
if (!batch) return;
|
||||
batch.photos = batch.photos.filter((p) => p.id !== photoId);
|
||||
if (batch.photos.length === 0) {
|
||||
deleteBatch(batchId);
|
||||
} else {
|
||||
storage.set(`batch_${batchId}`, JSON.stringify(batch));
|
||||
}
|
||||
}, [getBatch, deleteBatch]);
|
||||
|
||||
return {
|
||||
saveBatch,
|
||||
getBatch,
|
||||
getAllBatches,
|
||||
addTagToBatch,
|
||||
removeTagFromBatch,
|
||||
deleteBatch,
|
||||
removePhotoFromBatch,
|
||||
};
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useCameraDevice, useCameraPermission, usePhotoOutput, type TorchMode, type CameraRef } from 'react-native-vision-camera';
|
||||
import { useUpload } from './useUpload';
|
||||
import { CapturedPhoto } from '../types';
|
||||
|
||||
type CaptureStatus = 'idle' | 'capturing' | 'uploading' | 'success' | 'error';
|
||||
|
||||
export function useCameraCapture() {
|
||||
const { hasPermission, requestPermission } = useCameraPermission();
|
||||
const device = useCameraDevice('back');
|
||||
const cameraRef = useRef<CameraRef>(null);
|
||||
|
||||
const [captureStatus, setCaptureStatus] = useState<CaptureStatus>('idle');
|
||||
const [captureError, setCaptureError] = useState<string>();
|
||||
const [torchMode, setTorchMode] = useState<TorchMode>('off');
|
||||
const [cameraReady, setCameraReady] = useState(false);
|
||||
const [capturedPhotos, setCapturedPhotos] = useState<CapturedPhoto[]>([]);
|
||||
|
||||
const upload = useUpload();
|
||||
|
||||
const photoOutput = usePhotoOutput();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPermission) {
|
||||
requestPermission();
|
||||
}
|
||||
}, [hasPermission, requestPermission]);
|
||||
|
||||
const isActive = hasPermission && !!device && captureStatus !== 'uploading';
|
||||
|
||||
const toggleTorch = useCallback(async () => {
|
||||
const next = torchMode === 'off' ? 'on' : 'off';
|
||||
setTorchMode(next);
|
||||
if (cameraReady && cameraRef.current) {
|
||||
try {
|
||||
const controller = cameraRef.current.controller;
|
||||
if (controller?.device.hasTorch) {
|
||||
await controller.setTorchMode(next);
|
||||
}
|
||||
} catch {
|
||||
setTorchMode(torchMode);
|
||||
}
|
||||
}
|
||||
}, [torchMode, cameraReady]);
|
||||
|
||||
const onStarted = useCallback(() => {
|
||||
setCameraReady(true);
|
||||
if (torchMode === 'on' && cameraRef.current) {
|
||||
const controller = cameraRef.current.controller;
|
||||
controller?.setTorchMode('on').catch(() => {});
|
||||
}
|
||||
}, [torchMode]);
|
||||
|
||||
const onStopped = useCallback(() => {
|
||||
setCameraReady(false);
|
||||
}, []);
|
||||
|
||||
const addCapturedPhoto = useCallback((photo: CapturedPhoto) => {
|
||||
setCapturedPhotos((prev) => [...prev, photo]);
|
||||
}, []);
|
||||
|
||||
const removeCapturedPhoto = useCallback((id: string) => {
|
||||
setCapturedPhotos((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearCapturedPhotos = useCallback(() => {
|
||||
setCapturedPhotos([]);
|
||||
}, []);
|
||||
|
||||
const capturePhoto = useCallback(async () => {
|
||||
if (!photoOutput) return;
|
||||
|
||||
const photoId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
|
||||
try {
|
||||
setCaptureStatus('capturing');
|
||||
setCaptureError(undefined);
|
||||
|
||||
const { filePath } = await photoOutput.capturePhotoToFile({}, {});
|
||||
|
||||
setCaptureStatus('uploading');
|
||||
|
||||
const capturedPhoto: CapturedPhoto = {
|
||||
id: photoId,
|
||||
filePath,
|
||||
uri: 'file://' + filePath,
|
||||
};
|
||||
|
||||
addCapturedPhoto(capturedPhoto);
|
||||
|
||||
const result = await upload.mutateAsync([
|
||||
{ uri: capturedPhoto.uri, type: 'image/jpeg', name: `scan_${photoId}.jpg` },
|
||||
]);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
setCaptureStatus('error');
|
||||
setCaptureError(result.errors[0].message);
|
||||
} else {
|
||||
capturedPhoto.uploadedId = result.uploaded[0]?.id;
|
||||
capturedPhoto.uploadedAt = new Date().toISOString();
|
||||
setCaptureStatus('success');
|
||||
}
|
||||
} catch (err) {
|
||||
setCaptureStatus('error');
|
||||
setCaptureError(err instanceof Error ? err.message : 'Erreur lors de la capture');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setCaptureStatus('idle');
|
||||
setCaptureError(undefined);
|
||||
}, 1500);
|
||||
}, [photoOutput, upload, addCapturedPhoto]);
|
||||
|
||||
return {
|
||||
cameraRef,
|
||||
hasPermission,
|
||||
requestPermission,
|
||||
device,
|
||||
photoOutput,
|
||||
capturePhoto,
|
||||
captureStatus,
|
||||
captureError,
|
||||
isActive,
|
||||
torchMode,
|
||||
toggleTorch,
|
||||
onStarted,
|
||||
onStopped,
|
||||
capturedPhotos,
|
||||
removeCapturedPhoto,
|
||||
clearCapturedPhotos,
|
||||
capturedCount: capturedPhotos.length,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { safDirectory, type StoredFolder } from '../services/safDirectory';
|
||||
import { downloadRegistry } from '../services/downloadRegistry';
|
||||
import { useFileWatcher } from './useFileWatcher';
|
||||
import { FileDetectedEvent } from '../modules/expo-download-detect';
|
||||
|
||||
export interface DeviceFile {
|
||||
id: string;
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
folderId?: string;
|
||||
}
|
||||
|
||||
function guessMimeType(name: string): string {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const map: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
json: 'application/json',
|
||||
xml: 'application/xml',
|
||||
zip: 'application/zip',
|
||||
rar: 'application/x-rar-compressed',
|
||||
mp4: 'video/mp4',
|
||||
mp3: 'audio/mpeg',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
return map[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function eventToDeviceFile(event: FileDetectedEvent): DeviceFile {
|
||||
return {
|
||||
id: event.id,
|
||||
uri: event.uri,
|
||||
name: event.name,
|
||||
mimeType: event.mimeType,
|
||||
size: event.size,
|
||||
createdAt: new Date(event.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const SKIP_SUBDIRS = new Set([
|
||||
'Android', 'android', 'data', 'obb', 'cache',
|
||||
'.thumbnails', '.Trash', 'lost+found',
|
||||
'LOST.DIR', 'System Volume Information',
|
||||
'com.android', '.cache', 'tmp', '.tmp',
|
||||
]);
|
||||
|
||||
function shouldSkipDir(name: string): boolean {
|
||||
if (name.startsWith('.')) return true;
|
||||
return SKIP_SUBDIRS.has(name);
|
||||
}
|
||||
|
||||
async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
|
||||
try {
|
||||
const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(folder.uri);
|
||||
const files: DeviceFile[] = [];
|
||||
for (const entryUri of entries) {
|
||||
const parts = entryUri.split('/');
|
||||
const name = decodeURIComponent(parts[parts.length - 1]);
|
||||
if (name.startsWith('.')) continue;
|
||||
files.push({
|
||||
id: `saf_${folder.id}_${entryUri}`,
|
||||
uri: entryUri,
|
||||
name,
|
||||
mimeType: guessMimeType(name),
|
||||
size: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
folderId: folder.id,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanSubdirectories(
|
||||
baseUri: string,
|
||||
depth: number = 0,
|
||||
maxDepth: number = 3,
|
||||
discovered: Array<{ uri: string; name: string; parentUri: string }> = []
|
||||
): Promise<Array<{ uri: string; name: string; parentUri: string }>> {
|
||||
if (depth >= maxDepth || discovered.length >= 300) return discovered;
|
||||
|
||||
try {
|
||||
const entries = await FileSystem.StorageAccessFramework.readDirectoryAsync(baseUri);
|
||||
for (const entryUri of entries) {
|
||||
const parts = entryUri.split('/');
|
||||
const name = decodeURIComponent(parts[parts.length - 1]);
|
||||
if (shouldSkipDir(name)) continue;
|
||||
|
||||
try {
|
||||
await FileSystem.StorageAccessFramework.readDirectoryAsync(entryUri);
|
||||
discovered.push({ uri: entryUri, name, parentUri: baseUri });
|
||||
await scanSubdirectories(entryUri, depth + 1, maxDepth, discovered);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
export function useDeviceFiles() {
|
||||
const [files, setFiles] = useState<DeviceFile[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [folders, setFolders] = useState<StoredFolder[]>(() => safDirectory.getAll());
|
||||
const [discovered, setDiscovered] = useState(() => safDirectory.getDiscovered());
|
||||
const { newFiles, clearNewFiles } = useFileWatcher();
|
||||
|
||||
const scanVisibleFolders = useCallback(async () => {
|
||||
const visibleFolders = safDirectory.getVisibleFolders().filter((f) => f.source !== 'media-library');
|
||||
if (visibleFolders.length === 0) return;
|
||||
|
||||
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));
|
||||
const mediaOnly = prev.filter((f) => !f.folderId);
|
||||
const merged = [...mediaOnly];
|
||||
for (const f of safFiles) {
|
||||
if (!existing.has(f.id)) merged.push(f);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadRegistryFiles = useCallback(() => {
|
||||
const registryEntries = downloadRegistry.getAll();
|
||||
const registryFiles: DeviceFile[] = registryEntries.map((entry) => ({
|
||||
id: entry.id,
|
||||
uri: entry.uri,
|
||||
name: entry.name,
|
||||
mimeType: entry.mimeType,
|
||||
size: entry.size,
|
||||
createdAt: new Date(entry.createdAt).toISOString(),
|
||||
}));
|
||||
|
||||
setFiles((prev) => {
|
||||
const existing = new Set(prev.map((f) => f.id));
|
||||
const merged = [...prev];
|
||||
for (const f of registryFiles) {
|
||||
if (!existing.has(f.id)) merged.push(f);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pickAndScanRecursive = useCallback(async (): Promise<{ addedFolders: number }> => {
|
||||
try {
|
||||
const result = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
|
||||
if (!result.granted) return { addedFolders: 0 };
|
||||
|
||||
const dirUri = result.directoryUri;
|
||||
const parts = dirUri.split('/');
|
||||
const dirName = decodeURIComponent(parts[parts.length - 1] ?? 'Dossier');
|
||||
|
||||
safDirectory.addFolder(dirUri, dirName);
|
||||
|
||||
const subdirs = await scanSubdirectories(dirUri);
|
||||
if (subdirs.length > 0) {
|
||||
safDirectory.addBatchFolders(
|
||||
subdirs.map((d) => ({ uri: d.uri, name: d.name, source: 'recursive', parentUri: d.parentUri }))
|
||||
);
|
||||
}
|
||||
|
||||
setFolders(safDirectory.getAll());
|
||||
|
||||
if (!discovered) {
|
||||
safDirectory.setDiscovered();
|
||||
setDiscovered(true);
|
||||
}
|
||||
|
||||
await scanVisibleFolders();
|
||||
|
||||
return { addedFolders: 1 + subdirs.length };
|
||||
} catch (err) {
|
||||
return { addedFolders: 0 };
|
||||
}
|
||||
}, [discovered, scanVisibleFolders]);
|
||||
|
||||
const refreshFolders = useCallback(() => {
|
||||
setFolders(safDirectory.getAll());
|
||||
}, []);
|
||||
|
||||
const rescan = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await scanVisibleFolders();
|
||||
loadRegistryFiles();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [scanVisibleFolders, loadRegistryFiles]);
|
||||
|
||||
// Handle new files detected by native module
|
||||
useEffect(() => {
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
downloadRegistry.addBatch(newFiles);
|
||||
|
||||
const newDeviceFiles = newFiles.map(eventToDeviceFile);
|
||||
setFiles((prev) => {
|
||||
const existing = new Set(prev.map((f) => f.id));
|
||||
const merged = [...prev];
|
||||
for (const f of newDeviceFiles) {
|
||||
if (!existing.has(f.id)) merged.push(f);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
clearNewFiles();
|
||||
}, [newFiles, clearNewFiles]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
scanVisibleFolders();
|
||||
loadRegistryFiles();
|
||||
}, [scanVisibleFolders, loadRegistryFiles]);
|
||||
|
||||
return {
|
||||
files, isLoading,
|
||||
rescan,
|
||||
pickAndScanRecursive,
|
||||
folders, refreshFolders,
|
||||
discovered,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import { Device as DeviceType } from '../types';
|
||||
|
||||
const DEVICE_ID_KEY = 'vaultdrop_device_id';
|
||||
const DEVICE_NAME_KEY = 'vaultdrop_device_name';
|
||||
const DEVICE_SERVER_ID_KEY = 'vaultdrop_device_server_id';
|
||||
|
||||
function generateDefaultDeviceName(): string {
|
||||
const constants = Platform.constants as Record<string, unknown>;
|
||||
const brand = String(constants?.Manufacturer ?? '');
|
||||
const model = String(constants?.Model ?? '');
|
||||
const suffix = Math.random().toString(36).slice(2, 6);
|
||||
const base = [brand, model].filter(Boolean).join(' ') || Platform.OS;
|
||||
return `${Platform.OS === 'ios' ? 'iOS' : 'Android'} ${base} (${suffix})`;
|
||||
}
|
||||
|
||||
async function getOrCreateDeviceId(): Promise<string> {
|
||||
let deviceId = await SecureStore.getItemAsync(DEVICE_ID_KEY);
|
||||
if (!deviceId) {
|
||||
deviceId = `device_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
await SecureStore.setItemAsync(DEVICE_ID_KEY, deviceId);
|
||||
}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
export async function getStoredDeviceServerId(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(DEVICE_SERVER_ID_KEY);
|
||||
}
|
||||
|
||||
export async function getStoredDeviceName(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(DEVICE_NAME_KEY);
|
||||
}
|
||||
|
||||
export function useDeviceRegistration() {
|
||||
const [device, setDevice] = useState<{ localId: string; serverId: string | null; name: string } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRegistered, setIsRegistered] = useState<boolean | null>(null);
|
||||
|
||||
const register = useCallback(async (deviceName: string) => {
|
||||
const localId = await getOrCreateDeviceId();
|
||||
const result = await apiClient.post<{ id: string; device_name: string; role: string }>(ENDPOINTS.DEVICES, {
|
||||
device_name: deviceName,
|
||||
});
|
||||
|
||||
await SecureStore.setItemAsync(DEVICE_SERVER_ID_KEY, result.id);
|
||||
await SecureStore.setItemAsync(DEVICE_NAME_KEY, result.device_name);
|
||||
|
||||
setDevice({ localId, serverId: result.id, name: result.device_name });
|
||||
setIsRegistered(true);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const checkRegistration = useCallback(async () => {
|
||||
try {
|
||||
const localId = await getOrCreateDeviceId();
|
||||
const storedName = await getStoredDeviceName();
|
||||
|
||||
try {
|
||||
const devices = await apiClient.get<DeviceType[]>(ENDPOINTS.DEVICES);
|
||||
if (devices.length > 0) {
|
||||
const existing = devices[0];
|
||||
await SecureStore.setItemAsync(DEVICE_SERVER_ID_KEY, existing.id);
|
||||
const name = storedName || existing.device_name;
|
||||
setDevice({ localId, serverId: existing.id, name });
|
||||
setIsRegistered(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not registered yet
|
||||
}
|
||||
|
||||
// Auto-register with a generated name instead of blocking
|
||||
const autoName = storedName || generateDefaultDeviceName();
|
||||
try {
|
||||
await register(autoName);
|
||||
} catch {
|
||||
setDevice({ localId, serverId: null, name: autoName });
|
||||
setIsRegistered(false);
|
||||
}
|
||||
} catch {
|
||||
setIsRegistered(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [register]);
|
||||
|
||||
useEffect(() => {
|
||||
checkRegistration();
|
||||
}, [checkRegistration]);
|
||||
|
||||
return {
|
||||
device,
|
||||
isLoading,
|
||||
isRegistered,
|
||||
register,
|
||||
checkRegistration,
|
||||
};
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { ExpoDownloadDetectModule, FileDetectedEvent } from '../modules/expo-download-detect';
|
||||
|
||||
export function useFileWatcher() {
|
||||
const [newFiles, setNewFiles] = useState<FileDetectedEvent[]>([]);
|
||||
const [isSupported] = useState(() => Platform.OS === 'android');
|
||||
|
||||
const clearNewFiles = useCallback(() => {
|
||||
setNewFiles([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupported) return;
|
||||
|
||||
ExpoDownloadDetectModule.startWatching();
|
||||
|
||||
const subscription = ExpoDownloadDetectModule.addListener('onNewFile', (event: FileDetectedEvent) => {
|
||||
setNewFiles((prev) => {
|
||||
if (prev.some((f) => f.id === event.id)) return prev;
|
||||
return [...prev, event];
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
ExpoDownloadDetectModule.stopWatching();
|
||||
};
|
||||
}, [isSupported]);
|
||||
|
||||
return { newFiles, clearNewFiles, isSupported };
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import type { UnifiedFileItem, PaginatedResponse, FileItem, Tag } from '../types';
|
||||
import { fileStore } from '../services/fileStore';
|
||||
import { actionQueue } from '../services/actionQueue';
|
||||
import { useNetworkStatus } from './useNetworkStatus';
|
||||
|
||||
function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): UnifiedFileItem | null {
|
||||
if (!record) return null;
|
||||
return {
|
||||
id: record.id,
|
||||
backendResourceId: record.backendId ?? undefined,
|
||||
name: record.name,
|
||||
mimeType: record.mimeType,
|
||||
size: record.size,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
source: record.source as UnifiedFileItem['source'],
|
||||
syncStatus: record.syncStatus as UnifiedFileItem['syncStatus'],
|
||||
localUri: record.localUri ?? undefined,
|
||||
ocrText: record.ocrText ?? undefined,
|
||||
tags: record.tags ?? [],
|
||||
isFolder: record.isFolder === 1,
|
||||
parentResourceId: record.parentResourceId ?? undefined,
|
||||
ownerId: record.ownerId ?? undefined,
|
||||
thumbnailUrl: record.thumbnailUrl ?? undefined,
|
||||
thumbnailLocal: record.thumbnailLocal ?? undefined,
|
||||
isDeviceFile: record.source === 'local' && !record.backendId,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
|
||||
const queryKey = parentId
|
||||
? ['resources', parentId, page, limit]
|
||||
: ['resources', 'root', page, limit];
|
||||
|
||||
const { isOnline } = useNetworkStatus();
|
||||
|
||||
return useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
|
||||
if ( !isOnline ) {
|
||||
|
||||
if ( parentId ) {
|
||||
|
||||
const children = fileStore.getChildrenByParent(parentId);
|
||||
|
||||
return {
|
||||
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||
meta: { page, total: children.length },
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
const cached = fileStore.getRootFiles();
|
||||
const localDeviceFiles = fileStore.getLocalDeviceFiles();
|
||||
const validFiles = cached.files.filter(
|
||||
(f) => !f.backendId || returnedIds.has(f.backendId) || f.source === 'local',
|
||||
);
|
||||
const validIds = new Set(validFiles.map((f) => f.id));
|
||||
const extraLocal = localDeviceFiles.filter((f) => !validIds.has(f.id));
|
||||
const mergedFiles = [...validFiles, ...extraLocal];
|
||||
|
||||
return {
|
||||
data: mergedFiles.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||
meta: { page, total: ( cached.total) + extraLocal.length },
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (parentId) {
|
||||
const backendRes = await apiClient.get<PaginatedResponse<FileItem>>(
|
||||
`${ENDPOINTS.RESOURCES}/folders/${parentId}/resources?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
|
||||
);
|
||||
fileStore.mergeFromBackend(
|
||||
backendRes.data.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
createdAt: f.createdAt,
|
||||
updatedAt: f.updatedAt,
|
||||
ocrText: f.ocrText,
|
||||
tags: f.tags,
|
||||
isFolder: f.isFolder,
|
||||
parentResourceId: f.parentResourceId,
|
||||
ownerId: f.ownerId,
|
||||
thumbnailUrl: f.thumbnailUrl,
|
||||
})),
|
||||
);
|
||||
const children = fileStore.getChildrenByParent(parentId);
|
||||
return {
|
||||
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||
meta: { page, total: backendRes.meta?.total ?? children.length },
|
||||
};
|
||||
}
|
||||
|
||||
const backendRes = await apiClient.get<PaginatedResponse<FileItem>>(
|
||||
`${ENDPOINTS.RESOURCES}?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
|
||||
);
|
||||
const returnedIds = new Set(backendRes.data.map((f) => f.id));
|
||||
|
||||
fileStore.mergeFromBackend(
|
||||
backendRes.data.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
createdAt: f.createdAt,
|
||||
updatedAt: f.updatedAt,
|
||||
ocrText: f.ocrText,
|
||||
tags: f.tags,
|
||||
isFolder: f.isFolder,
|
||||
parentResourceId: f.parentResourceId,
|
||||
ownerId: f.ownerId,
|
||||
thumbnailUrl: f.thumbnailUrl,
|
||||
})),
|
||||
);
|
||||
|
||||
const cached = fileStore.getRootFiles();
|
||||
const localDeviceFiles = fileStore.getLocalDeviceFiles();
|
||||
const validFiles = cached.files.filter(
|
||||
(f) => !f.backendId || returnedIds.has(f.backendId) || f.source === 'local',
|
||||
);
|
||||
const validIds = new Set(validFiles.map((f) => f.id));
|
||||
const extraLocal = localDeviceFiles.filter((f) => !validIds.has(f.id));
|
||||
const mergedFiles = [...validFiles, ...extraLocal];
|
||||
return {
|
||||
data: mergedFiles.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||
meta: { page, total: (backendRes.meta?.total ?? cached.total) + extraLocal.length },
|
||||
};
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
initialData: () => {
|
||||
if (!parentId) {
|
||||
const cached = fileStore.getRootFiles();
|
||||
const localDeviceFiles = fileStore.getLocalDeviceFiles();
|
||||
|
||||
const validIds = new Set(cached.files.map((f) => f.id));
|
||||
const extraLocal = localDeviceFiles.filter((f) => !validIds.has(f.id));
|
||||
const allFiles = [...cached.files, ...extraLocal];
|
||||
if (allFiles.length === 0) return undefined;
|
||||
return {
|
||||
data: allFiles.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||
meta: { page: 0, total: cached.total + extraLocal.length },
|
||||
};
|
||||
}
|
||||
const children = fileStore.getChildrenByParent(parentId);
|
||||
if (children.length === 0) return undefined;
|
||||
return {
|
||||
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||
meta: { page: 0, total: children.length },
|
||||
};
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useFile(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['resources', id],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<{ data: FileItem }>(`${ENDPOINTS.RESOURCES}/${id}?thumbnail=thumbnail_small`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const record = fileStore.getByBackendId(id) ?? fileStore.getById(id);
|
||||
|
||||
if (record?.backendId) {
|
||||
actionQueue.enqueue('delete', { backendId: record.backendId }, record.backendId);
|
||||
}
|
||||
fileStore.deleteByBackendId(id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTags() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ fileId, tags }: { fileId: string; tags: string[] }) => {
|
||||
const record = fileStore.getById(fileId) ?? fileStore.getByBackendId(fileId);
|
||||
if (record) {
|
||||
const existingTags = record.tags ?? [];
|
||||
const newTags = [...existingTags, ...tags.map((t) => ({ id: t, tag_name: t }))];
|
||||
const uniqueTags = newTags.filter((t, i, arr) => arr.findIndex((x) => x.tag_name === t.tag_name) === i);
|
||||
fileStore.updatePartial(record.id, {});
|
||||
actionQueue.enqueue('tag_add', { fileId: record.backendId ?? record.id, tags }, record.backendId ?? record.id);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMoveResources() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ resourceIds, parentResourceId }: { resourceIds: string[]; parentResourceId: string | null }) => {
|
||||
const backendIds: string[] = [];
|
||||
for (const id of resourceIds) {
|
||||
const record = fileStore.getById(id) ?? fileStore.getByBackendId(id);
|
||||
if (record?.backendId) {
|
||||
backendIds.push(record.backendId);
|
||||
fileStore.updatePartial(record.id, { parentResourceId: parentResourceId ?? null });
|
||||
}
|
||||
}
|
||||
if (backendIds.length > 0) {
|
||||
actionQueue.enqueue('move', { resourceIds: backendIds, parentResourceId });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFolders() {
|
||||
return useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: async () => {
|
||||
const backendRes = await apiClient.get<{ data: FileItem[] }>(ENDPOINTS.FOLDERS);
|
||||
fileStore.mergeFromBackend(
|
||||
backendRes.data.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
createdAt: f.createdAt,
|
||||
updatedAt: f.updatedAt,
|
||||
ocrText: f.ocrText,
|
||||
tags: f.tags,
|
||||
isFolder: f.isFolder,
|
||||
parentResourceId: f.parentResourceId,
|
||||
ownerId: f.ownerId,
|
||||
thumbnailUrl: f.thumbnailUrl,
|
||||
})),
|
||||
);
|
||||
return fileStore.getAllFolders();
|
||||
},
|
||||
initialData: () => {
|
||||
const folders = fileStore.getAllFolders();
|
||||
return folders.length > 0 ? folders : undefined;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFolder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, parentResourceId }: { name: string; parentResourceId?: string }) => {
|
||||
const now = new Date().toISOString();
|
||||
const localId = `local_folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
fileStore.upsert({
|
||||
id: localId,
|
||||
backendId: null,
|
||||
name,
|
||||
mimeType: 'inode/directory',
|
||||
size: 0,
|
||||
source: 'local',
|
||||
localUri: null,
|
||||
syncStatus: 'local',
|
||||
parentResourceId: parentResourceId ?? null,
|
||||
isFolder: 1,
|
||||
ocrText: null,
|
||||
thumbnailUrl: null,
|
||||
ownerId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastSyncedAt: null,
|
||||
});
|
||||
actionQueue.enqueue('create_folder', { name, parentResourceId }, localId);
|
||||
return { id: localId, name };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFilesByParent(parentId: string) {
|
||||
return useFiles(parentId);
|
||||
}
|
||||
|
||||
export function useDownloadFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (file: UnifiedFileItem): Promise<string> => {
|
||||
if (file.syncStatus !== 'cloud') {
|
||||
return file.localUri ?? '';
|
||||
}
|
||||
|
||||
const res = await apiClient.get<{ url: string }>(
|
||||
`${ENDPOINTS.RESOURCES}/${file.backendResourceId ?? file.id}`,
|
||||
);
|
||||
|
||||
const { downloadAsync, documentDirectory, makeDirectoryAsync } = await import('expo-file-system/legacy');
|
||||
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
|
||||
await makeDirectoryAsync(DOWNLOAD_DIR, { intermediates: true });
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < file.name.length; i++) {
|
||||
hash = ((hash << 5) - hash + file.name.charCodeAt(i)) | 0;
|
||||
}
|
||||
const cacheKey = Math.abs(hash).toString(36);
|
||||
const dot = file.name.lastIndexOf('.');
|
||||
const ext = dot >= 0 ? file.name.slice(dot) : '';
|
||||
const fileUri = `${DOWNLOAD_DIR}${cacheKey}${ext}`;
|
||||
|
||||
const result = await downloadAsync(res.url, fileUri);
|
||||
|
||||
fileStore.upsert({
|
||||
id: file.backendResourceId ?? file.id,
|
||||
backendId: file.backendResourceId ?? file.id,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
source: 'synced',
|
||||
localUri: result.uri,
|
||||
syncStatus: 'synced',
|
||||
parentResourceId: file.parentResourceId ?? null,
|
||||
isFolder: 0,
|
||||
ocrText: file.ocrText ?? null,
|
||||
thumbnailUrl: file.thumbnailUrl ?? null,
|
||||
ownerId: file.ownerId ?? null,
|
||||
createdAt: file.createdAt,
|
||||
updatedAt: file.updatedAt ?? file.createdAt,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
tags: file.tags,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
return result.uri;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFreeLocalSpace() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (fileIds: string[]) => {
|
||||
const { deleteAsync } = await import('expo-file-system/legacy');
|
||||
for (const fid of fileIds) {
|
||||
const entry = fileStore.getByBackendId(fid);
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.localUri) {
|
||||
try {
|
||||
await deleteAsync(entry.localUri, { idempotent: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
fileStore.markAsCloudOnly(entry.id);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useMemo, useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useDeviceFiles } from './useDeviceFiles';
|
||||
import { fileStore } from '../services/fileStore';
|
||||
import { generateLocalThumbnail } from '../services/thumbnail';
|
||||
import { UnifiedFileItem } from '../types';
|
||||
|
||||
export function useLocalFiles() {
|
||||
const { files: deviceFiles, isLoading: deviceLoading, rescan, pickAndScanRecursive, folders, refreshFolders, discovered } = useDeviceFiles();
|
||||
const queryClient = useQueryClient();
|
||||
const lastDeviceCount = useRef(0);
|
||||
const thumbnailQueue = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceFiles.length === 0) return;
|
||||
if (deviceFiles.length === lastDeviceCount.current) return;
|
||||
lastDeviceCount.current = deviceFiles.length;
|
||||
|
||||
const toMerge = deviceFiles.map((df) => ({
|
||||
id: df.id,
|
||||
uri: df.uri,
|
||||
name: df.name,
|
||||
mimeType: df.mimeType,
|
||||
size: df.size,
|
||||
createdAt: df.createdAt,
|
||||
folderId: df.folderId,
|
||||
}));
|
||||
|
||||
fileStore.mergeFromDevice(toMerge);
|
||||
|
||||
const jobs: Promise<void>[] = [];
|
||||
for (const df of toMerge) {
|
||||
if (thumbnailQueue.current.has(df.id)) continue;
|
||||
const mime = (df.mimeType ?? '').toLowerCase();
|
||||
if (!mime.startsWith('image/')) continue;
|
||||
if (fileStore.getById(df.id)?.thumbnailLocal) continue;
|
||||
thumbnailQueue.current.add(df.id);
|
||||
jobs.push(
|
||||
generateLocalThumbnail(df.uri, df.mimeType).then((thumb) => {
|
||||
try {
|
||||
if (thumb) fileStore.setThumbnailLocal(df.id, thumb);
|
||||
} catch {}
|
||||
}).finally(() => {
|
||||
thumbnailQueue.current.delete(df.id);
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (jobs.length > 0) {
|
||||
Promise.all(jobs).finally(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
});
|
||||
}
|
||||
}, [deviceFiles, queryClient]);
|
||||
|
||||
const localFiles = useMemo(() => {
|
||||
const registryEntries = fileStore.getAllLocal();
|
||||
const merged = new Map<string, UnifiedFileItem>();
|
||||
|
||||
for (const entry of registryEntries) {
|
||||
merged.set(entry.id, {
|
||||
id: entry.id,
|
||||
backendResourceId: entry.backendId ?? undefined,
|
||||
name: entry.name,
|
||||
mimeType: entry.mimeType,
|
||||
size: entry.size,
|
||||
createdAt: entry.createdAt,
|
||||
source: entry.source as UnifiedFileItem['source'],
|
||||
syncStatus: entry.syncStatus as UnifiedFileItem['syncStatus'],
|
||||
localUri: entry.localUri ?? undefined,
|
||||
thumbnailUrl: entry.thumbnailUrl ?? undefined,
|
||||
thumbnailLocal: entry.thumbnailLocal ?? undefined,
|
||||
tags: entry.tags ?? [],
|
||||
isFolder: entry.isFolder === 1,
|
||||
parentResourceId: entry.parentResourceId ?? undefined,
|
||||
isDeviceFile: entry.source === 'local' && !entry.backendId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const df of deviceFiles) {
|
||||
if (!merged.has(df.id) && !fileStore.isDeleted(df.id)) {
|
||||
merged.set(df.id, {
|
||||
id: df.id,
|
||||
name: df.name,
|
||||
mimeType: df.mimeType,
|
||||
size: df.size,
|
||||
createdAt: df.createdAt,
|
||||
source: 'local',
|
||||
syncStatus: 'local',
|
||||
localUri: df.uri,
|
||||
tags: [],
|
||||
isFolder: false,
|
||||
isDeviceFile: true,
|
||||
parentResourceId: df.folderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(merged.values());
|
||||
}, [deviceFiles]);
|
||||
|
||||
return {
|
||||
localFiles,
|
||||
isLoading: deviceLoading,
|
||||
rescan,
|
||||
pickAndScanRecursive,
|
||||
folders,
|
||||
refreshFolders,
|
||||
discovered,
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useSyncExternalStore, useRef } from 'react';
|
||||
import NetInfo, { NetInfoState, NetInfoSubscription } from '@react-native-community/netinfo';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let state: NetInfoState | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
let subscription: NetInfoSubscription | null = null;
|
||||
|
||||
function subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return state?.isConnected ?? true;
|
||||
}
|
||||
|
||||
function initIfNeeded() {
|
||||
if (subscription) return;
|
||||
NetInfo.fetch().then((info) => {
|
||||
state = info;
|
||||
listeners.forEach((l) => l());
|
||||
});
|
||||
subscription = NetInfo.addEventListener((info) => {
|
||||
state = info;
|
||||
listeners.forEach((l) => l());
|
||||
});
|
||||
}
|
||||
|
||||
export interface NetworkStatus {
|
||||
isOnline: boolean;
|
||||
isWifi: boolean;
|
||||
isCellular: boolean;
|
||||
connectionType: string;
|
||||
isInternetReachable: boolean | null;
|
||||
}
|
||||
|
||||
export function useNetworkStatus(): NetworkStatus {
|
||||
const mountRef = useRef(false);
|
||||
if (!mountRef.current) {
|
||||
initIfNeeded();
|
||||
mountRef.current = true;
|
||||
}
|
||||
|
||||
const isConnected = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
return {
|
||||
isOnline: isConnected,
|
||||
isWifi: state?.type === 'wifi',
|
||||
isCellular: state?.type === 'cellular',
|
||||
connectionType: state?.type ?? 'unknown',
|
||||
isInternetReachable: state?.isInternetReachable ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import * as Print from 'expo-print';
|
||||
|
||||
export function usePdfGeneration() {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const generatePdf = useCallback(async (items: { uri: string }[], progressCb?: (pct: number) => void): Promise<string | null> => {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
setGenerating(true);
|
||||
setProgress(0);
|
||||
progressCb?.(0);
|
||||
|
||||
try {
|
||||
const imagesHtml = items.map((item) => {
|
||||
return `<div style="page-break-after: always; display: flex; justify-content: center; align-items: center; height: 100vh;">
|
||||
<img src="${item.uri}" style="max-width: 100%; max-height: 100vh; object-fit: contain;" />
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #fff; }
|
||||
@media print {
|
||||
@page { margin: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${imagesHtml}</body>
|
||||
</html>`;
|
||||
|
||||
const { uri } = await Print.printToFileAsync({ html });
|
||||
setProgress(100);
|
||||
progressCb?.(100);
|
||||
return uri;
|
||||
} catch (err) {
|
||||
console.error('PDF generation failed:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
generatePdf,
|
||||
generating,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useSyncExternalStore, useRef } from 'react';
|
||||
import { actionQueue } from '../services/actionQueue';
|
||||
import { useNetworkStatus } from './useNetworkStatus';
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
return actionQueue.subscribe(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): number {
|
||||
return actionQueue.getPendingCount();
|
||||
}
|
||||
|
||||
export function usePendingActions() {
|
||||
const pendingCount = useSyncExternalStore(subscribe, getSnapshot);
|
||||
const { isOnline } = useNetworkStatus();
|
||||
const wasOffline = useRef(!isOnline);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnline && wasOffline.current && pendingCount > 0) {
|
||||
actionQueue.processAll();
|
||||
}
|
||||
wasOffline.current = !isOnline;
|
||||
}, [isOnline, pendingCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingCount > 0 && isOnline) {
|
||||
actionQueue.scheduleRetry(5_000);
|
||||
}
|
||||
return () => actionQueue.cancelSchedule();
|
||||
}, [pendingCount, isOnline]);
|
||||
|
||||
return {
|
||||
pendingCount,
|
||||
isProcessing: actionQueue.isProcessing(),
|
||||
processAll: useCallback(() => actionQueue.processAll(), []),
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import { fileStore } from '../services/fileStore';
|
||||
import { useOcrDone } from '../contexts/SseContext';
|
||||
|
||||
function handleOcrDone(resourceId: string) {
|
||||
apiClient
|
||||
.get<{ data: { ocrText?: string } }>(`${ENDPOINTS.RESOURCES}/${resourceId}`)
|
||||
.then((detail) => {
|
||||
const ocrText = detail.data?.ocrText;
|
||||
if (ocrText) {
|
||||
fileStore.updatePartial(resourceId, { ocrText });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function SseOcrListener() {
|
||||
const { onOcrDone } = useOcrDone();
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onOcrDone(handleOcrDone);
|
||||
return unsub;
|
||||
}, [onOcrDone]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy';
|
||||
import { fileStore } from '../services/fileStore';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
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);
|
||||
|
||||
let page = 1;
|
||||
const limit = 100;
|
||||
let total = 0;
|
||||
const backendResources: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
ownerId?: string;
|
||||
}> = [];
|
||||
|
||||
do {
|
||||
const res = await apiClient.get<{ data: Array<typeof backendResources[number]>; meta?: { total: number } }>(
|
||||
`${ENDPOINTS.RESOURCES}?page=${page}&limit=${limit}&thumbnail=thumbnail_small`,
|
||||
);
|
||||
backendResources.push(...(res.data ?? []));
|
||||
total = res.meta?.total ?? res.data.length;
|
||||
page++;
|
||||
} while (backendResources.length < total);
|
||||
|
||||
const registry = fileStore.getAllSynced();
|
||||
const existingBackendIds = new Set(
|
||||
registry.filter((e) => e.backendId).map((e) => e.backendId)
|
||||
);
|
||||
|
||||
let pulled = 0;
|
||||
|
||||
for (const br of backendResources) {
|
||||
if (existingBackendIds.has(br.id)) continue;
|
||||
if (br.size === 0) continue;
|
||||
|
||||
try {
|
||||
const detail = await apiClient.get<{ url: string }>(`${ENDPOINTS.RESOURCES}/${br.id}`);
|
||||
const downloadUrl = detail.url;
|
||||
|
||||
await makeDirectoryAsync(SYNC_DIR, { intermediates: true });
|
||||
const safeName = br.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileUri = `${SYNC_DIR}${br.id}_${safeName}`;
|
||||
|
||||
const result = await downloadAsync(downloadUrl, fileUri);
|
||||
|
||||
fileStore.upsert({
|
||||
id: br.id,
|
||||
backendId: br.id,
|
||||
name: br.name,
|
||||
mimeType: br.mimeType,
|
||||
size: br.size,
|
||||
source: 'synced',
|
||||
localUri: result.uri,
|
||||
syncStatus: 'synced',
|
||||
parentResourceId: null,
|
||||
isFolder: 0,
|
||||
ocrText: null,
|
||||
thumbnailUrl: br.thumbnailUrl ?? null,
|
||||
ownerId: br.ownerId ?? null,
|
||||
createdAt: br.createdAt,
|
||||
updatedAt: br.createdAt,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
});
|
||||
pulled++;
|
||||
} catch {
|
||||
// skip individual file failures
|
||||
}
|
||||
}
|
||||
|
||||
if (pulled > 0) {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
}
|
||||
|
||||
return { pulled };
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
isRunning.current = false;
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
return { pullNewFiles };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useResourceCreated } from '../contexts/SseContext';
|
||||
|
||||
export function SseResourceListener() {
|
||||
const queryClient = useQueryClient();
|
||||
const { onResourceCreated } = useResourceCreated();
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onResourceCreated(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
});
|
||||
return unsub;
|
||||
}, [onResourceCreated, queryClient]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { fileStore } from '../services/fileStore';
|
||||
import { UnifiedFileItem } from '../types';
|
||||
|
||||
function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): UnifiedFileItem | null {
|
||||
if (!record) return null;
|
||||
return {
|
||||
id: record.id,
|
||||
backendResourceId: record.backendId ?? undefined,
|
||||
name: record.name,
|
||||
mimeType: record.mimeType,
|
||||
size: record.size,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
source: record.source as UnifiedFileItem['source'],
|
||||
syncStatus: record.syncStatus as UnifiedFileItem['syncStatus'],
|
||||
localUri: record.localUri ?? undefined,
|
||||
ocrText: record.ocrText ?? undefined,
|
||||
tags: record.tags ?? [],
|
||||
isFolder: record.isFolder === 1,
|
||||
parentResourceId: record.parentResourceId ?? undefined,
|
||||
ownerId: record.ownerId ?? undefined,
|
||||
thumbnailUrl: record.thumbnailUrl ?? undefined,
|
||||
thumbnailLocal: record.thumbnailLocal ?? undefined,
|
||||
isDeviceFile: record.source === 'local' && !record.backendId,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearch(query: string) {
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
const records = fileStore.searchFts(query);
|
||||
if (records.length === 0) {
|
||||
const fallback = fileStore.search(query);
|
||||
return fallback.map((r) => recordToUnifiedItem(r)!).filter(Boolean);
|
||||
}
|
||||
return records.map((r) => recordToUnifiedItem(r)!).filter(Boolean);
|
||||
}, [query]);
|
||||
|
||||
return {
|
||||
data: results,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ShareEntry } from '../types';
|
||||
|
||||
function shareEndpoint(resourceId: string) {
|
||||
return `/resources/${resourceId}/share`;
|
||||
}
|
||||
|
||||
export function useShares(resourceId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['shares', resourceId],
|
||||
queryFn: () => apiClient.get<ShareEntry[]>(shareEndpoint(resourceId)),
|
||||
enabled: !!resourceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrantShare() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ resourceId, subjectUserId, role }: { resourceId: string; subjectUserId: string; role: string }) =>
|
||||
apiClient.post(shareEndpoint(resourceId), { subject_user_id: subjectUserId, role }),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shares', variables.resourceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeShare() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ resourceId, userId }: { resourceId: string; userId: string }) =>
|
||||
apiClient.delete(`${shareEndpoint(resourceId)}/${userId}`),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shares', variables.resourceId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCheckAccess(resourceId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['access', resourceId],
|
||||
queryFn: () => apiClient.get<{ role: string; access: boolean }>(`/resources/${resourceId}/access`),
|
||||
enabled: !!resourceId,
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import { SyncQueueItem } from '../types';
|
||||
|
||||
export function useSyncPull() {
|
||||
const isRunning = useRef(false);
|
||||
|
||||
const pull = useCallback(async (locationId?: string) => {
|
||||
if (isRunning.current) return { items: [] };
|
||||
isRunning.current = true;
|
||||
|
||||
try {
|
||||
const body = locationId ? { location_id: locationId } : {};
|
||||
const result = await apiClient.post<SyncQueueItem[]>(ENDPOINTS.SYNC_PULL, body);
|
||||
return { items: result };
|
||||
} finally {
|
||||
isRunning.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { pull };
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
|
||||
export function useSyncPush() {
|
||||
const isRunning = useRef(false);
|
||||
|
||||
const push = useCallback(async (locationId: string) => {
|
||||
if (isRunning.current) return { pending: 0 };
|
||||
isRunning.current = true;
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<{ pending: number; message: string }>(ENDPOINTS.SYNC_PUSH, {
|
||||
location_id: locationId,
|
||||
});
|
||||
return { pending: result.pending };
|
||||
} finally {
|
||||
isRunning.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { push };
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
import { fileStore } from '../services/fileStore';
|
||||
|
||||
export type SyncProgress = {
|
||||
files: Array<{ id: string; name: string }>;
|
||||
currentIndex: number;
|
||||
} | null;
|
||||
|
||||
const syncStateStorage = createMMKV({ id: 'vaultdrop-sync-state' });
|
||||
const PROGRESS_KEY = 'sync_progress';
|
||||
|
||||
let syncRunning = false;
|
||||
let cancelRequested = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function getIsSyncing(): boolean {
|
||||
return syncStateStorage.getString('is_syncing') === 'true';
|
||||
}
|
||||
|
||||
export function setIsSyncing(value: boolean) {
|
||||
syncStateStorage.set('is_syncing', value ? 'true' : 'false');
|
||||
notify();
|
||||
}
|
||||
|
||||
export function getSyncProgress(): SyncProgress {
|
||||
return parseProgress(syncStateStorage.getString(PROGRESS_KEY));
|
||||
}
|
||||
|
||||
function parseProgress(raw: string | undefined): SyncProgress {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { files: Array<{ id: string; name: string }>; currentIndex: number };
|
||||
if (!parsed || !Array.isArray(parsed.files)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSyncProgress(value: SyncProgress) {
|
||||
if (value === null) {
|
||||
syncStateStorage.remove(PROGRESS_KEY);
|
||||
} else {
|
||||
syncStateStorage.set(PROGRESS_KEY, JSON.stringify(value));
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
export function isSyncLoopRunning(): boolean {
|
||||
return syncRunning;
|
||||
}
|
||||
|
||||
export function setSyncLoopRunning(value: boolean) {
|
||||
syncRunning = value;
|
||||
}
|
||||
|
||||
export function requestSyncCancel() {
|
||||
cancelRequested = true;
|
||||
}
|
||||
|
||||
export function isSyncCancelRequested(): boolean {
|
||||
return cancelRequested;
|
||||
}
|
||||
|
||||
export function consumeSyncCancel(): boolean {
|
||||
const value = cancelRequested;
|
||||
cancelRequested = false;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resetSyncState() {
|
||||
syncStateStorage.remove('is_syncing');
|
||||
syncStateStorage.remove(PROGRESS_KEY);
|
||||
syncRunning = false;
|
||||
cancelRequested = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
let cachedIsSyncing = getIsSyncing();
|
||||
let lastIsSyncingRaw: string | undefined;
|
||||
|
||||
function getIsSyncingSnapshot(): boolean {
|
||||
const raw = syncStateStorage.getString('is_syncing');
|
||||
if (raw !== lastIsSyncingRaw) {
|
||||
lastIsSyncingRaw = raw;
|
||||
cachedIsSyncing = raw === 'true';
|
||||
}
|
||||
return cachedIsSyncing;
|
||||
}
|
||||
|
||||
let cachedProgress = getSyncProgress();
|
||||
let lastProgressRaw: string | undefined;
|
||||
|
||||
function getSyncProgressSnapshot(): SyncProgress {
|
||||
const raw = syncStateStorage.getString(PROGRESS_KEY);
|
||||
if (raw !== lastProgressRaw) {
|
||||
lastProgressRaw = raw;
|
||||
cachedProgress = parseProgress(raw);
|
||||
}
|
||||
return cachedProgress;
|
||||
}
|
||||
|
||||
export function useSyncQueue() {
|
||||
const isSyncing = useSyncExternalStore(subscribe, getIsSyncingSnapshot, getIsSyncingSnapshot);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setPendingCount(fileStore.countPendingSync());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const interval = setInterval(refresh, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh]);
|
||||
|
||||
return { pendingCount, isSyncing, refresh };
|
||||
}
|
||||
|
||||
export function useSyncProgress() {
|
||||
const syncProgress = useSyncExternalStore(subscribe, getSyncProgressSnapshot, getSyncProgressSnapshot);
|
||||
return { syncProgress };
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { File, UploadType } from 'expo-file-system';
|
||||
import { apiClient } from '../api/client';
|
||||
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
||||
import { ApiError, UploadError } from '../types';
|
||||
|
||||
export type UploadFile = { uri: string; type: string; name: string };
|
||||
export type UploadResult = { name: string; id: string };
|
||||
|
||||
export function useUpload() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (files: UploadFile[]) => {
|
||||
const results = await Promise.allSettled(
|
||||
files.map(async (file) => {
|
||||
const fsFile = new File(file.uri);
|
||||
const headers: Record<string, string> = {};
|
||||
const token = apiClient.getAccessToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const result = await fsFile.upload(`${API_BASE_URL}${ENDPOINTS.UPLOAD}`, {
|
||||
httpMethod: 'POST',
|
||||
uploadType: UploadType.MULTIPART,
|
||||
fieldName: 'file',
|
||||
mimeType: file.type,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (result.status >= 400) {
|
||||
let serverMessage = 'Erreur serveur';
|
||||
let serverCode: string | undefined;
|
||||
try {
|
||||
const body: ApiError = JSON.parse(result.body);
|
||||
serverMessage = body.error?.message || serverMessage;
|
||||
serverCode = body.error?.code;
|
||||
} catch {
|
||||
serverMessage = result.body || serverMessage;
|
||||
}
|
||||
throw new UploadError(file.name, result.status, serverMessage, serverCode);
|
||||
}
|
||||
|
||||
const body = JSON.parse(result.body);
|
||||
const items = body.data ?? body;
|
||||
const item = Array.isArray(items) ? items[0] : items;
|
||||
return item as UploadResult;
|
||||
})
|
||||
);
|
||||
|
||||
const uploaded: UploadResult[] = [];
|
||||
const errors: UploadError[] = [];
|
||||
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled') {
|
||||
uploaded.push(r.value);
|
||||
} else {
|
||||
const reason = r.reason;
|
||||
if (reason instanceof UploadError) {
|
||||
errors.push(reason);
|
||||
} else {
|
||||
errors.push(new UploadError(files[i].name, 0, reason?.message || 'Upload failed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0 && uploaded.length === 0) {
|
||||
throw errors[0];
|
||||
}
|
||||
|
||||
return { uploaded, errors };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { uploadQueue, UploadFile } from '../services/uploadQueue';
|
||||
|
||||
export function useUploadQueue() {
|
||||
const tasks = useSyncExternalStore(
|
||||
uploadQueue.subscribe.bind(uploadQueue),
|
||||
uploadQueue.getTasks.bind(uploadQueue),
|
||||
);
|
||||
|
||||
return {
|
||||
tasks,
|
||||
enqueue: (files: UploadFile[]) => uploadQueue.enqueue(files),
|
||||
cancel: (id: string) => uploadQueue.cancel(id),
|
||||
retry: (id: string) => uploadQueue.retry(id),
|
||||
retryAll: () => uploadQueue.retryAll(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user