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 ( - - {listItem} - + 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 ( + + {listItem} + + ); + } + + 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' {