diff --git a/src/app/drive/services/downloadManager.service.test.ts b/src/app/drive/services/downloadManager.service.test.ts index 6124f7bc9f..3da1b2a87f 100644 --- a/src/app/drive/services/downloadManager.service.test.ts +++ b/src/app/drive/services/downloadManager.service.test.ts @@ -879,6 +879,7 @@ describe('downloadManagerService', () => { updateProgressCallback: mockUpdateProgress, abortController: mockTask.abortController, sharingOptions: mockTask.credentials, + downloadName: mockTask.options.downloadName, }); }); }); diff --git a/src/app/drive/services/downloadManager.service.ts b/src/app/drive/services/downloadManager.service.ts index ce511cddb6..7c8af898ce 100644 --- a/src/app/drive/services/downloadManager.service.ts +++ b/src/app/drive/services/downloadManager.service.ts @@ -45,6 +45,7 @@ export type DownloadItem = { downloadOptions?: { areSharedItems?: boolean; showErrors?: boolean; + downloadName?: string; }; createFoldersIterator?: FolderIterator | SharedFolderIterator; createFilesIterator?: FileIterator | SharedFileIterator; @@ -90,6 +91,13 @@ export type DownloadTask = { export class DownloadManagerService { public static readonly instance: DownloadManagerService = new DownloadManagerService(); + private getSingleItemName(item: DownloadItem['payload'][0]): string { + if (item.isFolder) { + return item.name; + } + return item.type ? `${item.name}.${item.type}` : item.name; + } + readonly getDownloadCredentialsFromWorkspace = ( selectedWorkspace: WorkspaceData | null, workspaceCredentials: WorkspaceCredentialsDetails | null, @@ -117,14 +125,11 @@ export class DownloadManagerService { const abort = () => Promise.resolve(uploadFolderAbortController.abort('Download cancelled')); const formattedDate = date.format(new Date(), 'YYYY-MM-DD_HHmmss'); - let downloadName = `Internxt (${formattedDate})`; - if (itemsPayload.length === 1) { - const item = itemsPayload[0]; - if (itemsPayload[0].isFolder) { - downloadName = item.name; - } else { - downloadName = item.type ? `${item.name}.${item.type}` : item.name; - } + let downloadName = downloadItem.downloadOptions?.downloadName; + + if (!downloadName) { + downloadName = + itemsPayload.length === 1 ? this.getSingleItemName(itemsPayload[0]) : `Internxt (${formattedDate})`; } let taskId = downloadItem.taskId; @@ -288,6 +293,7 @@ export class DownloadManagerService { updateProgressCallback, abortController, sharingOptions: credentials, + downloadName: options.downloadName, }); console.timeEnd(`download-file-${file.uuid}`); @@ -493,6 +499,7 @@ export class DownloadManagerService { updateProgressCallback: (progress: number) => void; abortController?: AbortController; sharingOptions: { credentials: { user: string; pass: string }; mnemonic: string }; + downloadName?: string; }) => { const isBrave = !!(navigator.brave && (await navigator.brave.isBrave())); @@ -514,6 +521,7 @@ export class DownloadManagerService { itemData: payload.file, updateProgressCallback: payload.updateProgressCallback, abortController: payload.abortController, + downloadName: payload.downloadName, }); }; } diff --git a/src/app/drive/services/worker.service/downloadWorkerHandler.ts b/src/app/drive/services/worker.service/downloadWorkerHandler.ts index 4db85a9619..300a3cde02 100644 --- a/src/app/drive/services/worker.service/downloadWorkerHandler.ts +++ b/src/app/drive/services/worker.service/downloadWorkerHandler.ts @@ -11,6 +11,7 @@ interface HandleWorkerMessagesPayload { abortController?: AbortController; itemData: DriveFileData; updateProgressCallback: (progress: number) => void; + downloadName?: string; } interface HandleMessagesPayload { @@ -37,9 +38,10 @@ export class DownloadWorkerHandler { abortController, itemData, updateProgressCallback, + downloadName, }: HandleWorkerMessagesPayload) { const fileName = itemData.plainName ?? itemData.name; - const completeFilename = itemData.type ? `${fileName}.${itemData.type}` : fileName; + const completeFilename = downloadName || (itemData.type ? `${fileName}.${itemData.type}` : fileName); const downloadId = itemData.fileId; const fileSize = itemData.size; diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index cb0f7c43d2..a23ffff15f 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -808,7 +808,13 @@ "restoreSuccess": "Version erfolgreich wiederhergestellt", "restoreError": "Fehler beim Wiederherstellen der Version", "deleteSuccess": "Version erfolgreich gelöscht", - "deleteError": "Fehler beim Löschen der Version" + "deleteError": "Fehler beim Löschen der Version", + "lockedFeature": { + "title": "Gesperrte Funktion", + "description": "Stellen Sie frühere Versionen Ihrer Dateien wieder her und verfolgen Sie Änderungen im Laufe der Zeit.", + "supportedFormats": "Verfügbar für PDF-, Word-, Excel- und CSV-Dateien in kostenpflichtigen Plänen.", + "upgradeButton": "Upgrade" + } }, "shareModal": { "title": "Aktie \"{{name}}\"", diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 3f61f38ddb..4c07b2c703 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -905,7 +905,13 @@ "restoreSuccess": "Version restored successfully", "restoreError": "Failed to restore version", "deleteSuccess": "Version deleted successfully", - "deleteError": "Failed to delete version" + "deleteError": "Failed to delete version", + "lockedFeature": { + "title": "Locked feature", + "description": "Restore previous versions of your files and track changes over time.", + "supportedFormats": "Available for PDF, Word, Excel, and CSV files in paid plans.", + "upgradeButton": "Upgrade" + } }, "shareModal": { "title": "Share \"{{name}}\"", diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 18798f93ef..f73d92d1b1 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -887,7 +887,13 @@ "restoreSuccess": "Versión restaurada exitosamente", "restoreError": "Error al restaurar la versión", "deleteSuccess": "Versión eliminada exitosamente", - "deleteError": "Error al eliminar la versión" + "deleteError": "Error al eliminar la versión", + "lockedFeature": { + "title": "Función bloqueada", + "description": "Restaura versiones anteriores de tus archivos y realiza un seguimiento de los cambios a lo largo del tiempo.", + "supportedFormats": "Disponible para archivos PDF, Word, Excel y CSV en planes de pago.", + "upgradeButton": "Mejorar plan" + } }, "shareModal": { "title": "Compartir \"{{name}}\"", diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 7fd403e323..e8ca3585e2 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -829,7 +829,13 @@ "restoreSuccess": "Version restaurée avec succès", "restoreError": "Échec de la restauration de la version", "deleteSuccess": "Version supprimée avec succès", - "deleteError": "Échec de la suppression de la version" + "deleteError": "Échec de la suppression de la version", + "lockedFeature": { + "title": "Fonctionnalité verrouillée", + "description": "Restaurez les versions précédentes de vos fichiers et suivez les modifications au fil du temps.", + "supportedFormats": "Disponible pour les fichiers PDF, Word, Excel et CSV dans les forfaits payants.", + "upgradeButton": "Mettre à niveau" + } }, "newFolderModal": { "title": "Nouveau dossier", diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 916622c362..0a0d6f7929 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -941,7 +941,13 @@ "restoreSuccess": "Versione ripristinata con successo", "restoreError": "Impossibile ripristinare la versione", "deleteSuccess": "Versione eliminata con successo", - "deleteError": "Impossibile eliminare la versione" + "deleteError": "Impossibile eliminare la versione", + "lockedFeature": { + "title": "Funzione bloccata", + "description": "Ripristina le versioni precedenti dei tuoi file e monitora le modifiche nel tempo.", + "supportedFormats": "Disponibile per file PDF, Word, Excel e CSV nei piani a pagamento.", + "upgradeButton": "Aggiorna" + } }, "shareModal": { "title": "Condividi \"{{name}}\"", diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index e3d644be9d..4b719f2a87 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -848,7 +848,13 @@ "restoreSuccess": "Версия успешно восстановлена", "restoreError": "Не удалось восстановить версию", "deleteSuccess": "Версия успешно удалена", - "deleteError": "Не удалось удалить версию" + "deleteError": "Не удалось удалить версию", + "lockedFeature": { + "title": "Заблокированная функция", + "description": "Восстанавливайте предыдущие версии файлов и отслеживайте изменения со временем.", + "supportedFormats": "Доступно для файлов PDF, Word, Excel и CSV в платных тарифах.", + "upgradeButton": "Обновить" + } }, "shareModal": { "title": "Поделиться \"{{name}}\"", diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index 216d1e3050..4fbc275c3f 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -835,7 +835,13 @@ "restoreSuccess": "版本復原成功", "restoreError": "復原版本失敗", "deleteSuccess": "版本刪除成功", - "deleteError": "刪除版本失敗" + "deleteError": "刪除版本失敗", + "lockedFeature": { + "title": "功能已鎖定", + "description": "復原檔案的先前版本並追蹤隨時間的變化。", + "supportedFormats": "適用於付費方案中的PDF、Word、Excel和CSV檔案。", + "upgradeButton": "升級" + } }, "shareModal": { "title": "分享“{{name}}”", diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index 9c448049ba..8e2c0fe09a 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -871,7 +871,13 @@ "restoreSuccess": "版本恢复成功", "restoreError": "恢复版本失败", "deleteSuccess": "版本删除成功", - "deleteError": "删除版本失败" + "deleteError": "删除版本失败", + "lockedFeature": { + "title": "功能已锁定", + "description": "恢复文件的以前版本并跟踪随时间的变化。", + "supportedFormats": "适用于付费计划中的PDF、Word、Excel和CSV文件。", + "upgradeButton": "升级" + } }, "shareModal": { "title": "分享 \"{{name}}\"", diff --git a/src/app/store/slices/fileVersions/fileVersions.selectors.ts b/src/app/store/slices/fileVersions/fileVersions.selectors.ts index 03c545460f..55b0d24b2e 100644 --- a/src/app/store/slices/fileVersions/fileVersions.selectors.ts +++ b/src/app/store/slices/fileVersions/fileVersions.selectors.ts @@ -5,6 +5,9 @@ const fileVersionsSelectors = { getLimits(state: RootState): FileLimitsResponse | null { return state.fileVersions.limits; }, + isLimitsLoading(state: RootState): boolean { + return state.fileVersions.isLimitsLoading; + }, getVersionsByFileId(state: RootState, fileId: NonNullable): FileVersion[] { return state.fileVersions.versionsByFileId[fileId] ?? []; }, diff --git a/src/app/store/slices/fileVersions/index.test.ts b/src/app/store/slices/fileVersions/index.test.ts index 6ed3ea378f..c935495416 100644 --- a/src/app/store/slices/fileVersions/index.test.ts +++ b/src/app/store/slices/fileVersions/index.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; -import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; +import fileVersionService from 'views/Drive/services/fileVersion.service'; import { fileVersionsActions, fileVersionsReducer, fetchFileVersionsThunk, fetchVersionLimitsThunk } from './index'; import { RootState } from '../..'; -vi.mock('views/Drive/components/VersionHistory/services/fileVersion.service', () => ({ +vi.mock('views/Drive/services/fileVersion.service', () => ({ default: { getFileVersions: vi.fn(), getLimits: vi.fn(), @@ -54,11 +54,11 @@ describe('File history state', () => { const getLimitsSpy = vi.spyOn(fileVersionService, 'getLimits').mockResolvedValueOnce(limits); const dispatch = vi.fn(); - const action = await fetchVersionLimitsThunk()(dispatch, () => ({}) as RootState, undefined); + const action = await fetchVersionLimitsThunk({})(dispatch, () => ({}) as RootState, undefined); expect(getLimitsSpy).toHaveBeenCalled(); expect(action.meta.requestStatus).toBe('fulfilled'); - expect(action.payload).toBe(limits); + expect(action.payload).toEqual({ limits, isSilent: false }); }); it('when version limits fail to load, then the error is reported', async () => { @@ -67,7 +67,7 @@ describe('File history state', () => { .mockRejectedValueOnce(new Error('limits unavailable')); const dispatch = vi.fn(); - const action = await fetchVersionLimitsThunk()(dispatch, () => ({}) as RootState, undefined); + const action = await fetchVersionLimitsThunk({})(dispatch, () => ({}) as RootState, undefined); expect(getLimitsSpy).toHaveBeenCalled(); expect(action.meta.requestStatus).toBe('rejected'); @@ -138,7 +138,7 @@ describe('File history state', () => { }); it('when limits are loading or finished, then the loading state updates', () => { - const pendingState = fileVersionsReducer(undefined, fetchVersionLimitsThunk.pending('', undefined)); + const pendingState = fileVersionsReducer(undefined, fetchVersionLimitsThunk.pending('', {})); expect(pendingState.isLimitsLoading).toBe(true); const limits: FileLimitsResponse = { @@ -146,14 +146,14 @@ describe('File history state', () => { }; const fulfilledState = fileVersionsReducer( pendingState, - fetchVersionLimitsThunk.fulfilled(limits, '', undefined), + fetchVersionLimitsThunk.fulfilled({ limits, isSilent: false }, '', {}), ); expect(fulfilledState.isLimitsLoading).toBe(false); - expect(fulfilledState.limits).toBe(limits); + expect(fulfilledState.limits).toEqual(limits); const rejectedState = fileVersionsReducer( pendingState, - fetchVersionLimitsThunk.rejected(new Error('err'), '', undefined), + fetchVersionLimitsThunk.rejected(new Error('err'), '', {}), ); expect(rejectedState.isLimitsLoading).toBe(false); }); diff --git a/src/app/store/slices/fileVersions/index.ts b/src/app/store/slices/fileVersions/index.ts index 70e099e007..cee9190397 100644 --- a/src/app/store/slices/fileVersions/index.ts +++ b/src/app/store/slices/fileVersions/index.ts @@ -1,6 +1,9 @@ import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { FileVersion, FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; -import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; +import fileVersionService from 'views/Drive/services/fileVersion.service'; + +export const VERSION_LIMITS_POLL_MAX_ATTEMPTS = 3; +export const VERSION_LIMITS_POLL_DELAYS = [2000, 4000, 6000]; interface FileVersionsState { versionsByFileId: Record, FileVersion[]>; @@ -30,14 +33,17 @@ export const fetchFileVersionsThunk = createAsyncThunk( }, ); -export const fetchVersionLimitsThunk = createAsyncThunk('fileVersions/fetchLimits', async (_, { rejectWithValue }) => { - try { - const limits = await fileVersionService.getLimits(); - return limits; - } catch (error) { - return rejectWithValue((error as Error).message); - } -}); +export const fetchVersionLimitsThunk = createAsyncThunk( + 'fileVersions/fetchLimits', + async ({ isSilent = false }: { isSilent?: boolean } = {}, { rejectWithValue }) => { + try { + const limits = await fileVersionService.getLimits(); + return { limits, isSilent }; + } catch (error) { + return rejectWithValue((error as Error).message); + } + }, +); export const fileVersionsSlice = createSlice({ name: 'fileVersions', @@ -75,11 +81,14 @@ export const fileVersionsSlice = createSlice({ state.isLoadingByFileId[action.meta.arg] = false; state.errorsByFileId[action.meta.arg] = action.payload as string; }) - .addCase(fetchVersionLimitsThunk.pending, (state) => { - state.isLimitsLoading = true; + .addCase(fetchVersionLimitsThunk.pending, (state, action) => { + const isSilent = action.meta.arg?.isSilent || false; + if (!isSilent) { + state.isLimitsLoading = true; + } }) .addCase(fetchVersionLimitsThunk.fulfilled, (state, action) => { - state.limits = action.payload; + state.limits = action.payload.limits; state.isLimitsLoading = false; }) .addCase(fetchVersionLimitsThunk.rejected, (state) => { diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx index cf1b4cb277..0cf66c4be1 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx @@ -34,7 +34,7 @@ import { } from './DriveItemContextMenu'; import { List } from '@internxt/ui'; import { DownloadManager } from 'app/network/DownloadManager'; -import { useVersionHistoryMenuConfig } from '../../VersionHistory/hooks'; +import { useVersionHistoryMenuConfig } from 'views/Drive/hooks/useVersionHistoryMenuConfig'; interface DriveExplorerListProps { folderId: string; diff --git a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx index 81e5b6f965..a77466cd88 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveItemContextMenu.tsx @@ -106,13 +106,12 @@ const getVersionHistoryMenuItem = ( ): MenuItemType => { const isLocked = config?.isLocked ?? false; const isExtensionAllowed = config?.isExtensionAllowed ?? true; - const onUpgradeClick = config?.onUpgradeClick; if (isLocked) { return { name: t('drive.dropdown.versionHistory') as string, icon: LockSimple, - action: onUpgradeClick, + action: viewVersionHistory, disabled: () => false, node: (
{ - if (versionHistoryMenuConfig.locked) { - versionHistoryMenuConfig.onLockedClick?.(); - return; - } dispatch(uiActions.setVersionHistoryItem(selectedItems[0])); dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); }; diff --git a/src/views/Drive/components/VersionHistory/Sidebar.tsx b/src/views/Drive/components/VersionHistory/Sidebar.tsx index 0d1ba45778..ff0f501379 100644 --- a/src/views/Drive/components/VersionHistory/Sidebar.tsx +++ b/src/views/Drive/components/VersionHistory/Sidebar.tsx @@ -5,6 +5,8 @@ import { uiActions } from 'app/store/slices/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; import storageSelectors from 'app/store/slices/storage/storage.selectors'; import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk'; +import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; +import navigationService from 'services/navigation.service'; import { Header, CurrentVersionItem, @@ -12,8 +14,9 @@ import { AutosaveSection, VersionActionDialog, VersionHistorySkeleton, + LockedFeatureModal, } from './components'; -import fileVersionService from 'views/Drive/components/VersionHistory/services/fileVersion.service'; +import fileVersionService from 'views/Drive/services/fileVersion.service'; import errorService from 'services/error.service'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import { @@ -35,9 +38,11 @@ const Sidebar = () => { const isRestoreVersionDialogOpen = useAppSelector((state: RootState) => state.ui.isRestoreVersionDialogOpen); const versionToRestore = useAppSelector((state: RootState) => state.ui.versionToRestore); const currentFolderId = useAppSelector(storageSelectors.currentFolderId); + const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace); const { translate } = useTranslationContext(); const limits = useAppSelector(fileVersionsSelectors.getLimits); + const isLimitsLoading = useAppSelector(fileVersionsSelectors.isLimitsLoading); const versions = useAppSelector((state: RootState) => item ? fileVersionsSelectors.getVersionsByFileId(state, item.uuid) : [], ); @@ -51,6 +56,25 @@ const Sidebar = () => { updatedAt: '', }); + const isVersioningEnabled = limits?.versioning?.enabled ?? false; + const isLoadingContent = (isVersioningEnabled && isLoading) || isLimitsLoading; + + const blurredBackgroundVersions = useMemo(() => { + if (isVersioningEnabled) return []; + + const fixedDate = new Date('2024-05-01').toISOString(); + return Array.from({ length: 10 }, (_, i) => ({ + id: `mock-${i}`, + fileId: '', + size: '1000000', + createdAt: fixedDate, + updatedAt: fixedDate, + type: 'file', + })); + }, [isVersioningEnabled]); + + const displayVersions = isVersioningEnabled ? versions : blurredBackgroundVersions; + useEffect(() => { if (item) { setCurrentVersion({ @@ -59,7 +83,7 @@ const Sidebar = () => { }); } }, [item]); - const totalVersionsCount = versions.length; + const totalVersionsCount = displayVersions.length; const selectedCount = selectedAutosaveVersions.size; const selectAllAutosave = selectedCount === totalVersionsCount && totalVersionsCount > 0; const totalAllowedVersions = limits?.versioning.maxVersions ?? 0; @@ -201,21 +225,31 @@ const Sidebar = () => { dispatch(uiActions.setIsDeleteVersionDialogOpen(true)); }, [item, selectedCount, dispatch]); - if (!item) return null; + const handleUpgrade = () => { + dispatch(uiActions.setIsPreferencesDialogOpen(true)); + navigationService.openPreferencesDialog({ + section: 'account', + subsection: 'plans', + workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, + }); + }; + + const shouldShowSidebar = isOpen && item; + return ( <> - {isOpen &&
} + {shouldShowSidebar &&
}
-
- {isLoading ? ( +
+ {isLoadingContent ? ( ) : ( <> @@ -235,7 +269,7 @@ const Sidebar = () => { onDeleteAll={handleDeleteSelectedVersions} /> - {versions.map((version) => ( + {displayVersions.map((version) => ( { ))} )} + {!isVersioningEnabled && !isLimitsLoading && }
diff --git a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx index 321a936375..4d404ae975 100644 --- a/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx +++ b/src/views/Drive/components/VersionHistory/components/AutosaveSection.tsx @@ -24,7 +24,7 @@ export const AutosaveSection = ({ const isIndeterminate = hasSelection && !selectAllAutosave; return ( -
+
+
{formatVersionDate(createdAt)} diff --git a/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx new file mode 100644 index 0000000000..4150619b44 --- /dev/null +++ b/src/views/Drive/components/VersionHistory/components/LockedFeatureModal.tsx @@ -0,0 +1,49 @@ +import { Button } from '@internxt/ui'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { ClockCounterClockwise, LockSimple } from '@phosphor-icons/react'; + +interface LockedFeatureModalProps { + onUpgrade: () => void; +} + +const ICON_SIZES = { + clock: 64, + lock: 35, +} as const; + +export const LockedFeatureModal = ({ onUpgrade }: LockedFeatureModalProps) => { + const { translate } = useTranslationContext(); + + return ( +
+
+
+
+ +
+
+ +
+
+ +
+

+ {translate('modals.versionHistory.lockedFeature.title')} +

+

+ {translate('modals.versionHistory.lockedFeature.description')} +

+

+ {translate('modals.versionHistory.lockedFeature.supportedFormats')} +

+
+ +
+ +
+
+
+ ); +}; diff --git a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx index 8415d0cb01..f4a53c0ee6 100644 --- a/src/views/Drive/components/VersionHistory/components/VersionItem.tsx +++ b/src/views/Drive/components/VersionHistory/components/VersionItem.tsx @@ -1,7 +1,8 @@ import { Info, DotsThree } from '@phosphor-icons/react'; import { Checkbox, Dropdown, Avatar } from '@internxt/ui'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { useDropdownPositioning, useVersionItemActions } from '../hooks'; +import { useDropdownPositioning } from 'views/Drive/hooks/useDropdownPositioning'; +import { useVersionItemActions } from 'views/Drive/hooks/useVersionItemActions'; import { formatVersionDate, getDaysUntilExpiration } from '../utils'; import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; import { memo } from 'react'; @@ -52,7 +53,7 @@ export const VersionItem = memo( type="button" aria-pressed={isSelected} aria-label={`Version from ${formatVersionDate(version.createdAt)}`} - className={`group relative w-full px-6 cursor-pointer text-left ${isSelected ? 'bg-primary/10' : 'hover:bg-gray-1'}`} + className={`group relative w-full px-6 cursor-pointer text-left ${isSelected ? 'bg-primary/10' : 'hover:bg-gray-1 dark:hover:bg-white/3'}`} onClick={handleItemClick} >
diff --git a/src/views/Drive/components/VersionHistory/components/index.ts b/src/views/Drive/components/VersionHistory/components/index.ts index 9bc3ebe779..f63f5c3aee 100644 --- a/src/views/Drive/components/VersionHistory/components/index.ts +++ b/src/views/Drive/components/VersionHistory/components/index.ts @@ -4,3 +4,4 @@ export { VersionItem } from './VersionItem'; export { AutosaveSection } from './AutosaveSection'; export { VersionActionDialog } from './VersionActionDialog'; export { VersionHistorySkeleton } from './VersionHistorySkeleton'; +export { LockedFeatureModal } from './LockedFeatureModal'; diff --git a/src/views/Drive/components/VersionHistory/hooks/index.ts b/src/views/Drive/components/VersionHistory/hooks/index.ts deleted file mode 100644 index 5fb50562cd..0000000000 --- a/src/views/Drive/components/VersionHistory/hooks/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useDropdownPositioning } from './useDropdownPositioning'; -export { useVersionItemActions } from './useVersionItemActions'; -export { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts b/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts deleted file mode 100644 index 9b612a67d5..0000000000 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useSelector } from 'react-redux'; -import { useAppDispatch } from 'app/store/hooks'; -import { uiActions } from 'app/store/slices/ui'; -import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; -import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; -import navigationService from 'services/navigation.service'; -import { DriveItemData } from 'app/drive/types'; -import { isVersioningExtensionAllowed } from '../utils'; -import { VersionHistoryMenuConfig } from '../../DriveExplorer/components/DriveItemContextMenu'; - -export const useVersionHistoryMenuConfig = (selectedItem?: DriveItemData): VersionHistoryMenuConfig => { - const dispatch = useAppDispatch(); - const selectedWorkspace = useSelector(workspacesSelectors.getSelectedWorkspace); - const limits = useSelector(fileVersionsSelectors.getLimits); - const isVersioningEnabled = limits?.versioning?.enabled ?? false; - const isExtensionAllowed = selectedItem ? isVersioningExtensionAllowed(selectedItem) : true; - - return { - isLocked: !isVersioningEnabled, - isExtensionAllowed, - onUpgradeClick: () => { - dispatch(uiActions.setIsPreferencesDialogOpen(true)); - navigationService.openPreferencesDialog({ - section: 'account', - subsection: 'plans', - workspaceUuid: selectedWorkspace?.workspaceUser.workspaceId, - }); - }, - }; -}; diff --git a/src/views/Drive/hooks/index.ts b/src/views/Drive/hooks/index.ts index ae8b20a932..8a207b8786 100644 --- a/src/views/Drive/hooks/index.ts +++ b/src/views/Drive/hooks/index.ts @@ -3,4 +3,7 @@ export { useDriveItemDrag, useDriveItemDrop } from './useDriveItemDragAndDrop'; export { default as useDriveItemStoreProps } from './useDriveStoreProps'; export { usePaginationState } from './usePaginationState'; export { useTutorialState } from './useTutorialState'; +export { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; +export { useVersionItemActions } from './useVersionItemActions'; +export { useDropdownPositioning } from './useDropdownPositioning'; export type { DriveItemActions } from './useDriveItemActions'; diff --git a/src/views/Drive/hooks/useDriveItemActions.tsx b/src/views/Drive/hooks/useDriveItemActions.tsx index 6460615b7e..30b5c1a432 100644 --- a/src/views/Drive/hooks/useDriveItemActions.tsx +++ b/src/views/Drive/hooks/useDriveItemActions.tsx @@ -11,7 +11,7 @@ import { storageActions } from 'app/store/slices/storage'; import { uiActions } from 'app/store/slices/ui'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { DownloadManager } from 'app/network/DownloadManager'; -import { useVersionHistoryMenuConfig } from '../components/VersionHistory/hooks'; +import { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; export interface DriveItemActions { nameInputRef: React.RefObject; @@ -104,10 +104,6 @@ const useDriveItemActions = (item): DriveItemActions => { }; const onViewVersionHistoryButtonClicked = () => { - if (versionHistoryConfig.locked) { - versionHistoryConfig.onLockedClick?.(); - return; - } dispatch(uiActions.setVersionHistoryItem(item as DriveItemData)); dispatch(uiActions.setIsVersionHistorySidebarOpen(true)); }; diff --git a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts b/src/views/Drive/hooks/useDropdownPositioning.test.ts similarity index 100% rename from src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.test.ts rename to src/views/Drive/hooks/useDropdownPositioning.test.ts diff --git a/src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.ts b/src/views/Drive/hooks/useDropdownPositioning.ts similarity index 100% rename from src/views/Drive/components/VersionHistory/hooks/useDropdownPositioning.ts rename to src/views/Drive/hooks/useDropdownPositioning.ts diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts b/src/views/Drive/hooks/useVersionHistoryMenuConfig.test.ts similarity index 57% rename from src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts rename to src/views/Drive/hooks/useVersionHistoryMenuConfig.test.ts index d45bb6e596..f8989ab120 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionHistoryMenuConfig.test.ts +++ b/src/views/Drive/hooks/useVersionHistoryMenuConfig.test.ts @@ -1,41 +1,13 @@ -import { renderHook, act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi, Mock } from 'vitest'; import { useSelector } from 'react-redux'; -import { useAppDispatch } from 'app/store/hooks'; -import navigationService from 'services/navigation.service'; import { useVersionHistoryMenuConfig } from './useVersionHistoryMenuConfig'; import { FileLimitsResponse } from '@internxt/sdk/dist/drive/storage/types'; -import { WorkspaceData } from '@internxt/sdk/dist/workspaces'; vi.mock('react-redux', () => ({ useSelector: vi.fn(), })); -vi.mock('app/store/hooks', () => ({ - useAppDispatch: vi.fn(), -})); - -const mockSetIsPreferencesDialogOpen = vi.hoisted(() => - vi.fn((payload: boolean) => ({ type: 'setIsPreferencesDialogOpen', payload })), -); - -vi.mock('app/store/slices/ui', () => ({ - uiActions: { - setIsPreferencesDialogOpen: mockSetIsPreferencesDialogOpen, - }, -})); - -vi.mock('services/navigation.service', () => ({ - default: { - openPreferencesDialog: vi.fn(), - }, -})); - -const workspaceMock = { - workspaceUser: { workspaceId: 'workspace-1' }, - workspace: { id: 'workspace-1' }, -} as unknown as WorkspaceData; - const enabledLimits: FileLimitsResponse = { versioning: { enabled: true, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, }; @@ -46,38 +18,23 @@ const disabledLimits: FileLimitsResponse = { describe('Version history menu', () => { const mockUseSelector = useSelector as unknown as Mock; - const mockUseAppDispatch = useAppDispatch as unknown as Mock; - const dispatch = vi.fn(); const mockState = (limits: FileLimitsResponse | null) => ({ - workspaces: { selectedWorkspace: workspaceMock }, fileVersions: { limits }, }) as any; beforeEach(() => { vi.clearAllMocks(); - mockUseAppDispatch.mockReturnValue(dispatch); }); - it('when versioning is locked, then the upgrade flow opens', () => { + it('when versioning is locked, then the menu is marked as locked', () => { mockUseSelector.mockImplementation((selector: (state: any) => unknown) => selector(mockState(disabledLimits))); const { result } = renderHook(() => useVersionHistoryMenuConfig({ type: 'pdf' } as any)); expect(result.current.isLocked).toBe(true); expect(result.current.isExtensionAllowed).toBe(true); - - act(() => { - result.current.onUpgradeClick?.(); - }); - - expect(dispatch).toHaveBeenCalledWith({ type: 'setIsPreferencesDialogOpen', payload: true }); - expect(navigationService.openPreferencesDialog).toHaveBeenCalledWith({ - section: 'account', - subsection: 'plans', - workspaceUuid: 'workspace-1', - }); }); it('when versioning is enabled and the file is supported, then the menu is unlocked', () => { diff --git a/src/views/Drive/hooks/useVersionHistoryMenuConfig.ts b/src/views/Drive/hooks/useVersionHistoryMenuConfig.ts new file mode 100644 index 0000000000..215b94093d --- /dev/null +++ b/src/views/Drive/hooks/useVersionHistoryMenuConfig.ts @@ -0,0 +1,16 @@ +import { useSelector } from 'react-redux'; +import { fileVersionsSelectors } from 'app/store/slices/fileVersions'; +import { DriveItemData } from 'app/drive/types'; +import { isVersioningExtensionAllowed } from '../components/VersionHistory/utils'; +import { VersionHistoryMenuConfig } from '../components/DriveExplorer/components/DriveItemContextMenu'; + +export const useVersionHistoryMenuConfig = (selectedItem?: DriveItemData): VersionHistoryMenuConfig => { + const limits = useSelector(fileVersionsSelectors.getLimits); + const isVersioningEnabled = limits?.versioning?.enabled ?? false; + const isExtensionAllowed = selectedItem ? isVersioningExtensionAllowed(selectedItem) : true; + + return { + isLocked: !isVersioningEnabled, + isExtensionAllowed, + }; +}; diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts b/src/views/Drive/hooks/useVersionItemActions.test.ts similarity index 96% rename from src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts rename to src/views/Drive/hooks/useVersionItemActions.test.ts index f055c78c9d..0739f87ddc 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.test.ts +++ b/src/views/Drive/hooks/useVersionItemActions.test.ts @@ -20,7 +20,7 @@ vi.mock('app/store/hooks', () => ({ useAppSelector: vi.fn(), })); -vi.mock('views/Drive/components/VersionHistory/services/fileVersion.service', () => ({ +vi.mock('views/Drive/services/fileVersion.service', () => ({ default: { downloadVersion: vi.fn(), }, @@ -35,6 +35,12 @@ vi.mock('app/notifications/services/notifications.service', () => ({ }, })); +vi.mock('services/date.service', () => ({ + default: { + format: vi.fn(() => '10-01-2026 at 14:30'), + }, +})); + const mockSetVersionToRestore = vi.hoisted(() => vi.fn((payload) => ({ type: 'setVersionToRestore', payload }))); const mockSetIsRestoreVersionDialogOpen = vi.hoisted(() => vi.fn((payload) => ({ type: 'setIsRestoreVersionDialogOpen', payload })), @@ -60,6 +66,7 @@ describe('Version item menu', () => { fileId: 'file-uuid', networkFileId: 'network-file-id', size: '5', + createdAt: '2026-01-10T14:30:00.000Z', } as FileVersion; const fileItem = { id: 'file-id', @@ -142,7 +149,7 @@ describe('Version item menu', () => { expect(downloadVersionSpy).toHaveBeenCalledWith( version, fileItem, - fileItem.plainName, + '(10-01-2026 at 14:30) pretty-name', selectedWorkspace, workspaceCredentials, ); diff --git a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts b/src/views/Drive/hooks/useVersionItemActions.ts similarity index 79% rename from src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts rename to src/views/Drive/hooks/useVersionItemActions.ts index 0400bdd476..6d8e0b1d1f 100644 --- a/src/views/Drive/components/VersionHistory/hooks/useVersionItemActions.ts +++ b/src/views/Drive/hooks/useVersionItemActions.ts @@ -8,6 +8,8 @@ import { uiActions } from 'app/store/slices/ui'; import { RootState } from 'app/store'; import workspacesSelectors from 'app/store/slices/workspaces/workspaces.selectors'; import { FileVersion } from '@internxt/sdk/dist/drive/storage/types'; +import dateService from 'services/date.service'; +import { items as itemsUtils } from '@internxt/lib'; interface UseVersionItemActionsParams { version: FileVersion; @@ -38,8 +40,14 @@ export const useVersionItemActions = ({ version, onDropdownClose }: UseVersionIt return; } - const fileName = item.plainName || item.name; - await fileVersionService.downloadVersion(version, item, fileName, selectedWorkspace, workspaceCredentials); + const entireFilename = item.plainName || item.name; + const formattedDate = dateService.format(version.createdAt, 'DD-MM-YYYY [at] HH:mm'); + const { filename, extension } = itemsUtils.getFilenameAndExt(entireFilename); + + const fileExtension = extension || item.type ? `.${extension || item.type}` : ''; + const versionFileName = `(${formattedDate}) ${filename}${fileExtension}`; + + await fileVersionService.downloadVersion(version, item, versionFileName, selectedWorkspace, workspaceCredentials); }; const handleDeleteClick = () => { diff --git a/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts b/src/views/Drive/services/fileVersion.service.test.ts similarity index 98% rename from src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts rename to src/views/Drive/services/fileVersion.service.test.ts index 8740e9b140..53cf50a133 100644 --- a/src/views/Drive/components/VersionHistory/services/fileVersion.service.test.ts +++ b/src/views/Drive/services/fileVersion.service.test.ts @@ -102,11 +102,13 @@ describe('File version actions', () => { ...fileItem, fileId: version.networkFileId, size: Number(version.size), - name: 'custom-name', }, ], selectedWorkspace, workspaceCredentials, + downloadOptions: { + downloadName: 'custom-name', + }, }); }); }); diff --git a/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts b/src/views/Drive/services/fileVersion.service.ts similarity index 96% rename from src/views/Drive/components/VersionHistory/services/fileVersion.service.ts rename to src/views/Drive/services/fileVersion.service.ts index a5d085091f..6a82e43d43 100644 --- a/src/views/Drive/components/VersionHistory/services/fileVersion.service.ts +++ b/src/views/Drive/services/fileVersion.service.ts @@ -33,13 +33,15 @@ export async function downloadVersion( ...fileItem, fileId: version.networkFileId, size: Number(version.size), - name: fileName, }; await DownloadManager.downloadItem({ payload: [versionFileData], selectedWorkspace, workspaceCredentials, + downloadOptions: { + downloadName: fileName, + }, }); } diff --git a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx index ff9f18d67b..61c43eb7cd 100644 --- a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx +++ b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx @@ -13,6 +13,12 @@ import { paymentService } from 'views/Checkout/services'; import { RootState } from 'app/store'; import { useAppDispatch } from 'app/store/hooks'; import { PlanState, planThunks } from 'app/store/slices/plan'; +import { + fetchVersionLimitsThunk, + VERSION_LIMITS_POLL_MAX_ATTEMPTS, + VERSION_LIMITS_POLL_DELAYS, + fileVersionsSelectors, +} from 'app/store/slices/fileVersions'; import CancelSubscriptionModal from '../../Workspace/Billing/CancelSubscriptionModal'; import { fetchPlanPrices, getStripe } from '../../../../services/plansApi'; import ChangePlanDialog from './components/ChangePlanDialog'; @@ -46,6 +52,7 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) const selectedWorkspace = useSelector((state: RootState) => state.workspaces.selectedWorkspace); const plan = useSelector((state) => state.plan); const user = useSelector((state) => state.user.user); + const versionLimits = useSelector(fileVersionsSelectors.getLimits); const { individualSubscription, businessSubscription } = plan; let stripe; @@ -182,9 +189,34 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) [translate], ); + const pollVersionLimitsUntilChanged = useCallback( + async (attempt = 0, previousLimits = versionLimits) => { + if (attempt >= VERSION_LIMITS_POLL_MAX_ATTEMPTS) return; + + const delay = VERSION_LIMITS_POLL_DELAYS[attempt] || VERSION_LIMITS_POLL_DELAYS.at(-1); + await new Promise((resolve) => setTimeout(resolve, delay)); + const result = await dispatch(fetchVersionLimitsThunk({ isSilent: true })).unwrap(); + + const hasLimitsChanged = + previousLimits?.versioning.enabled !== result.limits.versioning.enabled || + previousLimits?.versioning.maxFileSize !== result.limits.versioning.maxFileSize || + previousLimits?.versioning.retentionDays !== result.limits.versioning.retentionDays || + previousLimits?.versioning.maxVersions !== result.limits.versioning.maxVersions; + + if (!hasLimitsChanged) { + pollVersionLimitsUntilChanged(attempt + 1, previousLimits); + } + }, + [dispatch, versionLimits], + ); + const handlePaymentSuccess = () => { showSuccessSubscriptionNotification(); - setTimeout(() => dispatch(planThunks.initializeThunk()).unwrap(), 2000); + setTimeout(() => { + dispatch(planThunks.initializeThunk()).unwrap(); + }, 2000); + + pollVersionLimitsUntilChanged(); }; const handleSubscriptionPayment = async (priceId: string) => { @@ -263,6 +295,8 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) setTimeout(() => { dispatch(planThunks.initializeThunk()).unwrap(); }, 1000); + + pollVersionLimitsUntilChanged(); } }