diff --git a/libs/core/__deprecated__/renderer/locales/default/en-US.json b/libs/core/__deprecated__/renderer/locales/default/en-US.json index 05ff79016..9ae911124 100644 --- a/libs/core/__deprecated__/renderer/locales/default/en-US.json +++ b/libs/core/__deprecated__/renderer/locales/default/en-US.json @@ -1056,7 +1056,6 @@ "module.genericViews.filesManager.preview.unknownError": "Failed to load photo.\nPlease try again.", "module.genericViews.filesManager.preview.backButton": "Back", "module.genericViews.filesManager.preview.retryButton": "Try again", - "module.genericViews.appInstallation.progress.modalTitle": "Installing the App...", "module.genericViews.appInstallation.error.modalTitle": "Installation failed", "module.genericViews.appInstallation.error.globalMessage": "The installation process was interrupted.", diff --git a/libs/core/core/components/root-wrapper.tsx b/libs/core/core/components/root-wrapper.tsx index 04c2818ac..f47d0b49b 100644 --- a/libs/core/core/components/root-wrapper.tsx +++ b/libs/core/core/components/root-wrapper.tsx @@ -15,22 +15,28 @@ import localeEn from "Core/__deprecated__/renderer/locales/default/en-US.json" import { ModalProvider } from "Core/__deprecated__/renderer/components/core/modal/modal.service" import modalService from "Core/__deprecated__/renderer/components/core/modal/modal.service" import AppsSwitch from "Core/core/components/apps-switch" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ReactQueryDevtools } from "@tanstack/react-query-devtools" const RootWrapper: FunctionComponent = () => { + const queryClient = new QueryClient() return ( - - - - - - - - - + + + + + + + + + + + + ) } diff --git a/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-export-button.ts b/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-export-button.ts index bcaacec96..b04548614 100644 --- a/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-export-button.ts +++ b/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-export-button.ts @@ -22,10 +22,10 @@ export const generateFilesExportButtonActions = ( { singleEntityId, entityType, - exportActionId, + exportActionId = entityType + "Export", }: Pick & { singleEntityId?: string - exportActionId: string + exportActionId?: string } ): ButtonTextConfig["actions"] => { return [ @@ -128,9 +128,17 @@ export const generateFileExportProcessButton: ComponentGenerator< "entityType" | "directoryPath" > & { singleEntityId?: string - exportActionId: string + exportActionId?: string } -> = (key, { directoryPath, entityType, singleEntityId, exportActionId }) => { +> = ( + key, + { + directoryPath, + entityType, + singleEntityId, + exportActionId = entityType + "Export", + } +) => { return { [generateFilesExportProcessButtonKey(key)]: { component: "button-text", diff --git a/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-list.ts b/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-list.ts index e216635d0..27667187b 100644 --- a/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-list.ts +++ b/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-list.ts @@ -39,7 +39,9 @@ const generateFileList: ComponentGenerator< features, } ) => { - const isExportEnabled = features?.includes("export") + const isExportEnabled = Boolean(features?.includes("export")) + const isPreviewEnabled = + Boolean(features?.includes("preview")) && entityType === "imageFiles" return { [`${key}${id}fileListContainer`]: { component: "conditional-renderer", @@ -297,7 +299,6 @@ const generateFileList: ComponentGenerator< ...generateFileExportProcessButton(`${key}${id}`, { directoryPath, entityType, - exportActionId: entityType + "Export", }), [`${key}${id}deleteButton`]: { component: "button-text", @@ -355,7 +356,7 @@ const generateFileList: ComponentGenerator< allIdsFieldName: "allItems", }, previewOptions: { - enabled: entityType === "imageFiles", + enabled: isPreviewEnabled, entitiesType: entityType, entityIdFieldName: "id", entityPathFieldName: "filePath", diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview-error-types.ts b/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview-error-types.ts new file mode 100644 index 000000000..439e84a52 --- /dev/null +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview-error-types.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +export enum FilePreviewErrorType { + UnsupportedFileType = "unsupported-file-type", + UnsupportedTransferMode = "unsupported-transfer-mode", + FileNotFound = "file-not-found", + Unknown = "unknown", +} + +export type FilePreviewError = { + type: FilePreviewErrorType + details?: string +} + +export const isFilePreviewError = ( + error: unknown +): error is FilePreviewError => { + return ( + typeof error === "object" && + "type" in (error as FilePreviewError) && + Object.values(FilePreviewErrorType).includes( + (error as FilePreviewError).type + ) + ) +} diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview-error.types.ts b/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview-error.types.ts deleted file mode 100644 index df9302186..000000000 --- a/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview-error.types.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) Mudita sp. z o.o. All rights reserved. - * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md - */ - -export enum FilePreviewErrorType { - UnsupportedFileType = "unsupported-file-type", - UnsupportedTransferType = "unsupported-transfer-type", - Unknown = "unknown", -} - -export type FilePreviewError = { - type: FilePreviewErrorType - details?: string -} - -export type FilePreviewErrorHandler = (error: FilePreviewError | undefined) => void diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview.tsx index 2f0b962bd..2c3fc0c00 100644 --- a/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview.tsx +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview.tsx @@ -8,35 +8,26 @@ import React, { memo, useCallback, useEffect, - useMemo, - useRef, useState, } from "react" -import { useSelector } from "react-redux" import styled, { css } from "styled-components" -import { selectEntityData, selectFilesTransferMode } from "generic-view/store" import { IconType } from "generic-view/utils" import { Modal } from "../../interactive/modal/modal" import { IconButton } from "../../shared/button" import { Icon } from "../../icon/icon" import { Typography } from "../../typography" -import { SpinnerLoader } from "../../shared/spinner-loader" -import { ImagePreview } from "./image-preview" import { AnimatePresence, motion } from "motion/react" -import { useFilesPreview, UseFilesPreviewParams } from "./use-files-preview" -import { activeDeviceIdSelector } from "active-device-registry/feature" -import { ReduxRootState } from "Core/__deprecated__/renderer/store" +import { FilePreviewEntitiesConfig, useFilePreview } from "./use-file-preview" import { ButtonIcon } from "../../buttons/button-icon" import { generateFilesExportButtonActions } from "../../generated/mc-file-manager/file-export-button" import { generateDeleteFilesButtonActions } from "../../generated/mc-file-manager/delete-files" import { ModalLayers } from "Core/modals-manager/constants/modal-layers.enum" -import { - FilePreviewError, - FilePreviewErrorType, -} from "./file-preview-error.types" import { defineMessages } from "react-intl" -import { intl } from "Core/__deprecated__/renderer/utils/intl" import { ButtonSecondary } from "../../buttons/button-secondary" +import { intl } from "Core/__deprecated__/renderer/utils/intl" +import { ImagePreview } from "./image-preview" +import { FilePreviewErrorType } from "./file-preview-error-types" +import { FilePreviewLoader } from "./shared-components" const messages = defineMessages({ unsupportedFileType: { @@ -54,121 +45,128 @@ const messages = defineMessages({ }) interface Props { - componentKey: string - entitiesConfig: UseFilesPreviewParams["entitiesConfig"] items: string[] - activeItem: string | undefined + initialItem: string | undefined + opened: boolean onActiveItemChange: (item: string | undefined) => void - actions?: React.ReactNode + onClose: VoidFunction + componentKey: string + entitiesConfig: FilePreviewEntitiesConfig } export const FilePreview: FunctionComponent = memo( - ({ items, activeItem, onActiveItemChange, entitiesConfig, componentKey }) => { - const deviceId = useSelector(activeDeviceIdSelector) - const entity = useSelector((state: ReduxRootState) => { - if (!activeItem || !deviceId) return undefined - return selectEntityData(state, { - deviceId, - entitiesType: entitiesConfig.type, - entityId: activeItem, - }) - }) - const fileTransferMode = useSelector(selectFilesTransferMode) - const nextIdReference = useRef() + ({ + opened, + items, + initialItem, + onClose, + onActiveItemChange, + entitiesConfig, + componentKey, + }) => { + const [currentItemId, setCurrentItemId] = useState(initialItem) + const [previewLoadingError, setPreviewLoadingError] = useState(false) + const [entitiesIds, setEntitiesIds] = useState(items) + + useEffect(() => { + setEntitiesIds(items) + }, [items]) const { - data: fileInfo, - nextId, - previousId, - refetch, + data, isLoading, - } = useFilesPreview({ - items, - activeItem, + isPending, + isFetching, + isError, + error, + refetch, + cancel, + dataUpdatedAt, + fileName, + getNextFileId, + getPrevFileId, + } = useFilePreview({ + entitiesIds, + entitiesType: entitiesConfig.type, + entityId: currentItemId, entitiesConfig, }) - const [fileUid, setFileUid] = useState(Date.now()) - const [error, setError] = useState() - const isFinished = (!isLoading && !!fileInfo) || !!error - - const entityType = useMemo(() => { - if (!entity) return "" - return entity[entitiesConfig.mimeTypeField] as string - }, [entitiesConfig.mimeTypeField, entity]) - - const entityName = useMemo(() => { - if (!entity) return "" - return entity[entitiesConfig.titleField] as string - }, [entitiesConfig.titleField, entity]) + const inProgress = isLoading || isPending || isFetching + const hasError = isError || previewLoadingError const handleClose = useCallback(() => { - onActiveItemChange(undefined) - setError(undefined) - nextIdReference.current = undefined - }, [onActiveItemChange]) + onClose() + }, [onClose]) - const handlePreviousFile = useCallback(() => { - setError(undefined) - onActiveItemChange(previousId) - setFileUid(Date.now()) - }, [onActiveItemChange, previousId]) + const handlePreviousFile = useCallback(async () => { + await cancel() + setPreviewLoadingError(false) + setCurrentItemId(getPrevFileId()) + }, [cancel, getPrevFileId]) - const handleNextFile = useCallback(() => { - setError(undefined) - onActiveItemChange(nextIdReference.current || nextId) - setFileUid(Date.now()) - }, [nextId, onActiveItemChange]) + const handleNextFile = useCallback(async () => { + await cancel() + setPreviewLoadingError(false) + setCurrentItemId(getNextFileId()) + }, [cancel, getNextFileId]) const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === "ArrowLeft") { - handlePreviousFile() + void handlePreviousFile() } else if (event.key === "ArrowRight") { - handleNextFile() + void handleNextFile() } }, [handleNextFile, handlePreviousFile] ) - const handleRetry = useCallback(async () => { - if (!activeItem) return - setError(undefined) - await refetch() - setFileUid(Date.now()) - }, [activeItem, refetch]) + const handlePreviewError = useCallback(() => { + setPreviewLoadingError(true) + }, []) + + const handleRetry = useCallback(() => { + setPreviewLoadingError(false) + void refetch() + }, [refetch]) useEffect(() => { - document.addEventListener("keydown", handleKeyDown) - if (!activeItem) { + void (async () => { + // Open next file preview if current file was deleted + if (currentItemId && !items.includes(currentItemId)) { + await handleNextFile() + setEntitiesIds(items) + } + })() + }, [currentItemId, handleNextFile, items]) + + useEffect(() => { + // Set initial state when opening preview modal + setCurrentItemId(initialItem) + }, [initialItem]) + + useEffect(() => { + // Notify parent component about active file change + onActiveItemChange(currentItemId) + }, [currentItemId, getNextFileId, onActiveItemChange]) + + useEffect(() => { + if (initialItem) { + document.addEventListener("keydown", handleKeyDown) + } else { document.removeEventListener("keydown", handleKeyDown) } return () => { document.removeEventListener("keydown", handleKeyDown) } - }, [activeItem, handleKeyDown]) - - useEffect(() => { - if (fileTransferMode !== "mtp") { - setError({ - type: FilePreviewErrorType.UnsupportedTransferType, - }) - } else if (error?.type === FilePreviewErrorType.UnsupportedTransferType) { - void handleRetry() - } - }, [error?.type, fileTransferMode, handleRetry]) - - useEffect(() => { - if (!activeItem) { - setError(undefined) - } - }, [activeItem]) + }, [handleKeyDown, initialItem]) return ( = memo( >
- - {entityName} + + {fileName} = memo(
- - {!error && ( - + {hasError ? ( + - - - )} - - - {isFinished && ( + + {error?.type === FilePreviewErrorType.UnsupportedFileType ? ( + <> + + {intl.formatMessage(messages.unsupportedFileType, { + type: error.details, + })} + + + + ) : ( + <> + + {intl.formatMessage(messages.unknownError)} + + + + )} + + ) : inProgress ? ( + + ) : ( - {entityType.startsWith("image") && ( + {data?.fileType?.startsWith("image") && ( )} - - {Boolean(error) && ( - - - {error?.type === - FilePreviewErrorType.UnsupportedFileType ? ( - <> - - {intl.formatMessage( - messages.unsupportedFileType, - { - type: error.details, - } - )} - - - - ) : ( - <> - - {intl.formatMessage(messages.unknownError)} - - - - )} - - )} - )} @@ -312,8 +296,7 @@ export const FilePreview: FunctionComponent = memo( iconSize: "large", actions: [ ...generateFilesExportButtonActions(componentKey, { - exportActionId: "previewExport", - singleEntityId: activeItem, + singleEntityId: currentItemId, entityType: entitiesConfig.type, }), ], @@ -324,7 +307,7 @@ export const FilePreview: FunctionComponent = memo( icon: IconType.Delete, iconSize: "large", actions: generateDeleteFilesButtonActions(componentKey, { - singleEntityId: activeItem, + singleEntityId: currentItemId, }), }} /> @@ -454,14 +437,6 @@ const ModalContent = styled.section` } ` -const Loader = styled(motion.div)` - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1; -` - const PreviewWrapper = styled(motion.div)` position: relative; width: 100%; @@ -469,19 +444,7 @@ const PreviewWrapper = styled(motion.div)` z-index: 2; ` -const Main = styled.main` - position: relative; - z-index: 1; - width: 100%; - height: 64rem; - display: flex; - align-items: center; - justify-content: center; - background-color: ${({ theme }) => theme.color.grey0}; -` - const ErrorWrapper = styled(motion.div)` - background-color: ${({ theme }) => theme.color.grey0}; position: absolute; top: 50%; left: 50%; @@ -522,3 +485,14 @@ const ErrorIcon = styled(Icon)` border-radius: 50%; background-color: ${({ theme }) => theme.color.white}; ` + +const Main = styled.main` + position: relative; + z-index: 1; + width: 100%; + height: 64rem; + display: flex; + align-items: center; + justify-content: center; + background-color: ${({ theme }) => theme.color.grey0}; +` diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/image-preview.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/image-preview.tsx index 89ca521d4..a7b9ea2e3 100644 --- a/libs/generic-view/ui/src/lib/predefined/file-preview/image-preview.tsx +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/image-preview.tsx @@ -11,63 +11,55 @@ import React, { useState, } from "react" import styled from "styled-components" -import { - FilePreviewErrorHandler, - FilePreviewErrorType, -} from "./file-preview-error.types" +import { AnimatePresence } from "motion/react" +import { FilePreviewLoader } from "./shared-components" interface Props { src?: string - fileUid?: string | number - onError?: FilePreviewErrorHandler + onError?: () => void } -export const ImagePreview: FunctionComponent = ({ - src, - fileUid, - onError, -}) => { - const loadedTimeoutRef = useRef() +export const ImagePreview: FunctionComponent = ({ src, onError }) => { + const imgRef = useRef(null) const [loaded, setLoaded] = useState(false) - const uniqueSrc = `${src}?u=${fileUid}` - const handleLoad = useCallback(() => { - setLoaded(true) - onError?.(undefined) + const onLoad = useCallback(async () => { + const img = imgRef.current + + try { + if (!img) { + throw new Error("Image not found") + } + await img.decode() + setLoaded(true) + } catch { + onError?.() + } }, [onError]) - const handleError = useCallback(() => { - if (src?.endsWith(".heic")) { - onError?.({ - type: FilePreviewErrorType.UnsupportedFileType, - details: "HEIC", - }) - } else if (src?.endsWith(".heif")) { - onError?.({ - type: FilePreviewErrorType.UnsupportedFileType, - details: "HEIF", - }) - } else { - onError?.({ type: FilePreviewErrorType.Unknown }) - } - }, [onError, src]) - useEffect(() => { - clearTimeout(loadedTimeoutRef.current) setLoaded(false) }, [src]) return ( - - - - + <> + + + + + + {!loaded && } + + ) } diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/shared-components.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/shared-components.tsx new file mode 100644 index 000000000..63a7afaf9 --- /dev/null +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/shared-components.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import styled from "styled-components" +import { motion } from "motion/react" +import React, { FunctionComponent } from "react" +import { SpinnerLoader } from "../../shared/spinner-loader" + +const FilePreviewLoaderWrapper = styled(motion.div)` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1; +` + +export const FilePreviewLoader: FunctionComponent = () => { + return ( + + + + ) +} diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview-download.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview-download.tsx index 8f50b188d..222274e81 100644 --- a/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview-download.tsx +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview-download.tsx @@ -7,6 +7,7 @@ import { useDispatch, useSelector, useStore } from "react-redux" import { FileWithPath, selectEntityData, + selectFilesSendingFailed, sendFiles, SendFilesAction, } from "generic-view/store" @@ -23,6 +24,7 @@ import path from "node:path" export interface FilePreviewResponse { id: string path: string + nativePath: string name: string type: string } @@ -62,7 +64,7 @@ export const useFilePreviewDownload = ({ const nativePath = path.join(nativeDir, fileName) return { nativeDir, - nativePath: nativePath, + nativePath, safePath: process.platform === "win32" ? nativePath @@ -72,30 +74,50 @@ export const useFilePreviewDownload = ({ [tempDirectoryPath] ) + const getEntityData = useCallback( + (id?: string) => { + const entity = + deviceId && id + ? selectEntityData(store.getState(), { + deviceId, + entitiesType, + entityId: id, + }) + : undefined + + return { + fileName: (entity?.[fields.titleField] as string) || "", + fileType: (entity?.[fields.mimeTypeField] as string) || "", + filePath: (entity?.[fields.pathField] as string) || "", + fileSize: entity?.[fields.sizeField] as number | undefined, + isInternal: entity?.["isInternal"] as boolean | undefined, + } + }, + [ + deviceId, + store, + entitiesType, + fields.titleField, + fields.mimeTypeField, + fields.pathField, + fields.sizeField, + ] + ) + const downloadFile = useCallback( - async ( - entityId: string, - onFileNameFound?: (name: string) => void - ): Promise => { + async (entityId: string): Promise => { if (!deviceId || !tempDirectoryPath) { return } - const entity = selectEntityData(store.getState(), { - deviceId, - entitiesType, - entityId, - }) + const entity = getEntityData(entityId) - if (!entity || !("filePath" in entity) || !("fileName" in entity)) { + if (!entity.fileName || !entity.filePath || !entity.fileType) { return } - const fileName = entity[fields.titleField] as string - onFileNameFound?.(fileName) - const fileType = (entity[fields.mimeTypeField] as string) - .split("/")[0] - .toLowerCase() - const filePath = getFilePath(entity[fields.pathField] as string) + const fileName = entity.fileName + const fileType = entity.fileType.split("/")[0].toLowerCase() + const filePath = getFilePath(entity.filePath) if (!filePath) { return } @@ -104,6 +126,7 @@ export const useFilePreviewDownload = ({ return { id: entityId, path: filePath.safePath, + nativePath: filePath.nativePath, name: fileName, type: fileType, } @@ -112,12 +135,9 @@ export const useFilePreviewDownload = ({ const fileData: FileWithPath = { id: entityId, - path: sliceMtpPaths( - entity[fields.pathField] as string, - entity.isInternal as boolean - ), - name: String(entity[fields.titleField]), - size: Number(entity[fields.sizeField]), + path: sliceMtpPaths(entity.filePath, entity.isInternal as boolean), + name: String(entity.fileName), + size: Number(entity.fileSize), groupId: actionId, } @@ -127,9 +147,7 @@ export const useFilePreviewDownload = ({ destinationPath: filePath.nativeDir, actionId, entitiesType, - isMtpPathInternal: isMtpPathInternal( - entity[fields.pathField] as string - ), + isMtpPathInternal: isMtpPathInternal(entity.filePath as string), actionType: SendFilesAction.ActionExport, }) ) as unknown as ReturnType> @@ -142,13 +160,20 @@ export const useFilePreviewDownload = ({ const response = await promise - if (response.meta.requestStatus === "rejected") { - return + const requestFailed = + selectFilesSendingFailed(store.getState(), { + filesIds: [entityId], + }).length > 0 + + if (response.meta.requestStatus === "rejected" || requestFailed) { + throw new Error("Preview download failed") } + delete abortReferences.current[entityId] return { id: entityId, path: filePath.safePath, + nativePath: filePath.nativePath, name: fileName, type: fileType, } @@ -158,42 +183,34 @@ export const useFilePreviewDownload = ({ deviceId, dispatch, entitiesType, - fields.mimeTypeField, - fields.pathField, - fields.sizeField, - fields.titleField, + getEntityData, getFilePath, store, tempDirectoryPath, ] ) - const removePreviewFile = useCallback( - async (entityId: string) => { - if (!deviceId || !tempDirectoryPath) { - return + const removeFile = useCallback( + async (params: { entityId: string } | { path: string }) => { + let filePath: string | undefined + + if ("path" in params) { + filePath = params.path + } else if ("entityId" in params) { + if (!deviceId || !tempDirectoryPath) { + return + } + const entity = getEntityData(params.entityId) + if (!entity.filePath) { + return + } + filePath = getFilePath(entity.filePath)?.nativePath } - const entity = selectEntityData(store.getState(), { - deviceId, - entitiesType, - entityId, - }) - if (!entity) { - return - } - const path = getFilePath(entity[fields.pathField] as string)?.nativePath - if (path) { - await removeDirectory(path) + if (filePath) { + await removeDirectory(filePath) } }, - [ - deviceId, - entitiesType, - fields.pathField, - getFilePath, - store, - tempDirectoryPath, - ] + [deviceId, getEntityData, getFilePath, tempDirectoryPath] ) const cancelDownload = useCallback( @@ -202,13 +219,14 @@ export const useFilePreviewDownload = ({ abortReferences.current[entityId]() delete abortReferences.current[entityId] } - await removePreviewFile(entityId) + await removeFile({ entityId }) }, - [removePreviewFile] + [removeFile] ) return { downloadFile, cancelDownload, + removeFile, } } diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview.tsx new file mode 100644 index 000000000..b97795688 --- /dev/null +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview.tsx @@ -0,0 +1,349 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { Query, useQuery, useQueryClient } from "@tanstack/react-query" +import { useDispatch, useSelector, useStore } from "react-redux" +import { activeDeviceIdSelector } from "active-device-registry/feature" +import { AppDispatch, ReduxRootState } from "Core/__deprecated__/renderer/store" +import { + FilesTransferMode, + selectEntityData, + selectFilesTransferMode, + sendFilesClear, + setFilesTransferMode, +} from "generic-view/store" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { EventEmitter } from "events" +import { + useFilePreviewDownload, + UseFilePreviewDownloadParams, +} from "./use-file-preview-download" +import { checkPath, getAppPath, removeDirectory } from "system-utils/feature" +import { + FilePreviewError, + FilePreviewErrorType, + isFilePreviewError, +} from "./file-preview-error-types" + +export type FilePreviewEntitiesConfig = + UseFilePreviewDownloadParams["fields"] & { + type: string + } + +const STALE_TIME = Infinity +const GC_TIME = 60 * 60 * 1000 // 1 hour + +enum QueueEvent { + Busy = "queueBusy", + Free = "queueFree", +} + +type QueryData = { + fileName: string + fileType?: string + filePath?: string + nativeFilePath?: string +} | null + +interface Params { + entitiesIds: string[] + entitiesType: string + entityId?: string + entitiesConfig: FilePreviewEntitiesConfig +} + +export const useFilePreview = ({ + entitiesIds, + entitiesType, + entityId, + entitiesConfig, +}: Params) => { + const store = useStore() + const dispatch = useDispatch() + const queryClient = useQueryClient() + + const eventEmitterRef = useRef(new EventEmitter()) + const queueBusyRef = useRef(false) + + const [tempDirectoryPath, setTempDirectoryPath] = useState() + const deviceId = useSelector(activeDeviceIdSelector) + const fileTransferMode = useSelector(selectFilesTransferMode) + + const actionId = entitiesConfig.type + "Preview" + const queryEnabled = entityId !== undefined + + const { downloadFile, cancelDownload, removeFile } = useFilePreviewDownload({ + actionId, + tempDirectoryPath, + entitiesType: entitiesConfig.type, + fields: entitiesConfig, + }) + + const ensureTempDirectory = useCallback(async () => { + const destinationPath = await getAppPath("filePreview") + if (destinationPath.ok) { + await checkPath(destinationPath.data, true) + setTempDirectoryPath(destinationPath.data) + } + }, []) + + const clearTempDirectory = useCallback(async () => { + dispatch(sendFilesClear({ groupId: actionId })) + if (tempDirectoryPath) { + await removeDirectory(tempDirectoryPath) + } + }, [actionId, dispatch, tempDirectoryPath]) + + const getBaseFileData = useCallback( + (id?: string) => { + const entity = + deviceId && id + ? selectEntityData(store.getState(), { + deviceId, + entitiesType, + entityId: id, + }) + : undefined + + return { + fileName: (entity?.[entitiesConfig.titleField] as string) || "", + fileType: (entity?.[entitiesConfig.mimeTypeField] as string) || "", + } + }, + [ + deviceId, + entitiesConfig.mimeTypeField, + entitiesConfig.titleField, + entitiesType, + store, + ] + ) + + const getNextFileId = useCallback(() => { + const currentIndex = entitiesIds.findIndex((id) => id === entityId) + const nextIndex = currentIndex + 1 + if (nextIndex < entitiesIds.length) { + return entitiesIds[nextIndex] + } + return entitiesIds[0] + }, [entitiesIds, entityId]) + + const getPrevFileId = useCallback(() => { + const currentIndex = entitiesIds.findIndex((id) => id === entityId) + const prevIndex = currentIndex - 1 + if (prevIndex >= 0) { + return entitiesIds[prevIndex] + } + return entitiesIds[entitiesIds.length - 1] + }, [entitiesIds, entityId]) + + const fileBaseData = useMemo(() => { + return getBaseFileData(entityId) + }, [entityId, getBaseFileData]) + + const queryKey = useMemo(() => { + return ["filePreview", entityId] + }, [entityId]) + + const queryFn = useCallback( + async (id?: string, signal?: AbortSignal) => { + if (!id) { + return null + } + + if (fileTransferMode !== FilesTransferMode.Mtp) { + await cancelDownload(id) + throw { + type: FilePreviewErrorType.UnsupportedTransferMode, + } + } + + // Delay request before blocking the queue, to properly handle quick switching between files + await new Promise((r) => setTimeout(r, 500)) + if (signal?.aborted) { + await cancelDownload(id) + return null + } + + eventEmitterRef.current.emit(QueueEvent.Busy) + + if (queueBusyRef.current) { + await new Promise((resolve) => { + eventEmitterRef.current.once(QueueEvent.Free, () => { + resolve() + }) + }) + if (signal?.aborted) { + await cancelDownload(id) + return null + } + } else { + queueBusyRef.current = true + } + + try { + const entityData = getBaseFileData(id) + + // Check for unsupported file types + if (entityData.fileType.toLowerCase().startsWith("image")) { + if (entityData.fileName.toLowerCase().endsWith(".heic")) { + throw { + type: FilePreviewErrorType.UnsupportedFileType, + details: "HEIC", + } satisfies FilePreviewError + } + if (entityData.fileName.toLowerCase().endsWith(".heif")) { + throw { + type: FilePreviewErrorType.UnsupportedFileType, + details: "HEIF", + } satisfies FilePreviewError + } + } + + const file = await downloadFile(id) + + if (!file) { + throw { + type: FilePreviewErrorType.FileNotFound, + } satisfies FilePreviewError + } + + return { + ...entityData, + filePath: file.path, + nativeFilePath: file.nativePath, + } + } catch (error) { + await cancelDownload(id) + if (isFilePreviewError(error)) { + throw error + } + throw { + type: FilePreviewErrorType.Unknown, + } satisfies FilePreviewError + } finally { + eventEmitterRef.current.emit(QueueEvent.Free) + queueBusyRef.current = false + } + }, + [cancelDownload, downloadFile, fileTransferMode, getBaseFileData] + ) + + const clearOutdatedFiles = useCallback( + async (ignoredFiles: string[] = []) => { + const queries = queryClient.getQueryCache().findAll({ + predicate: (query) => { + return ( + query.queryKey[0] === "filePreview" && + ![entityId, ...ignoredFiles].includes(query.queryKey[1] as string) + ) + }, + }) as Query[] + + await Promise.all( + queries.map(async (query) => { + const id = query.queryKey[1] as string + const path = query.state.data?.nativeFilePath + if (path) { + await removeFile({ path }) + } + queryClient.removeQueries({ + queryKey: ["filePreview", id], + }) + }) + ) + }, + [entityId, queryClient, removeFile] + ) + + const preloadSurroundingEntities = useCallback(async () => { + const nextId = getNextFileId() + const prevId = getPrevFileId() + + await clearOutdatedFiles([nextId, prevId]) + + await queryClient.prefetchQuery({ + queryKey: ["filePreview", nextId], + queryFn: ({ signal }) => queryFn(nextId, signal), + networkMode: "offlineFirst", + retry: false, + staleTime: STALE_TIME, + gcTime: GC_TIME, + }) + await queryClient.prefetchQuery({ + queryKey: ["filePreview", prevId], + queryFn: ({ signal }) => queryFn(prevId, signal), + networkMode: "offlineFirst", + retry: false, + staleTime: STALE_TIME, + gcTime: GC_TIME, + }) + }, [clearOutdatedFiles, getNextFileId, getPrevFileId, queryClient, queryFn]) + + const cancel = useCallback(async () => { + await queryClient.cancelQueries({ + queryKey: ["filePreview", entityId], + }) + }, [entityId, queryClient]) + + const query = useQuery({ + queryKey, + queryFn: ({ signal }) => queryFn(entityId, signal), + enabled: queryEnabled, + retry: (failureCount, error) => { + // Retry only for errors other than unsupported file type + return error.type === FilePreviewErrorType.UnsupportedFileType + ? false + : failureCount < 1 + }, + staleTime: STALE_TIME, + gcTime: GC_TIME, + retryOnMount: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + networkMode: "offlineFirst", + }) + + const refetch = useCallback(async () => { + dispatch(setFilesTransferMode(FilesTransferMode.Mtp)) + await query.refetch() + }, [dispatch, query]) + + useEffect(() => { + if (fileTransferMode !== FilesTransferMode.Mtp && entityId !== undefined) { + void cancelDownload(entityId) + } + }, [fileTransferMode, cancelDownload, entityId]) + + useEffect(() => { + if (query.isSuccess) { + void preloadSurroundingEntities() + } + }, [preloadSurroundingEntities, query.isSuccess]) + + useEffect(() => { + if (queryEnabled) { + void ensureTempDirectory() + } else { + queryClient.removeQueries({ + queryKey: ["filePreview"], + }) + void clearTempDirectory() + + eventEmitterRef.current.emit(QueueEvent.Free) + queueBusyRef.current = false + } + }, [clearTempDirectory, ensureTempDirectory, queryClient, queryEnabled]) + + return { + ...query, + ...fileBaseData, + refetch, + getNextFileId, + getPrevFileId, + cancel, + } +} diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/use-files-preview.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/use-files-preview.tsx deleted file mode 100644 index 7462d0da7..000000000 --- a/libs/generic-view/ui/src/lib/predefined/file-preview/use-files-preview.tsx +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Copyright (c) Mudita sp. z o.o. All rights reserved. - * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md - */ - -import { useDispatch } from "react-redux" -import { sendFilesClear } from "generic-view/store" -import { AppDispatch } from "Core/__deprecated__/renderer/store" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { checkPath, getAppPath, removeDirectory } from "system-utils/feature" -import { uniqBy } from "lodash" -import { - FilePreviewResponse, - useFilePreviewDownload, - UseFilePreviewDownloadParams, -} from "./use-file-preview-download" -import { Queue } from "shared/utils" -import EventEmitter from "events" - -export enum QueuePriority { - Current = 2, - Next = 1, - Prev = 0, -} - -export interface UseFilesPreviewParams { - items: string[] - activeItem?: string - entitiesConfig: UseFilePreviewDownloadParams["fields"] & { - type: string - } -} - -export const useFilesPreview = ({ - items, - activeItem, - entitiesConfig, -}: UseFilesPreviewParams) => { - const actionId = entitiesConfig.type + "Preview" - const dispatch = useDispatch() - const eventEmitterRef = useRef(new EventEmitter()) - - const [refetchTrigger, setRefetchTrigger] = useState(0) - const [isLoading, setIsLoading] = useState(false) - const [tempDirectoryPath, setTempDirectoryPath] = useState() - const [downloadedItems, setDownloadedItems] = useState( - [] - ) - const downloadedItemsDependency = JSON.stringify( - downloadedItems.map((item) => item.id) - ) - - const { downloadFile, cancelDownload } = useFilePreviewDownload({ - actionId, - tempDirectoryPath, - entitiesType: entitiesConfig.type, - fields: entitiesConfig, - }) - - const queue = useMemo(() => { - return new Queue({ interval: 100, capacity: 3 }) - }, []) - - const getNearestFiles = useCallback( - (item?: string) => { - if (!item || !items.length) { - return { previous: undefined, next: undefined } - } - const currentIndex = items.indexOf(item) - return { - previous: items[(currentIndex - 1 + items.length) % items.length], - next: items[(currentIndex + 1) % items.length], - } - }, - [items] - ) - - const nearestFiles = useMemo(() => { - return getNearestFiles(activeItem) - }, [activeItem, getNearestFiles]) - - const currentFile = useMemo(() => { - if (!activeItem) return undefined - return downloadedItems.find((item) => item.id === activeItem) - // eslint-disable-next-line - }, [activeItem, downloadedItemsDependency, refetchTrigger]) - - const getFilePriority = useCallback( - (fileId: string) => { - if (fileId === activeItem) { - return QueuePriority.Current - } - const nearest = getNearestFiles(activeItem) - if (fileId === nearest.next) { - return QueuePriority.Next - } - return QueuePriority.Prev - }, - [activeItem, getNearestFiles] - ) - - const downloadFiles = useCallback( - (filesIds: string[]) => { - for (const fileId of filesIds) { - void queue.add({ - id: fileId, - task: async () => { - const fileInfo = await downloadFile(fileId) - if (fileInfo) { - setDownloadedItems((prev) => uniqBy([...prev, fileInfo], "id")) - if (fileInfo.id === activeItem) { - setIsLoading(false) - eventEmitterRef.current.emit("currentFileDownloaded", fileId) - } - } - }, - priority: getFilePriority(fileId), - }) - } - }, - [activeItem, downloadFile, getFilePriority, queue] - ) - - useEffect(() => { - if (!currentFile?.id) return - - const filesToRemove = downloadedItems.filter((item) => { - return ( - item.id !== currentFile.id && - item.id !== nearestFiles.next && - item.id !== nearestFiles.previous - ) - }) - - if (filesToRemove.length > 0) { - for (const file of filesToRemove) { - void cancelDownload(file.id) - setDownloadedItems((prev) => prev.filter((item) => item.id !== file.id)) - } - } - // eslint-disable-next-line - }, [ - currentFile?.id, - cancelDownload, - downloadedItemsDependency, - nearestFiles.next, - nearestFiles.previous, - ]) - - useEffect(() => { - if (!activeItem) return - - const filesToDownload = [ - activeItem, - nearestFiles.next, - nearestFiles.previous, - ].filter(Boolean) as string[] - - downloadFiles(filesToDownload) - }, [ - activeItem, - nearestFiles.next, - nearestFiles.previous, - downloadFiles, - refetchTrigger, - ]) - - const ensureTempDirectory = useCallback(async () => { - const destinationPath = await getAppPath("filePreview") - if (destinationPath.ok) { - await checkPath(destinationPath.data, true) - setTempDirectoryPath(destinationPath.data) - } - }, []) - - const clearTempDirectory = useCallback(async () => { - dispatch(sendFilesClear({ groupId: actionId })) - if (tempDirectoryPath) { - await removeDirectory(tempDirectoryPath) - } - }, [actionId, dispatch, tempDirectoryPath]) - - const refetch = useCallback(() => { - return new Promise((resolve) => { - setIsLoading(true) - setRefetchTrigger((prev) => prev + 1) - eventEmitterRef.current.on("currentFileDownloaded", (fileId: string) => { - if (fileId === activeItem) { - resolve() - } - }) - }) - }, [activeItem]) - - useEffect(() => { - if (!activeItem) { - void clearTempDirectory() - setDownloadedItems([]) - } - }, [activeItem, clearTempDirectory]) - - useEffect(() => { - const emitter = eventEmitterRef.current - void ensureTempDirectory() - return () => { - queue.clear() - emitter.removeAllListeners() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return { - data: currentFile, - nextId: nearestFiles.next, - previousId: nearestFiles.previous, - refetch, - isLoading, - } -} - -// Comment for rebuild trigger diff --git a/libs/generic-view/ui/src/lib/table/table.tsx b/libs/generic-view/ui/src/lib/table/table.tsx index dbf25de88..60d6d9dfe 100644 --- a/libs/generic-view/ui/src/lib/table/table.tsx +++ b/libs/generic-view/ui/src/lib/table/table.tsx @@ -48,6 +48,8 @@ export const Table: APIFC & { -1, -1, ]) + const [previewOpened, setPreviewOpened] = useState(false) + const [initialPreviewId, setInitialPreviewId] = useState() const [activePreviewId, setActivePreviewId] = useState() const nextActivePreviewId = useRef(undefined) const { activeIdFieldName } = formOptions || {} @@ -63,7 +65,9 @@ export const Table: APIFC & { const onRowClick = useCallback( (id: string) => { if (previewOptions?.enabled) { - handleActivePreviewIdChange(id) + // handleActivePreviewIdChange(id) + setInitialPreviewId(id) + setPreviewOpened(true) return } if (activeIdFieldName) { @@ -108,15 +112,14 @@ export const Table: APIFC & { } }, [activePreviewId]) - const handleActivePreviewIdChange = useCallback( - (id?: string) => { - setActivePreviewId(id) - if (id) { - nextActivePreviewId.current = data[(data.indexOf(id) + 1) % data.length] - } - }, - [data] - ) + const handleActivePreviewIdChange = useCallback((id?: string) => { + setActivePreviewId(id) + }, []) + + const handlePreviewClose = useCallback(() => { + setPreviewOpened(false) + setInitialPreviewId(undefined) + }, []) useEffect(() => { if (activePreviewId !== undefined) { @@ -137,12 +140,6 @@ export const Table: APIFC & { scrollToPreviewActiveItem, ]) - useEffect(() => { - if (activePreviewId && !data.includes(activePreviewId)) { - handleActivePreviewIdChange(nextActivePreviewId.current) - } - }, [activePreviewId, data, handleActivePreviewIdChange]) - useEffect(() => { if (activeRowId) { scrollToActiveItem() @@ -286,8 +283,10 @@ export const Table: APIFC & { return ( & { }} /> ) - }, [activePreviewId, data, handleActivePreviewIdChange, previewOptions]) + }, [ + data, + handleActivePreviewIdChange, + handlePreviewClose, + initialPreviewId, + previewOpened, + previewOptions, + ]) return useMemo( () => ( diff --git a/libs/generic-view/utils/src/lib/models/api-fc.types.ts b/libs/generic-view/utils/src/lib/models/api-fc.types.ts index 282b43925..d1d970ef4 100644 --- a/libs/generic-view/utils/src/lib/models/api-fc.types.ts +++ b/libs/generic-view/utils/src/lib/models/api-fc.types.ts @@ -11,7 +11,7 @@ import { } from "react" type DefaultProps = Partial< - Pick["props"], "className" | "style"> + Pick["props"], "className" | "style" | "id"> > & { // eslint-disable-next-line @typescript-eslint/no-explicit-any componentRef?: Ref diff --git a/package-lock.json b/package-lock.json index 4114543a7..badc34414 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "dependencies": { "@contentful/rich-text-plain-text-renderer": "^16.2.6", "@orama/orama": "^2.0.22", + "@tanstack/react-query": "^5.85.5", + "@tanstack/react-query-devtools": "^5.85.5", "@types/w3c-web-usb": "^1.0.10", "@vscode/sudo-prompt": "^9.3.1", "@wdio/json-reporter": "^8.40.3", @@ -9422,6 +9424,55 @@ "node": ">=10" } }, + "node_modules/@tanstack/query-core": { + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.5.tgz", + "integrity": "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.84.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz", + "integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.5.tgz", + "integrity": "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A==", + "dependencies": { + "@tanstack/query-core": "5.85.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.85.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.5.tgz", + "integrity": "sha512-6Ol6Q+LxrCZlQR4NoI5181r+ptTwnlPG2t7H9Sp3klxTBhYGunONqcgBn2YKRPsaKiYM8pItpKMdMXMEINntMQ==", + "dependencies": { + "@tanstack/query-devtools": "5.84.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.85.5", + "react": "^18 || ^19" + } + }, "node_modules/@testim/chrome-version": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@testim/chrome-version/-/chrome-version-1.1.4.tgz", diff --git a/package.json b/package.json index c810f284c..86c830913 100644 --- a/package.json +++ b/package.json @@ -283,6 +283,8 @@ "dependencies": { "@contentful/rich-text-plain-text-renderer": "^16.2.6", "@orama/orama": "^2.0.22", + "@tanstack/react-query": "^5.85.5", + "@tanstack/react-query-devtools": "^5.85.5", "@types/w3c-web-usb": "^1.0.10", "@vscode/sudo-prompt": "^9.3.1", "@wdio/json-reporter": "^8.40.3",