diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 416a392f..4f147a23 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1047,4 +1047,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 61a76b69206754db38d48117525d4ed24975f765
-COCOAPODS: 1.12.1
+COCOAPODS: 1.14.3
diff --git a/package-lock.json b/package-lock.json
index e62511de..373a6976 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,7 +6,7 @@
"packages": {
"": {
"name": "@polito/students-app",
- "version": "1.6.0",
+ "version": "1.6.2",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.2.1",
diff --git a/src/core/constants.ts b/src/core/constants.ts
index 6dcd8574..0e52db5c 100644
--- a/src/core/constants.ts
+++ b/src/core/constants.ts
@@ -1,8 +1,12 @@
import { Platform } from 'react-native';
+import { DocumentDirectoryPath, ExternalDirectoryPath } from 'react-native-fs';
export const IS_ANDROID = Platform.OS === 'android';
export const IS_IOS = Platform.OS === 'ios';
export const MAX_RECENT_SEARCHES = 10;
+export const PUBLIC_APP_DIRECTORY_PATH = IS_IOS
+ ? DocumentDirectoryPath
+ : ExternalDirectoryPath;
export const courseColors = [
{ name: 'colors.red', color: '#DC2626' },
{ name: 'colors.orange', color: '#EA580C' },
diff --git a/src/core/hooks/useDownload.ts b/src/core/hooks/useDownloadCourseFile.ts
similarity index 74%
rename from src/core/hooks/useDownload.ts
rename to src/core/hooks/useDownloadCourseFile.ts
index 163bee4b..01d47ea3 100644
--- a/src/core/hooks/useDownload.ts
+++ b/src/core/hooks/useDownloadCourseFile.ts
@@ -8,21 +8,32 @@ import {
downloadFile,
stopDownload as fsStopDownload,
mkdir,
+ moveFile,
unlink,
} from 'react-native-fs';
import { dirname } from 'react-native-path';
-import { FilesCacheContext } from '../../features/courses/contexts/FilesCacheContext';
+import { CourseFilesCacheContext } from '../../features/courses/contexts/CourseFilesCacheContext';
import { UnsupportedFileTypeError } from '../../features/courses/errors/UnsupportedFileTypeError';
+import { useCoursesFilesCachePath } from '../../features/courses/hooks/useCourseFilesCachePath';
+import { cleanupEmptyFolders } from '../../utils/files';
import { useApiContext } from '../contexts/ApiContext';
import { Download, useDownloadsContext } from '../contexts/DownloadsContext';
-export const useDownload = (fromUrl: string, toFile: string) => {
+export const useDownloadCourseFile = (
+ fromUrl: string,
+ toFile: string,
+ fileId: string,
+) => {
const { token } = useApiContext();
const { t } = useTranslation();
+ const coursesFilesCachePath = useCoursesFilesCachePath();
const { downloadsRef, setDownloads } = useDownloadsContext();
- const { cache, isRefreshing: isCacheRefreshing } =
- useContext(FilesCacheContext);
+ const {
+ cache,
+ isRefreshing: isCacheRefreshing,
+ refresh,
+ } = useContext(CourseFilesCacheContext);
const key = `${fromUrl}:${toFile}`;
const download = useMemo(
() =>
@@ -30,6 +41,7 @@ export const useDownload = (fromUrl: string, toFile: string) => {
isDownloaded: false,
downloadProgress: undefined,
},
+ // eslint-disable-next-line react-hooks/exhaustive-deps
[downloadsRef.current?.[key], key],
);
@@ -46,19 +58,41 @@ export const useDownload = (fromUrl: string, toFile: string) => {
[key, setDownloads],
);
+ const cachedFilePath = cache[fileId];
+
useEffect(() => {
- if (toFile && !isCacheRefreshing) {
- updateDownload({ isDownloaded: Boolean(cache[toFile]) });
- }
- }, [cache, isCacheRefreshing, toFile, updateDownload]);
+ (async () => {
+ if (toFile && !isCacheRefreshing) {
+ if (cachedFilePath) {
+ if (cachedFilePath === toFile) {
+ updateDownload({ isDownloaded: true });
+ } else {
+ // Update the name when changed
+ await mkdir(dirname(toFile));
+ await moveFile(cachedFilePath, toFile);
+ await cleanupEmptyFolders(coursesFilesCachePath);
+ refresh();
+ }
+ } else {
+ updateDownload({ isDownloaded: false });
+ }
+ }
+ })();
+ }, [
+ cachedFilePath,
+ coursesFilesCachePath,
+ fileId,
+ isCacheRefreshing,
+ refresh,
+ toFile,
+ updateDownload,
+ ]);
const startDownload = useCallback(async () => {
if (!download.isDownloaded && download.downloadProgress == null) {
updateDownload({ downloadProgress: 0 });
try {
- await mkdir(dirname(toFile), {
- NSURLIsExcludedFromBackupKey: true,
- });
+ await mkdir(dirname(toFile));
const { jobId, promise } = downloadFile({
fromUrl,
toFile,
diff --git a/src/core/migrations/1.6.2/migrateCourseFilesCacheToDocumentsDirectory.ts b/src/core/migrations/1.6.2/migrateCourseFilesCacheToDocumentsDirectory.ts
new file mode 100644
index 00000000..fc802121
--- /dev/null
+++ b/src/core/migrations/1.6.2/migrateCourseFilesCacheToDocumentsDirectory.ts
@@ -0,0 +1,52 @@
+import {
+ CachesDirectoryPath,
+ mkdir,
+ moveFile,
+ readDir,
+ unlink,
+} from 'react-native-fs';
+
+import { splitNameAndExtension } from '../../../utils/files';
+import { PUBLIC_APP_DIRECTORY_PATH } from '../../constants';
+import { PreferencesContextProps } from '../../contexts/PreferencesContext';
+
+export const migrateCourseFilesCacheToDocumentsDirectory = async (
+ preferences: PreferencesContextProps,
+) => {
+ const { username } = preferences;
+ if (!username) {
+ return;
+ }
+
+ try {
+ const courseCachesPath = [CachesDirectoryPath, username, 'Courses'].join(
+ '/',
+ );
+ const courseCaches = await readDir(courseCachesPath);
+ for (const courseCache of courseCaches) {
+ if (courseCache.isDirectory()) {
+ const newCourseCachePath = [
+ PUBLIC_APP_DIRECTORY_PATH,
+ username,
+ 'Courses',
+ courseCache.name,
+ ].join('/');
+ await mkdir(newCourseCachePath);
+ const files = await readDir(courseCache.path);
+ for (const courseFile of files) {
+ if (courseFile.isFile()) {
+ const [name, extension] = splitNameAndExtension(courseFile.name);
+ const newPath = [newCourseCachePath, `(${name}).${extension}`].join(
+ '/',
+ );
+ await moveFile(courseFile.path, newPath);
+ }
+ }
+ await unlink(courseCache.path);
+ }
+ }
+ await unlink(courseCachesPath);
+ } catch (_) {
+ // Empty cache, don't transfer
+ }
+};
diff --git a/src/core/migrations/MigrationService.ts b/src/core/migrations/MigrationService.ts
index 7df0329e..fbf636b3 100644
--- a/src/core/migrations/MigrationService.ts
+++ b/src/core/migrations/MigrationService.ts
@@ -6,6 +6,7 @@ import { version as currentVersion } from '../../../package.json';
import { PreferencesContextProps } from '../contexts/PreferencesContext';
import { Migration } from '../types/migrations';
import { storeCoursePreferencesByShortcode } from './1.5.0/storeCoursePreferencesByShortcode';
+import { migrateCourseFilesCacheToDocumentsDirectory } from './1.6.2/migrateCourseFilesCacheToDocumentsDirectory';
import { invalidateCache } from './common';
export class MigrationService {
@@ -14,6 +15,10 @@ export class MigrationService {
runBeforeVersion: '1.5.0',
run: [storeCoursePreferencesByShortcode, invalidateCache],
},
+ {
+ runBeforeVersion: '1.6.2',
+ run: [migrateCourseFilesCacheToDocumentsDirectory],
+ },
];
static needsMigration(preferences: PreferencesContextProps) {
diff --git a/src/core/queries/courseHooks.ts b/src/core/queries/courseHooks.ts
index fe5e1ab2..d6d26c39 100644
--- a/src/core/queries/courseHooks.ts
+++ b/src/core/queries/courseHooks.ts
@@ -20,7 +20,6 @@ import {
useQueryClient,
} from '@tanstack/react-query';
-import { CourseRecentFile } from '../../features/courses/components/CourseRecentFileListItem';
import { CourseLectureSection } from '../../features/courses/types/CourseLectureSections';
import { notNullish } from '../../utils/predicates';
import { pluckData } from '../../utils/queries';
@@ -30,6 +29,10 @@ import {
usePreferencesContext,
} from '../contexts/PreferencesContext';
import { CourseOverview } from '../types/api';
+import {
+ CourseDirectoryContentWithLocations,
+ CourseFileOverviewWithLocation,
+} from '../types/files';
import { useGetExams } from './examHooks';
export const COURSES_QUERY_KEY = ['courses'];
@@ -193,7 +196,8 @@ export const useGetCourseFiles = (courseId: number) => {
() => {
return coursesClient
.getCourseFiles({ courseId: courseId })
- .then(pluckData);
+ .then(pluckData)
+ .then(computeFileLocations);
},
{
staleTime: courseFilesStaleTime,
@@ -233,25 +237,58 @@ export const useGetCourseFilesRecent = (courseId: number) => {
};
};
+const isFile = (
+ item: CourseDirectoryContentInner,
+): item is { type: 'file' } & CourseFileOverview => item.type === 'file';
+
/**
- * Extract a flat array of files contained into the given directory tree
+ * Assigns a location to each file
*/
-const flattenFiles = (
- directoryContent: CourseDirectoryContentInner[] | CourseFileOverview[],
+const computeFileLocations = (
+ directoryContent: CourseDirectoryContentInner[],
location: string = '/',
-): CourseRecentFile[] => {
- const result: CourseRecentFile[] = [];
+): CourseDirectoryContentWithLocations[] => {
+ const result: CourseDirectoryContentWithLocations[] = [];
directoryContent?.forEach(item => {
- if (item.type === 'file') {
+ if (isFile(item)) {
result.push({ ...item, location });
} else {
- result.push(
- ...flattenFiles(
- (item as CourseDirectory).files,
+ result.push({
+ ...item,
+ files: computeFileLocations(
+ item.files,
location.length === 1
? location + item.name
: location + '/' + item.name,
),
+ });
+ }
+ });
+ return result;
+};
+
+/**
+ * Extract a flat array of files contained into the given directory tree
+ */
+const flattenFiles = (
+ directoryContent:
+ | CourseDirectoryContentWithLocations[]
+ | CourseFileOverviewWithLocation[],
+): CourseFileOverviewWithLocation[] => {
+ const result: CourseFileOverviewWithLocation[] = [];
+ directoryContent?.forEach(item => {
+ if (item.type === 'file') {
+ result.push(item);
+ } else {
+ result.push(
+ ...flattenFiles(
+ (
+ item as Extract<
+ CourseDirectoryContentWithLocations,
+ { type: 'directory' }
+ >
+ ).files,
+ ),
);
}
});
@@ -262,8 +299,8 @@ const flattenFiles = (
* Extract files from folders and sort them by decreasing createdAt
*/
const sortRecentFiles = (
- directoryContent: CourseDirectoryContentInner[],
-): CourseRecentFile[] => {
+ directoryContent: CourseDirectoryContentWithLocations[],
+): CourseFileOverviewWithLocation[] => {
const flatFiles = flattenFiles(directoryContent);
return flatFiles.sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
diff --git a/src/core/types/files.ts b/src/core/types/files.ts
new file mode 100644
index 00000000..d095bd4c
--- /dev/null
+++ b/src/core/types/files.ts
@@ -0,0 +1,12 @@
+import { CourseFileOverview } from '@polito/api-client';
+import type { CourseDirectory } from '@polito/api-client/models/CourseDirectory';
+
+export type CourseFileOverviewWithLocation = CourseFileOverview & {
+ location: string;
+};
+
+export type CourseDirectoryContentWithLocations =
+ | ({ type: 'directory' } & CourseDirectory & {
+ files: CourseDirectoryContentWithLocations[];
+ })
+ | ({ type: 'file' } & CourseFileOverviewWithLocation);
diff --git a/src/features/courses/components/CourseFileListItem.tsx b/src/features/courses/components/CourseFileListItem.tsx
index 97ab3186..f3f80a9e 100644
--- a/src/features/courses/components/CourseFileListItem.tsx
+++ b/src/features/courses/components/CourseFileListItem.tsx
@@ -1,7 +1,7 @@
-import { useCallback, useMemo, useState } from 'react';
+import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Platform } from 'react-native';
-import { extension } from 'react-native-mime-types';
+import { extension, lookup } from 'react-native-mime-types';
import {
faCloudArrowDown,
@@ -15,13 +15,12 @@ import { useTheme } from '@lib/ui/hooks/useTheme';
import { BASE_PATH, CourseFileOverview } from '@polito/api-client';
import { MenuView } from '@react-native-menu/menu';
import { MenuComponentProps } from '@react-native-menu/menu/src/types';
-import { useFocusEffect } from '@react-navigation/native';
import { IS_IOS } from '../../../core/constants';
-import { useDownload } from '../../../core/hooks/useDownload';
+import { useDownloadCourseFile } from '../../../core/hooks/useDownloadCourseFile';
import { useNotifications } from '../../../core/hooks/useNotifications';
import { formatDateTime } from '../../../utils/dates';
-import { formatFileSize } from '../../../utils/files';
+import { formatFileSize, splitNameAndExtension } from '../../../utils/files';
import { notNullish } from '../../../utils/predicates';
import { useCourseContext } from '../contexts/CourseContext';
import { UnsupportedFileTypeError } from '../errors/UnsupportedFileTypeError';
@@ -86,184 +85,182 @@ const Menu = ({
);
};
-export const CourseFileListItem = ({
- item,
- showSize = true,
- showLocation = false,
- showCreatedDate = true,
- isInVisibleRange = false,
- ...rest
-}: Props) => {
- const { t } = useTranslation();
- const { colors, fontSizes, spacing } = useTheme();
- const iconProps = useMemo(
- () => ({
- color: colors.secondaryText,
- size: fontSizes.xl,
- }),
- [colors, fontSizes],
- );
- const [notificationClearRequested, setNotificationClearRequested] =
- useState(false);
- const courseId = useCourseContext();
- const courseFilesCache = useCourseFilesCachePath();
- const { getUnreadsCount, clearNotificationScope } = useNotifications();
- const fileNotificationScope = useMemo(
- () => ['teaching', 'courses', courseId.toString(), 'files', item.id],
- [courseId, item.id],
- );
- const fileUrl = `${BASE_PATH}/courses/${courseId}/files/${item.id}`;
- const cachedFilePath = useMemo(() => {
- let ext: string | null = extension(item.mimeType!);
- if (!ext) {
- ext = item.name?.match(/\.(.+)$/)?.[1] ?? null;
- }
- return [courseFilesCache, [item.id, ext].filter(notNullish).join('.')].join(
- '/',
+export const CourseFileListItem = memo(
+ ({
+ item,
+ showSize = true,
+ showLocation = false,
+ showCreatedDate = true,
+ ...rest
+ }: Props) => {
+ const { t } = useTranslation();
+ const { colors, fontSizes, spacing } = useTheme();
+ const iconProps = useMemo(
+ () => ({
+ color: colors.secondaryText,
+ size: fontSizes.xl,
+ }),
+ [colors, fontSizes],
);
- }, [courseFilesCache, item]);
- const {
- isDownloaded,
- downloadProgress,
- startDownload,
- stopDownload,
- refreshDownload,
- removeDownload,
- openFile,
- } = useDownload(fileUrl, cachedFilePath);
-
- useFocusEffect(
- useCallback(() => {
- if (
- !notificationClearRequested &&
- isInVisibleRange &&
- clearNotificationScope &&
- fileNotificationScope
- ) {
- setNotificationClearRequested(true);
- clearNotificationScope(fileNotificationScope);
+ const courseId = useCourseContext();
+ const [courseFilesCache] = useCourseFilesCachePath();
+ const { getUnreadsCount } = useNotifications();
+ const fileNotificationScope = useMemo(
+ () => ['teaching', 'courses', courseId.toString(), 'files', item.id],
+ [courseId, item.id],
+ );
+ const fileUrl = `${BASE_PATH}/courses/${courseId}/files/${item.id}`;
+ const cachedFilePath = useMemo(() => {
+ let ext: string | null = extension(item.mimeType!);
+ const [filename, extensionFromName] = splitNameAndExtension(item.name);
+ if (!ext && extensionFromName && lookup(extensionFromName)) {
+ ext = extensionFromName;
}
- }, [
- notificationClearRequested,
- isInVisibleRange,
- clearNotificationScope,
- fileNotificationScope,
- ]),
- );
-
- const metrics = useMemo(
- () =>
- [
- showCreatedDate && item.createdAt && formatDateTime(item.createdAt),
- showSize &&
- item.sizeInKiloBytes &&
- formatFileSize(item.sizeInKiloBytes),
- showLocation && item.location,
+ return [
+ courseFilesCache,
+ item.location?.substring(1), // Files in the top-level directory have an empty location, hence the `filter(Boolean)` below
+ [filename ? `${filename} (${item.id})` : item.id, ext]
+ .filter(notNullish)
+ .join('.'),
]
- .filter(i => !!i)
- .join(' - '),
- [showCreatedDate, item, showSize, showLocation],
- );
+ .filter(Boolean)
+ .join('/');
+ }, [courseFilesCache, item]);
- const downloadFile = async () => {
- if (downloadProgress == null) {
- if (!isDownloaded) {
- await startDownload();
- }
- openDownloadedFile();
- }
- };
+ const {
+ isDownloaded,
+ downloadProgress,
+ startDownload,
+ stopDownload,
+ refreshDownload,
+ removeDownload,
+ openFile,
+ } = useDownloadCourseFile(fileUrl, cachedFilePath, item.id);
- const trailingItem = useMemo(
- () =>
- !isDownloaded ? (
- downloadProgress == null ? (
-
- ) : (
- {
- stopDownload();
- }}
- {...iconProps}
- hitSlop={{
- left: +spacing[2],
- right: +spacing[2],
- }}
- />
- )
- ) : (
- Platform.select({
- android: (
-
- ),
- })
- ),
- [isDownloaded, downloadProgress],
- );
+ const metrics = useMemo(
+ () =>
+ [
+ showCreatedDate && item.createdAt && formatDateTime(item.createdAt),
+ showSize &&
+ item.sizeInKiloBytes &&
+ formatFileSize(item.sizeInKiloBytes),
+ showLocation && item.location,
+ ]
+ .filter(i => !!i)
+ .join(' - '),
+ [showCreatedDate, item, showSize, showLocation],
+ );
- const openDownloadedFile = () => {
- openFile().catch(e => {
- if (e instanceof UnsupportedFileTypeError) {
- Alert.alert(t('common.error'), t('courseFileListItem.openFileError'));
- }
- });
- };
+ const openDownloadedFile = useCallback(() => {
+ openFile().catch(e => {
+ if (e instanceof UnsupportedFileTypeError) {
+ Alert.alert(t('common.error'), t('courseFileListItem.openFileError'));
+ }
+ });
+ }, [openFile, t]);
- const listItem = (
- {
+ if (downloadProgress == null) {
+ if (!isDownloaded) {
+ await startDownload();
+ }
+ openDownloadedFile();
}
- onPress={downloadFile}
- isDownloaded={isDownloaded}
- downloadProgress={downloadProgress}
- title={`#${item.id} ` + item.name ?? t('common.unnamedFile')}
- subtitle={metrics}
- trailingItem={trailingItem}
- mimeType={item.mimeType}
- unread={!!getUnreadsCount(fileNotificationScope)}
- />
- );
+ }, [downloadProgress, isDownloaded, openDownloadedFile, startDownload]);
- if (IS_IOS) {
- return (
-
+ const trailingItem = useMemo(
+ () =>
+ !isDownloaded ? (
+ downloadProgress == null ? (
+
+ ) : (
+ {
+ stopDownload();
+ }}
+ {...iconProps}
+ hitSlop={{
+ left: +spacing[2],
+ right: +spacing[2],
+ }}
+ />
+ )
+ ) : (
+ Platform.select({
+ android: (
+
+ ),
+ })
+ ),
+ [
+ isDownloaded,
+ downloadProgress,
+ t,
+ downloadFile,
+ iconProps,
+ spacing,
+ refreshDownload,
+ removeDownload,
+ stopDownload,
+ ],
);
- }
- return listItem;
-};
+ const listItem = (
+
+ );
+
+ if (IS_IOS) {
+ return (
+
+ );
+ }
+
+ return listItem;
+ },
+);
diff --git a/src/features/courses/components/CourseRecentFileListItem.tsx b/src/features/courses/components/CourseRecentFileListItem.tsx
index 0efa7a18..144fa1d0 100644
--- a/src/features/courses/components/CourseRecentFileListItem.tsx
+++ b/src/features/courses/components/CourseRecentFileListItem.tsx
@@ -1,14 +1,8 @@
-import { CourseFileOverview } from '@polito/api-client';
-
import {
CourseFileListItem,
Props as CourseFileListItemProps,
} from './CourseFileListItem';
-export type CourseRecentFile = CourseFileOverview & {
- location: string;
-};
-
export const CourseRecentFileListItem = (props: CourseFileListItemProps) => {
return (
diff --git a/src/features/courses/contexts/FilesCacheContext.ts b/src/features/courses/contexts/CourseFilesCacheContext.ts
similarity index 63%
rename from src/features/courses/contexts/FilesCacheContext.ts
rename to src/features/courses/contexts/CourseFilesCacheContext.ts
index 0754abaf..1b54f792 100644
--- a/src/features/courses/contexts/FilesCacheContext.ts
+++ b/src/features/courses/contexts/CourseFilesCacheContext.ts
@@ -1,12 +1,12 @@
import { createContext } from 'react';
export type FilesCacheContextProps = {
- cache: Record;
+ cache: Record;
refresh: () => void;
isRefreshing: boolean;
};
-export const FilesCacheContext = createContext({
+export const CourseFilesCacheContext = createContext({
cache: {},
refresh: () => {},
isRefreshing: false,
diff --git a/src/features/courses/hooks/useCourseFilesCachePath.ts b/src/features/courses/hooks/useCourseFilesCachePath.ts
index 974ad258..eb8f15ce 100644
--- a/src/features/courses/hooks/useCourseFilesCachePath.ts
+++ b/src/features/courses/hooks/useCourseFilesCachePath.ts
@@ -1,18 +1,49 @@
-import { useMemo } from 'react';
-import { CachesDirectoryPath } from 'react-native-fs';
+import { useEffect, useMemo, useState } from 'react';
+import { readDir } from 'react-native-fs';
+import { PUBLIC_APP_DIRECTORY_PATH } from '../../../core/constants';
import { useApiContext } from '../../../core/contexts/ApiContext';
+import { useGetCourse } from '../../../core/queries/courseHooks';
import { useCourseContext } from '../contexts/CourseContext';
export const useCourseFilesCachePath = () => {
- const { username } = useApiContext();
+ const coursesFilesCachePath = useCoursesFilesCachePath();
const courseId = useCourseContext();
+ const { data: course } = useGetCourse(courseId);
+ const cacheFolderName = useMemo(
+ () =>
+ course?.name ? `${course?.name} (${courseId})` : courseId.toString(),
+ [course?.name, courseId],
+ );
+ const principalCachePath = useMemo(() => {
+ return [coursesFilesCachePath, cacheFolderName].join('/');
+ }, [cacheFolderName, coursesFilesCachePath]);
+ const [alternativeCachePaths, setAlternativeCachePaths] = useState(
+ [],
+ );
+
+ useEffect(() => {
+ readDir(coursesFilesCachePath)
+ .then(coursesCaches =>
+ coursesCaches.filter(
+ c =>
+ c.isDirectory() &&
+ c.name !== cacheFolderName &&
+ c.name.includes(courseId.toString()),
+ ),
+ )
+ .then(alternativeCaches => {
+ setAlternativeCachePaths(alternativeCaches.map(i => i.path) ?? []);
+ })
+ .catch(() => {
+ // noop
+ });
+ }, [cacheFolderName, courseId, coursesFilesCachePath]);
- return useMemo(() => {
- return [CachesDirectoryPath, username, 'Courses', courseId].join('/');
- }, [courseId]);
+ return [principalCachePath, alternativeCachePaths] as const;
};
export const useCoursesFilesCachePath = () => {
- return CachesDirectoryPath;
+ const { username } = useApiContext();
+ return [PUBLIC_APP_DIRECTORY_PATH, username, 'Courses'].join('/');
};
diff --git a/src/features/courses/navigation/CourseNavigator.tsx b/src/features/courses/navigation/CourseNavigator.tsx
index 82dcbb43..779a394d 100644
--- a/src/features/courses/navigation/CourseNavigator.tsx
+++ b/src/features/courses/navigation/CourseNavigator.tsx
@@ -17,7 +17,7 @@ import { useGetCourses } from '../../../core/queries/courseHooks';
import { TeachingStackParamList } from '../../teaching/components/TeachingNavigator';
import { CourseIndicator } from '../components/CourseIndicator';
import { CourseContext } from '../contexts/CourseContext';
-import { FilesCacheProvider } from '../providers/FilesCacheProvider';
+import { CourseFilesCacheProvider } from '../providers/CourseFilesCacheProvider';
import { CourseAssignmentsScreen } from '../screens/CourseAssignmentsScreen';
import { CourseFilesScreen } from '../screens/CourseFilesScreen';
import { CourseInfoScreen } from '../screens/CourseInfoScreen';
@@ -116,7 +116,7 @@ export const CourseNavigator = ({ route, navigation }: Props) => {
return (
-
+
}>
{
}}
/>
-
+
);
};
diff --git a/src/features/courses/providers/CourseFilesCacheProvider.tsx b/src/features/courses/providers/CourseFilesCacheProvider.tsx
new file mode 100644
index 00000000..70c1df57
--- /dev/null
+++ b/src/features/courses/providers/CourseFilesCacheProvider.tsx
@@ -0,0 +1,69 @@
+import { PropsWithChildren, useCallback, useEffect, useState } from 'react';
+
+import { readDirRecursively } from '../../../utils/files';
+import { notNullish } from '../../../utils/predicates';
+import {
+ CourseFilesCacheContext,
+ FilesCacheContextProps,
+} from '../contexts/CourseFilesCacheContext';
+import { useCourseFilesCachePath } from '../hooks/useCourseFilesCachePath';
+
+export const CourseFilesCacheProvider = ({ children }: PropsWithChildren) => {
+ const [principalCache, alternativeCaches] = useCourseFilesCachePath();
+
+ const [filesCacheContext, setFilesCacheContext] =
+ useState({
+ cache: {},
+ refresh: () => {},
+ isRefreshing: false,
+ });
+
+ const refresh = useCallback(() => {
+ setFilesCacheContext(oldP => ({ ...oldP, isRefreshing: true }));
+ }, []);
+
+ useEffect(() => {
+ // Initialize the cache
+ refresh();
+ }, [refresh]);
+
+ useEffect(() => {
+ if (filesCacheContext.isRefreshing) {
+ Promise.all(
+ [principalCache, ...alternativeCaches].map(cachePath =>
+ readDirRecursively(cachePath).catch(() => []),
+ ),
+ )
+ .then(caches => caches.flat())
+ .then(cache => {
+ setFilesCacheContext(oldC => ({
+ ...oldC,
+ refresh,
+ cache: Object.fromEntries(
+ cache
+ ?.map(f => {
+ const fileId = f.path.match(/\((\d+)\)(?:\..+)?$/)?.[1];
+ if (!fileId) {
+ return null;
+ }
+ return [fileId, f.path];
+ })
+ .filter(notNullish) ?? [],
+ ),
+ isRefreshing: false,
+ }));
+ });
+ }
+ }, [
+ principalCache,
+ filesCacheContext.isRefreshing,
+ refresh,
+ alternativeCaches,
+ ]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/features/courses/providers/FilesCacheProvider.tsx b/src/features/courses/providers/FilesCacheProvider.tsx
deleted file mode 100644
index 812833dd..00000000
--- a/src/features/courses/providers/FilesCacheProvider.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { PropsWithChildren, useCallback, useEffect, useState } from 'react';
-import { ReadDirItem, readDir } from 'react-native-fs';
-
-import {
- FilesCacheContext,
- FilesCacheContextProps,
-} from '../contexts/FilesCacheContext';
-import { useCourseFilesCachePath } from '../hooks/useCourseFilesCachePath';
-
-export const FilesCacheProvider = ({ children }: PropsWithChildren) => {
- const courseFilesCachePath = useCourseFilesCachePath();
-
- const [filesCacheContext, setFilesCacheContext] =
- useState({
- cache: {},
- refresh: () => {},
- isRefreshing: false,
- });
-
- const refresh = useCallback(() => {
- setFilesCacheContext(oldP => ({ ...oldP, isRefreshing: true }));
- }, []);
-
- useEffect(() => {
- // Initialize the cache
- refresh();
- }, [refresh]);
-
- useEffect(() => {
- if (filesCacheContext.isRefreshing) {
- let cache: ReadDirItem[];
- readDir(courseFilesCachePath)
- .then(c => (cache = c))
- .catch(() => (cache = []))
- .finally(() => {
- setFilesCacheContext(oldC => ({
- ...oldC,
- refresh,
- cache: Object.fromEntries(cache?.map(f => [f.path, true]) ?? []),
- isRefreshing: false,
- }));
- });
- }
- }, [courseFilesCachePath, filesCacheContext.isRefreshing, refresh]);
-
- return (
-
- {children}
-
- );
-};
diff --git a/src/features/courses/screens/CourseDirectoryScreen.tsx b/src/features/courses/screens/CourseDirectoryScreen.tsx
index 70db3ca5..4c63f5ac 100644
--- a/src/features/courses/screens/CourseDirectoryScreen.tsx
+++ b/src/features/courses/screens/CourseDirectoryScreen.tsx
@@ -13,27 +13,24 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BottomBarSpacer } from '../../../core/components/BottomBarSpacer';
import { useSafeAreaSpacing } from '../../../core/hooks/useSafeAreaSpacing';
-import { useVisibleFlatListItems } from '../../../core/hooks/useVisibleFlatListItems';
import {
useGetCourseDirectory,
useGetCourseFilesRecent,
} from '../../../core/queries/courseHooks';
+import { CourseFileOverviewWithLocation } from '../../../core/types/files';
import { TeachingStackParamList } from '../../teaching/components/TeachingNavigator';
import { CourseDirectoryListItem } from '../components/CourseDirectoryListItem';
import { CourseFileListItem } from '../components/CourseFileListItem';
-import {
- CourseRecentFile,
- CourseRecentFileListItem,
-} from '../components/CourseRecentFileListItem';
+import { CourseRecentFileListItem } from '../components/CourseRecentFileListItem';
import { CourseContext } from '../contexts/CourseContext';
-import { FilesCacheContext } from '../contexts/FilesCacheContext';
-import { FilesCacheProvider } from '../providers/FilesCacheProvider';
+import { CourseFilesCacheContext } from '../contexts/CourseFilesCacheContext';
+import { CourseFilesCacheProvider } from '../providers/CourseFilesCacheProvider';
import { isDirectory } from '../utils/fs-entry';
type Props = NativeStackScreenProps;
const FileCacheChecker = () => {
- const { refresh } = useContext(FilesCacheContext);
+ const { refresh } = useContext(CourseFilesCacheContext);
useFocusEffect(
useCallback(() => {
@@ -64,7 +61,7 @@ export const CourseDirectoryScreen = ({ route, navigation }: Props) => {
return (
-
+
{searchFilter ? (
{
ListFooterComponent={}
/>
)}
-
+
);
};
@@ -112,12 +109,12 @@ interface SearchProps {
const CourseFileSearchFlatList = ({ courseId, searchFilter }: SearchProps) => {
const styles = useStylesheet(createStyles);
const { t } = useTranslation();
- const [searchResults, setSearchResults] = useState([]);
+ const [searchResults, setSearchResults] = useState<
+ CourseFileOverviewWithLocation[]
+ >([]);
const recentFilesQuery = useGetCourseFilesRecent(courseId);
const [scrollEnabled, setScrollEnabled] = useState(true);
const { paddingHorizontal } = useSafeAreaSpacing();
- const { visibleItemsIndexes, ...visibleItemsFlatListProps } =
- useVisibleFlatListItems();
useEffect(() => {
if (!recentFilesQuery.data) return;
@@ -126,19 +123,24 @@ const CourseFileSearchFlatList = ({ courseId, searchFilter }: SearchProps) => {
);
}, [recentFilesQuery.data, searchFilter]);
+ const onSwipeStart = useCallback(() => setScrollEnabled(false), []);
+ const onSwipeEnd = useCallback(() => setScrollEnabled(true), []);
+
return (
item.id}
- renderItem={({ item, index }) => (
+ keyExtractor={(item: CourseFileOverviewWithLocation) => item.id}
+ renderItem={({ item }) => (
setScrollEnabled(false)}
- onSwipeEnd={() => setScrollEnabled(true)}
- isInVisibleRange={!!visibleItemsIndexes[index]}
+ onSwipeStart={onSwipeStart}
+ onSwipeEnd={onSwipeEnd}
/>
)}
refreshControl={}
@@ -150,7 +152,6 @@ const CourseFileSearchFlatList = ({ courseId, searchFilter }: SearchProps) => {
{t('courseDirectoryScreen.noResult')}
}
- {...visibleItemsFlatListProps}
/>
);
};
diff --git a/src/features/courses/screens/CourseFilesScreen.tsx b/src/features/courses/screens/CourseFilesScreen.tsx
index 574d126d..aa802151 100644
--- a/src/features/courses/screens/CourseFilesScreen.tsx
+++ b/src/features/courses/screens/CourseFilesScreen.tsx
@@ -15,11 +15,10 @@ import { BottomBarSpacer } from '../../../core/components/BottomBarSpacer';
import { useNotifications } from '../../../core/hooks/useNotifications';
import { useOnLeaveScreen } from '../../../core/hooks/useOnLeaveScreen';
import { useSafeAreaSpacing } from '../../../core/hooks/useSafeAreaSpacing';
-import { useVisibleFlatListItems } from '../../../core/hooks/useVisibleFlatListItems';
import { useGetCourseFilesRecent } from '../../../core/queries/courseHooks';
import { CourseRecentFileListItem } from '../components/CourseRecentFileListItem';
import { useCourseContext } from '../contexts/CourseContext';
-import { FilesCacheContext } from '../contexts/FilesCacheContext';
+import { CourseFilesCacheContext } from '../contexts/CourseFilesCacheContext';
import { CourseTabsParamList } from '../navigation/CourseNavigator';
type Props = MaterialTopTabScreenProps<
@@ -30,12 +29,10 @@ type Props = MaterialTopTabScreenProps<
export const CourseFilesScreen = ({ navigation }: Props) => {
const { t } = useTranslation();
const [scrollEnabled, setScrollEnabled] = useState(true);
- const { refresh } = useContext(FilesCacheContext);
+ const { refresh } = useContext(CourseFilesCacheContext);
const courseId = useCourseContext();
const recentFilesQuery = useGetCourseFilesRecent(courseId);
const { paddingHorizontal } = useSafeAreaSpacing();
- const { visibleItemsIndexes, ...visibleItemsFlatListProps } =
- useVisibleFlatListItems();
const { clearNotificationScope } = useNotifications();
useOnLeaveScreen(() => {
@@ -48,6 +45,9 @@ export const CourseFilesScreen = ({ navigation }: Props) => {
}, [refresh]),
);
+ const onSwipeStart = useCallback(() => setScrollEnabled(false), []);
+ const onSwipeEnd = useCallback(() => setScrollEnabled(true), []);
+
return (
<>
{
scrollEnabled={scrollEnabled}
keyExtractor={(item: CourseDirectory | CourseFileOverview) => item.id}
initialNumToRender={15}
- renderItem={({ item, index }) => {
+ maxToRenderPerBatch={15}
+ windowSize={4}
+ renderItem={({ item }) => {
return (
setScrollEnabled(false)}
- onSwipeEnd={() => setScrollEnabled(true)}
- isInVisibleRange={!!visibleItemsIndexes[index]}
+ onSwipeStart={onSwipeStart}
+ onSwipeEnd={onSwipeEnd}
/>
);
}}
@@ -85,7 +86,6 @@ export const CourseFilesScreen = ({ navigation }: Props) => {
/>
) : null
}
- {...visibleItemsFlatListProps}
/>
{recentFilesQuery.data && recentFilesQuery.data.length > 0 && (
{
const { setFeedback } = useFeedbackContext();
const { fontSizes } = useTheme();
- const courseFilesCache = useCourseFilesCachePath();
+ const [courseFilesCache] = useCourseFilesCachePath();
const [cacheSize, setCacheSize] = useState(0);
const confirm = useConfirmationDialog({
title: t('common.areYouSure?'),
diff --git a/src/utils/files.ts b/src/utils/files.ts
index 742c1ea0..f8013d89 100644
--- a/src/utils/files.ts
+++ b/src/utils/files.ts
@@ -1,3 +1,5 @@
+import { ReadDirItem, readDir, unlink } from 'react-native-fs';
+
export const formatFileSize = (
sizeInKiloBytes: number,
fractionDigit: number = 2,
@@ -10,3 +12,43 @@ export const formatFileSize = (
}
return `${Math.round(sizeInKiloBytes / 1000000)} GB`;
};
+
+export const splitNameAndExtension = (filePath?: string) => {
+ const [_, name, extension] = filePath?.match(/(.+)\.(.+)$/) ?? [];
+ return [name, extension] as [string | null, string | null];
+};
+
+/**
+ * Returns a flattened list of files in the subtree of rootPath
+ */
+export const readDirRecursively = async (rootPath: string) => {
+ const files: ReadDirItem[] = [];
+ const visitNode = async (path: string) => {
+ for (const item of await readDir(path)) {
+ if (item.isFile()) {
+ files.push(item);
+ } else {
+ await visitNode(item.path);
+ }
+ }
+ };
+ await visitNode(rootPath);
+ return files;
+};
+
+/**
+ * Cleans up folders that don't contain at least one file in their subtree
+ */
+export const cleanupEmptyFolders = async (rootPath: string) => {
+ const deleteIfEmpty = async (folderPath: string, skip = false) => {
+ for (const item of await readDir(folderPath)) {
+ if (item.isDirectory()) {
+ await deleteIfEmpty(item.path);
+ }
+ }
+ if (!skip && !(await readDir(folderPath)).length) {
+ await unlink(folderPath);
+ }
+ };
+ await deleteIfEmpty(rootPath, true);
+};
diff --git a/src/utils/predicates.ts b/src/utils/predicates.ts
index 5a1c63a4..9c06b687 100644
--- a/src/utils/predicates.ts
+++ b/src/utils/predicates.ts
@@ -1,4 +1,4 @@
-export const notNullish = (i: unknown) => i != null;
+export const notNullish = (i: T): i is NonNullable => i != null;
export const notUndefined = (i: unknown) => i !== undefined;
export const negate = (val: unknown) => !val;
diff --git a/types.d.ts b/types.d.ts
index 24b4a898..5937f22c 100644
--- a/types.d.ts
+++ b/types.d.ts
@@ -1,5 +1,6 @@
declare module 'react-native-mime-types' {
function extension(type: string): string;
+ function lookup(path: string): string | false;
}
declare module 'react-native-path' {