Skip to content

Commit 5efc705

Browse files
authored
Merge pull request #314 from internxt/feature/PB-4866-regenerate-thumbnails-on-preview
[PB-4866] feature/Implement thumbnail regeneration
2 parents b23706f + e2bc79b commit 5efc705

7 files changed

Lines changed: 297 additions & 8 deletions

File tree

src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ function DriveGridModeItemComp(props: DriveItemProps): JSX.Element {
4242
useEffect(() => {
4343
if (props.data.thumbnails && props.data.thumbnails.length && !downloadedThumbnail) {
4444
InteractionManager.runAfterInteractions(() => {
45+
// TODO: NEED TO UPDATE SDK TYPES
46+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
47+
// @ts-ignore
4548
driveFileService.getThumbnail(props.data.thumbnails[0]).then((downloadedThumbnail) => {
4649
setDownloadedThumbnail(downloadedThumbnail);
4750
});

src/contexts/Drive/Drive.context.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import errorService from '@internxt-mobile/services/ErrorService';
88
import { AppStateStatus, NativeEventSubscription } from 'react-native';
99

1010
import { driveFolderService } from '@internxt-mobile/services/drive/folder';
11+
import { Thumbnail } from '@internxt/sdk/dist/drive/storage/types';
1112

1213
export type DriveFoldersTreeNode = {
1314
name: string;
@@ -32,7 +33,11 @@ export interface DriveContextType {
3233
toggleViewMode: () => void;
3334
loadFolderContent: (folderUuid: string, options?: LoadFolderContentOptions) => Promise<void>;
3435
focusedFolder: DriveFoldersTreeNode | null;
35-
updateItemInTree: (folderId: string, itemId: number, updates: { name?: string; plainName?: string }) => void;
36+
updateItemInTree: (
37+
folderId: string,
38+
itemId: number,
39+
updates: { name?: string; plainName?: string; thumbnails?: Thumbnail[] },
40+
) => void;
3641
removeItemFromTree: (folderId: string, itemId: number) => void;
3742
addItemToTree: (folderId: string, item: DriveItemData, isFolder: boolean) => void;
3843
}
@@ -324,7 +329,11 @@ export const DriveContextProvider: React.FC<DriveContextProviderProps> = ({ chil
324329
});
325330
};
326331

327-
const updateItemInTree = (folderId: string, itemId: number, updates: { name?: string; plainName?: string }) => {
332+
const updateItemInTree = (
333+
folderId: string,
334+
itemId: number,
335+
updates: { name?: string; plainName?: string; thumbnails?: Thumbnail[] },
336+
) => {
328337
setDriveFoldersTree((prevTree) => {
329338
const folder = prevTree[folderId];
330339
if (!folder) return prevTree;

src/screens/drive/DrivePreviewScreen/DrivePreviewScreen.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { fs } from '@internxt-mobile/services/FileSystemService';
55
import { notifications } from '@internxt-mobile/services/NotificationsService';
66
import { FileExtension } from '@internxt-mobile/types/drive';
77
import { RootStackScreenProps } from '@internxt-mobile/types/navigation';
8+
import { Thumbnail } from '@internxt/sdk/dist/drive/storage/types';
89
import strings from 'assets/lang/strings';
910
import { WarningCircle } from 'phosphor-react-native';
1011
import React, { useEffect, useRef, useState } from 'react';
@@ -16,6 +17,7 @@ import AppScreen from 'src/components/AppScreen';
1617
import AppText from 'src/components/AppText';
1718
import { DEFAULT_EASING } from 'src/components/modals/SharedLinkSettingsModal/animations';
1819
import { getFileTypeIcon } from 'src/helpers';
20+
import { useDrive } from 'src/hooks/drive';
1921
import { useAppDispatch, useAppSelector } from 'src/store/hooks';
2022
import { uiActions } from 'src/store/slices/ui';
2123
import { getLineHeight } from 'src/styles/global';
@@ -25,6 +27,7 @@ import { DriveImagePreview } from './DriveImagePreview';
2527
import { DrivePdfPreview } from './DrivePdfPreview';
2628
import { DRIVE_PREVIEW_HEADER_HEIGHT, DrivePreviewScreenHeader } from './DrivePreviewScreenHeader';
2729
import { DriveVideoPreview } from './DriveVideoPreview';
30+
import { useThumbnailRegeneration } from './hooks/useThumbnailRegeneration';
2831
import AnimatedLoadingDots from './LoadingDots';
2932

3033
const IMAGE_PREVIEW_TYPES = [FileExtension.PNG, FileExtension.JPG, FileExtension.JPEG, FileExtension.HEIC];
@@ -40,6 +43,7 @@ export const DrivePreviewScreen: React.FC<RootStackScreenProps<'DrivePreview'>>
4043
// REDUX USAGE STARTS
4144
const insets = useSafeAreaInsets();
4245
const dispatch = useAppDispatch();
46+
const driveCtx = useDrive();
4347
// REDUX USAGE ENDS
4448
const { downloadingFile } = useAppSelector((state) => state.drive);
4549
// Use this in order to listen for state changes
@@ -63,6 +67,32 @@ export const DrivePreviewScreen: React.FC<RootStackScreenProps<'DrivePreview'>>
6367
}
6468
}, [downloadingFile?.downloadedFilePath]);
6569

70+
const updateItemWithNewThumbnail = (thumbnail: Thumbnail) => {
71+
if (!focusedItem?.folderUuid) return;
72+
73+
driveCtx.updateItemInTree(focusedItem.folderUuid, focusedItem.id, {
74+
thumbnails: [thumbnail],
75+
});
76+
dispatch(
77+
driveActions.setFocusedItem({
78+
...focusedItem,
79+
thumbnails: [thumbnail],
80+
}),
81+
);
82+
};
83+
84+
useThumbnailRegeneration(
85+
{
86+
downloadedFilePath: downloadingFile?.downloadedFilePath,
87+
fileExtension: focusedItem?.type,
88+
fileUuid: focusedItem?.uuid,
89+
hasThumbnails: !!(focusedItem?.thumbnails && focusedItem.thumbnails.length > 0),
90+
},
91+
{
92+
onSuccess: updateItemWithNewThumbnail,
93+
},
94+
);
95+
6696
useEffect(() => {
6797
Animated.timing(topbarYPosition, {
6898
toValue: topbarVisible ? 0 : -totalHeaderHeight,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { canGenerateThumbnail, shouldRegenerateThumbnail } from './useThumbnailRegeneration';
2+
3+
describe('useThumbnailRegeneration', () => {
4+
describe('canGenerateThumbnail', () => {
5+
it('should return true for image types', () => {
6+
expect(canGenerateThumbnail('png')).toBe(true);
7+
expect(canGenerateThumbnail('PNG')).toBe(true);
8+
expect(canGenerateThumbnail('jpg')).toBe(true);
9+
expect(canGenerateThumbnail('jpeg')).toBe(true);
10+
expect(canGenerateThumbnail('HEIC')).toBe(true);
11+
});
12+
13+
it('should return true for video types', () => {
14+
expect(canGenerateThumbnail('mp4')).toBe(true);
15+
expect(canGenerateThumbnail('MP4')).toBe(true);
16+
expect(canGenerateThumbnail('mov')).toBe(true);
17+
expect(canGenerateThumbnail('avi')).toBe(true);
18+
});
19+
20+
it('should return true for PDF', () => {
21+
expect(canGenerateThumbnail('pdf')).toBe(true);
22+
expect(canGenerateThumbnail('PDF')).toBe(true);
23+
});
24+
25+
it('should return false for unsupported types', () => {
26+
expect(canGenerateThumbnail('txt')).toBe(false);
27+
expect(canGenerateThumbnail('docx')).toBe(false);
28+
expect(canGenerateThumbnail('zip')).toBe(false);
29+
});
30+
});
31+
32+
describe('shouldRegenerateThumbnail', () => {
33+
it('should return true when file is downloaded, has no thumbnails, and is supported type', () => {
34+
const result = shouldRegenerateThumbnail({
35+
downloadedFilePath: '/path/to/file.jpg',
36+
fileExtension: 'jpg',
37+
fileUuid: 'uuid-123',
38+
hasThumbnails: false,
39+
});
40+
expect(result).toBe(true);
41+
});
42+
43+
it('should return false when file already has thumbnails', () => {
44+
const result = shouldRegenerateThumbnail({
45+
downloadedFilePath: '/path/to/file.jpg',
46+
fileExtension: 'jpg',
47+
fileUuid: 'uuid-123',
48+
hasThumbnails: true,
49+
});
50+
expect(result).toBe(false);
51+
});
52+
53+
it('should return false when file is not downloaded', () => {
54+
const result = shouldRegenerateThumbnail({
55+
downloadedFilePath: undefined,
56+
fileExtension: 'jpg',
57+
fileUuid: 'uuid-123',
58+
hasThumbnails: false,
59+
});
60+
expect(result).toBe(false);
61+
});
62+
63+
it('should return false when file type is unsupported', () => {
64+
const result = shouldRegenerateThumbnail({
65+
downloadedFilePath: '/path/to/file.txt',
66+
fileExtension: 'txt',
67+
fileUuid: 'uuid-123',
68+
hasThumbnails: false,
69+
});
70+
expect(result).toBe(false);
71+
});
72+
73+
it('should return false when file extension is missing', () => {
74+
const result = shouldRegenerateThumbnail({
75+
downloadedFilePath: '/path/to/file',
76+
fileExtension: undefined,
77+
fileUuid: 'uuid-123',
78+
hasThumbnails: false,
79+
});
80+
expect(result).toBe(false);
81+
});
82+
});
83+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { logger } from '@internxt-mobile/services/common';
2+
import { driveFileService } from '@internxt-mobile/services/drive/file';
3+
import errorService from '@internxt-mobile/services/ErrorService';
4+
import { FileExtension } from '@internxt-mobile/types/drive';
5+
import { Thumbnail } from '@internxt/sdk/dist/drive/storage/types';
6+
import { useEffect, useRef, useState } from 'react';
7+
8+
const IMAGE_PREVIEW_TYPES = new Set([FileExtension.PNG, FileExtension.JPG, FileExtension.JPEG, FileExtension.HEIC]);
9+
const VIDEO_PREVIEW_TYPES = new Set([FileExtension.MP4, FileExtension.MOV, FileExtension.AVI]);
10+
const PDF_PREVIEW_TYPES = new Set([FileExtension.PDF]);
11+
12+
interface ThumbnailRegenerationParams {
13+
downloadedFilePath?: string;
14+
fileExtension?: string;
15+
fileUuid?: string;
16+
hasThumbnails: boolean;
17+
}
18+
19+
interface ThumbnailRegenerationCallbacks {
20+
onSuccess: (thumbnail: Thumbnail) => void;
21+
}
22+
23+
export const canGenerateThumbnail = (fileExtension: string): boolean => {
24+
const extension = fileExtension.toLowerCase() as FileExtension;
25+
return IMAGE_PREVIEW_TYPES.has(extension) || VIDEO_PREVIEW_TYPES.has(extension) || PDF_PREVIEW_TYPES.has(extension);
26+
};
27+
28+
export const shouldRegenerateThumbnail = (params: ThumbnailRegenerationParams): boolean => {
29+
const { downloadedFilePath, fileExtension, hasThumbnails } = params;
30+
31+
if (!downloadedFilePath || hasThumbnails || !fileExtension) {
32+
return false;
33+
}
34+
35+
return canGenerateThumbnail(fileExtension);
36+
};
37+
38+
export const regenerateThumbnail = async (
39+
fileUuid: string,
40+
downloadedFilePath: string,
41+
fileExtension: string,
42+
): Promise<Thumbnail | null> => {
43+
try {
44+
const thumbnail = await driveFileService.regenerateThumbnail(
45+
fileUuid,
46+
downloadedFilePath,
47+
fileExtension.toLowerCase(),
48+
);
49+
logger.info('Thumbnail regenerated successfully', { thumbnail });
50+
return thumbnail;
51+
} catch (error) {
52+
logger.info('Thumbnail regeneration error', { error });
53+
errorService.reportError(error);
54+
return null;
55+
}
56+
};
57+
58+
export const useThumbnailRegeneration = (
59+
params: ThumbnailRegenerationParams,
60+
callbacks: ThumbnailRegenerationCallbacks,
61+
) => {
62+
const [isRegenerating, setIsRegenerating] = useState(false);
63+
const regenerationAttempted = useRef(false);
64+
65+
useEffect(() => {
66+
const { downloadedFilePath, fileExtension, fileUuid } = params;
67+
68+
if (regenerationAttempted.current || isRegenerating) {
69+
return;
70+
}
71+
72+
if (!shouldRegenerateThumbnail(params)) {
73+
return;
74+
}
75+
76+
if (!fileUuid || !downloadedFilePath || !fileExtension) {
77+
return;
78+
}
79+
80+
regenerationAttempted.current = true;
81+
setIsRegenerating(true);
82+
83+
regenerateThumbnail(fileUuid, downloadedFilePath, fileExtension)
84+
.then((thumbnail) => {
85+
if (thumbnail) {
86+
callbacks.onSuccess(thumbnail);
87+
}
88+
})
89+
.finally(() => {
90+
setIsRegenerating(false);
91+
});
92+
}, [params.downloadedFilePath, params.hasThumbnails]);
93+
94+
return { isRegenerating, regenerationAttempted: regenerationAttempted.current };
95+
};

0 commit comments

Comments
 (0)