fix sync
This commit is contained in:
@@ -28,9 +28,11 @@ import { onboardingStorage } from './services/onboardingStorage';
|
|||||||
import { initDB } from './services/fileStore';
|
import { initDB } from './services/fileStore';
|
||||||
import { migrateFromLegacy } from './services/fileStore/migrate';
|
import { migrateFromLegacy } from './services/fileStore/migrate';
|
||||||
import { createMMKVPersister } from './services/mmkvPersister';
|
import { createMMKVPersister } from './services/mmkvPersister';
|
||||||
|
import { setIsSyncing } from './hooks/useSyncQueue';
|
||||||
|
|
||||||
initDB();
|
initDB();
|
||||||
migrateFromLegacy();
|
migrateFromLegacy();
|
||||||
|
setIsSyncing(false);
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator();
|
const Stack = createNativeStackNavigator();
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function HomeScreen() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const debouncedSearch = useDebounce(searchQuery, 250);
|
const debouncedSearch = useDebounce(searchQuery, 250);
|
||||||
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true });
|
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true });
|
||||||
const [mediaFilter, setMediaFilter] = useState<MediaFilter>('all');
|
const [mediaFilter, setMediaFilter] = useState<MediaFilter>('documents');
|
||||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [numColumns, setNumColumns] = useState(3);
|
const [numColumns, setNumColumns] = useState(3);
|
||||||
@@ -149,6 +149,7 @@ export function HomeScreen() {
|
|||||||
const totalFiles = data?.meta?.total ?? 0;
|
const totalFiles = data?.meta?.total ?? 0;
|
||||||
const loadedFiles = data?.data?.length ?? 0;
|
const loadedFiles = data?.data?.length ?? 0;
|
||||||
const hasMore = loadedFiles > 0 && loadedFiles < totalFiles;
|
const hasMore = loadedFiles > 0 && loadedFiles < totalFiles;
|
||||||
|
const isFiltering = mediaFilter !== 'all' || !!debouncedSearch.trim();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
||||||
@@ -233,6 +234,8 @@ export function HomeScreen() {
|
|||||||
});
|
});
|
||||||
}, [mediaFilteredFiles, uploadGhostItems]);
|
}, [mediaFilteredFiles, uploadGhostItems]);
|
||||||
|
|
||||||
|
const displayedCount = sortedFiles.length;
|
||||||
|
|
||||||
const fileIdToIndex = useMemo(() => {
|
const fileIdToIndex = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
sortedFiles.forEach((f, i) => map.set(f.id, i));
|
sortedFiles.forEach((f, i) => map.set(f.id, i));
|
||||||
@@ -541,12 +544,12 @@ export function HomeScreen() {
|
|||||||
<ActivityIndicator size="small" color="#1976D2" />
|
<ActivityIndicator size="small" color="#1976D2" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.loadMoreText}>
|
<Text style={styles.loadMoreText}>
|
||||||
Charger plus ({loadedFiles}/{totalFiles})
|
Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`})
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : loadedFiles > 0 ? (
|
) : displayedCount > 0 ? (
|
||||||
<Text style={styles.loadedAllText}>{loadedFiles} fichier{loadedFiles > 1 ? 's' : ''}</Text>
|
<Text style={styles.loadedAllText}>{displayedCount} fichier{displayedCount > 1 ? 's' : ''}</Text>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
|
|||||||
+77
-16
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useCallback } from 'react';
|
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -45,11 +45,28 @@ export function SyncDetailScreen() {
|
|||||||
const { push } = useSyncPush();
|
const { push } = useSyncPush();
|
||||||
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
|
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
|
||||||
|
|
||||||
|
const [listVersion, setListVersion] = useState(0);
|
||||||
|
const bumpList = useCallback(() => setListVersion((v) => v + 1), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(bumpList, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [bumpList]);
|
||||||
|
|
||||||
const pendingFiles = useMemo(() => {
|
const pendingFiles = useMemo(() => {
|
||||||
return fileStore.getPendingSync();
|
return fileStore.getPendingSync();
|
||||||
}, []);
|
}, [listVersion]);
|
||||||
|
|
||||||
|
const errorFiles = useMemo(() => {
|
||||||
|
return fileStore.getErrorFiles();
|
||||||
|
}, [listVersion]);
|
||||||
|
|
||||||
|
const uploadErrors = uploadTasks.filter((t) => t.status === 'error');
|
||||||
|
|
||||||
const handleSyncAll = useCallback(async () => {
|
const handleSyncAll = useCallback(async () => {
|
||||||
|
for (const f of fileStore.getErrorFiles()) {
|
||||||
|
fileStore.resetSyncError(f.id);
|
||||||
|
}
|
||||||
await triggerSync();
|
await triggerSync();
|
||||||
try {
|
try {
|
||||||
const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration');
|
const { getStoredDeviceServerId } = await import('../hooks/useDeviceRegistration');
|
||||||
@@ -59,7 +76,35 @@ export function SyncDetailScreen() {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
refresh();
|
refresh();
|
||||||
}, [triggerSync, push, refresh]);
|
bumpList();
|
||||||
|
}, [triggerSync, push, refresh, bumpList]);
|
||||||
|
|
||||||
|
const handleSyncButtonPress = useCallback(async () => {
|
||||||
|
if (uploadErrors.length > 0) {
|
||||||
|
retryAll();
|
||||||
|
}
|
||||||
|
await handleSyncAll();
|
||||||
|
}, [uploadErrors.length, retryAll, handleSyncAll]);
|
||||||
|
|
||||||
|
const handleErrorFilePress = useCallback((file: FileRecord) => {
|
||||||
|
Alert.alert(
|
||||||
|
'Fichier en erreur',
|
||||||
|
'Réessayer la synchronisation de ce fichier ?',
|
||||||
|
[
|
||||||
|
{ text: 'Annuler', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Réessayer',
|
||||||
|
onPress: () => {
|
||||||
|
fileStore.resetSyncError(file.id);
|
||||||
|
triggerSync().finally(() => {
|
||||||
|
refresh();
|
||||||
|
bumpList();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [triggerSync, refresh, bumpList]);
|
||||||
|
|
||||||
const handleTaskPress = useCallback((task: UploadTask) => {
|
const handleTaskPress = useCallback((task: UploadTask) => {
|
||||||
if (task.status !== 'error') return;
|
if (task.status !== 'error') return;
|
||||||
@@ -75,16 +120,15 @@ export function SyncDetailScreen() {
|
|||||||
|
|
||||||
const hasUploads = uploadTasks.length > 0;
|
const hasUploads = uploadTasks.length > 0;
|
||||||
const hasPending = pendingFiles.length > 0;
|
const hasPending = pendingFiles.length > 0;
|
||||||
const hasContent = hasUploads || hasPending;
|
const hasErrors = errorFiles.length > 0;
|
||||||
|
const hasContent = hasUploads || hasPending || hasErrors;
|
||||||
const uploadErrors = uploadTasks.filter((t) => t.status === 'error');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{(hasPending || uploadErrors.length > 0) && (
|
{(hasPending || hasErrors || uploadErrors.length > 0) && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]}
|
style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]}
|
||||||
onPress={uploadErrors.length > 0 && hasPending ? retryAll : handleSyncAll}
|
onPress={handleSyncButtonPress}
|
||||||
disabled={isSyncing}
|
disabled={isSyncing}
|
||||||
>
|
>
|
||||||
{isSyncing ? (
|
{isSyncing ? (
|
||||||
@@ -98,11 +142,9 @@ export function SyncDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
<Text style={styles.syncBtnText}>
|
<Text style={styles.syncBtnText}>
|
||||||
{isSyncing ? 'Synchronisation...'
|
{isSyncing ? 'Synchronisation...'
|
||||||
: uploadErrors.length > 0 && hasPending
|
: uploadErrors.length > 0
|
||||||
? 'Tout réessayer'
|
? `Tout réessayer (${uploadErrors.length + (hasPending ? pendingCount : 0)})`
|
||||||
: hasPending
|
: `Synchroniser (${pendingCount})`}
|
||||||
? `Synchroniser (${pendingCount})`
|
|
||||||
: `Réessayer (${uploadErrors.length})`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
@@ -120,13 +162,13 @@ export function SyncDetailScreen() {
|
|||||||
data={[
|
data={[
|
||||||
...(hasUploads ? [{ type: 'section', label: 'Uploads en cours' } as const] : []),
|
...(hasUploads ? [{ type: 'section', label: 'Uploads en cours' } as const] : []),
|
||||||
...uploadTasks.map((t) => ({ type: 'upload' as const, data: t })),
|
...uploadTasks.map((t) => ({ type: 'upload' as const, data: t })),
|
||||||
|
...(hasErrors ? [{ type: 'section', label: 'Fichiers en erreur' } as const] : []),
|
||||||
|
...errorFiles.map((f) => ({ type: 'error' as const, data: f })),
|
||||||
...(hasPending ? [{ type: 'section', label: 'Fichiers locaux à synchroniser' } as const] : []),
|
...(hasPending ? [{ type: 'section', label: 'Fichiers locaux à synchroniser' } as const] : []),
|
||||||
...pendingFiles.map((f) => ({ type: 'file' as const, data: f })),
|
...pendingFiles.map((f) => ({ type: 'file' as const, data: f })),
|
||||||
]}
|
]}
|
||||||
keyExtractor={(item) =>
|
keyExtractor={(item) =>
|
||||||
item.type === 'section' ? item.label
|
item.type === 'section' ? item.label : item.data.id
|
||||||
: item.type === 'upload' ? item.data.id
|
|
||||||
: item.data.id
|
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
if (item.type === 'section') {
|
if (item.type === 'section') {
|
||||||
@@ -161,6 +203,25 @@ export function SyncDetailScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (item.type === 'error') {
|
||||||
|
const file = item.data;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.fileRow, styles.fileRowError]}
|
||||||
|
onPress={() => handleErrorFilePress(file)}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="error" size={20} color="#E53935" />
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
Échec de synchronisation · toucher pour réessayer
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons name="refresh" size={18} color="#E53935" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
const file = item.data;
|
const file = item.data;
|
||||||
return (
|
return (
|
||||||
<View style={styles.fileRow}>
|
<View style={styles.fileRow}>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { File, UploadType } from 'expo-file-system';
|
|||||||
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
|
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
|
||||||
import { safDirectory, StoredFolder } from '../services/safDirectory';
|
import { safDirectory, StoredFolder } from '../services/safDirectory';
|
||||||
import { fileStore } from '../services/fileStore';
|
import { fileStore } from '../services/fileStore';
|
||||||
|
import { activeUploadUris } from '../services/uploadQueue';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
||||||
import { ApiError } from '../types';
|
import { ApiError } from '../types';
|
||||||
@@ -113,9 +114,12 @@ export function useAutoSync() {
|
|||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
|
|
||||||
for (const entry of pendingFiles) {
|
for (const entry of pendingFiles) {
|
||||||
|
const uri = entry.localUri;
|
||||||
|
if (!uri || activeUploadUris.has(uri)) continue;
|
||||||
|
activeUploadUris.add(uri);
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadFile({
|
const uploaded = await uploadFile({
|
||||||
uri: entry.localUri!,
|
uri,
|
||||||
type: entry.mimeType,
|
type: entry.mimeType,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
});
|
});
|
||||||
@@ -134,6 +138,8 @@ export function useAutoSync() {
|
|||||||
});
|
});
|
||||||
resetRetry(entry.id);
|
resetRetry(entry.id);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
activeUploadUris.delete(uri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,6 @@ function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): Unif
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordsToUnifiedItems(records: ReturnType<typeof fileStore.getRootFiles>): {
|
|
||||||
data: UnifiedFileItem[];
|
|
||||||
meta: { page: number; total: number };
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
data: records.files.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
|
||||||
meta: { page: 0, total: records.total },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
|
export function useFiles(parentId?: string | null, page: number = 1, limit: number = 100) {
|
||||||
const queryKey = parentId
|
const queryKey = parentId
|
||||||
? ['resources', parentId, page, limit]
|
? ['resources', parentId, page, limit]
|
||||||
@@ -65,7 +55,6 @@ export function useFiles(parentId?: string | null, page: number = 1, limit: numb
|
|||||||
thumbnailUrl: f.thumbnailUrl,
|
thumbnailUrl: f.thumbnailUrl,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const children = fileStore.getChildrenByParent(parentId);
|
const children = fileStore.getChildrenByParent(parentId);
|
||||||
return {
|
return {
|
||||||
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
data: children.map((r) => recordToUnifiedItem(r)!).filter(Boolean),
|
||||||
|
|||||||
@@ -28,21 +28,25 @@ export function useSyncQueue() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = fileStore.getPendingSync();
|
const allFolders = safDirectory.getAll();
|
||||||
|
const autoFolderIds = new Set(
|
||||||
|
allFolders.filter((f) => f.syncMode === 'auto').map((f) => f.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const registry = fileStore.getAllLocal();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
for (const entry of registry) {
|
for (const entry of registry) {
|
||||||
|
if (entry.backendId || !entry.localUri) continue;
|
||||||
|
if (entry.syncStatus !== 'local' && entry.syncStatus !== 'error') continue;
|
||||||
if (globalMode === 'auto') {
|
if (globalMode === 'auto') {
|
||||||
count++;
|
count++;
|
||||||
} else {
|
} else {
|
||||||
// mode manuel : uniquement les fichiers des dossiers en mode auto
|
// mode manuel : uniquement les fichiers des dossiers en mode auto
|
||||||
if (!entry.parentResourceId) continue;
|
if (!entry.parentResourceId || !autoFolderIds.has(entry.parentResourceId)) continue;
|
||||||
const folder = safDirectory.getAll().find((f) => f.id === entry.parentResourceId);
|
|
||||||
if (folder && folder.syncMode === 'auto') {
|
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setPendingCount(count);
|
setPendingCount(count);
|
||||||
setIsSyncingState(getIsSyncing());
|
setIsSyncingState(getIsSyncing());
|
||||||
|
|||||||
@@ -286,6 +286,13 @@ export const fileStore = {
|
|||||||
return rowToRecord(row, getTagsForFile(row.id));
|
return rowToRecord(row, getTagsForFile(row.id));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getByLocalUri(localUri: string): FileRecord | null {
|
||||||
|
const d = getDb();
|
||||||
|
const row = d.select().from(files).where(eq(files.localUri, localUri)).get() as FileRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return rowToRecord(row, getTagsForFile(row.id));
|
||||||
|
},
|
||||||
|
|
||||||
getRootFolders(): FileRecord[] {
|
getRootFolders(): FileRecord[] {
|
||||||
const d = getDb();
|
const d = getDb();
|
||||||
const rows = d.select().from(files)
|
const rows = d.select().from(files)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createMMKV } from 'react-native-mmkv';
|
|||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
||||||
import { ApiError, UploadError } from '../types';
|
import { ApiError, UploadError } from '../types';
|
||||||
|
import { fileStore } from './fileStore';
|
||||||
|
|
||||||
export type UploadFile = { uri: string; type: string; name: string };
|
export type UploadFile = { uri: string; type: string; name: string };
|
||||||
export type UploadResult = { name: string; id: string };
|
export type UploadResult = { name: string; id: string };
|
||||||
@@ -10,6 +11,8 @@ export type UploadResult = { name: string; id: string };
|
|||||||
export const UPLOAD_MAX_RETRIES = 3;
|
export const UPLOAD_MAX_RETRIES = 3;
|
||||||
const BASE_RETRY_DELAY_MS = 1000;
|
const BASE_RETRY_DELAY_MS = 1000;
|
||||||
|
|
||||||
|
export const activeUploadUris = new Set<string>();
|
||||||
|
|
||||||
export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
|
export type UploadTaskStatus = 'pending' | 'uploading' | 'done' | 'error';
|
||||||
|
|
||||||
export type UploadTask = {
|
export type UploadTask = {
|
||||||
@@ -222,6 +225,7 @@ class UploadQueue {
|
|||||||
|
|
||||||
private async runTask(task: UploadTask) {
|
private async runTask(task: UploadTask) {
|
||||||
let willRetry = false;
|
let willRetry = false;
|
||||||
|
activeUploadUris.add(task.file.uri);
|
||||||
try {
|
try {
|
||||||
const fsFile = new File(task.file.uri);
|
const fsFile = new File(task.file.uri);
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
@@ -263,6 +267,7 @@ class UploadQueue {
|
|||||||
task.progress = 100;
|
task.progress = 100;
|
||||||
task.result = item as UploadResult;
|
task.result = item as UploadResult;
|
||||||
task.updatedAt = Date.now();
|
task.updatedAt = Date.now();
|
||||||
|
this.linkResultToStore(task);
|
||||||
this.persist();
|
this.persist();
|
||||||
this.notify();
|
this.notify();
|
||||||
this.scheduleCleanup();
|
this.scheduleCleanup();
|
||||||
@@ -293,6 +298,7 @@ class UploadQueue {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.active--;
|
this.active--;
|
||||||
|
activeUploadUris.delete(task.file.uri);
|
||||||
this.notify();
|
this.notify();
|
||||||
if (!willRetry) {
|
if (!willRetry) {
|
||||||
this.processNext();
|
this.processNext();
|
||||||
@@ -300,6 +306,20 @@ class UploadQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private linkResultToStore(task: UploadTask) {
|
||||||
|
try {
|
||||||
|
const backendId = task.result?.id;
|
||||||
|
if (!backendId) return;
|
||||||
|
const entry = fileStore.getByLocalUri(task.file.uri);
|
||||||
|
if (!entry || entry.backendId) return;
|
||||||
|
fileStore.updatePartial(entry.id, {
|
||||||
|
backendId,
|
||||||
|
syncStatus: 'synced',
|
||||||
|
source: 'synced',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleCleanup() {
|
private scheduleCleanup() {
|
||||||
if (this.cleanupTimer) return;
|
if (this.cleanupTimer) return;
|
||||||
this.cleanupTimer = setTimeout(() => {
|
this.cleanupTimer = setTimeout(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user