[CP-3663] Refactored image preview (#2638)

Co-authored-by: slawomir-werner <slawomir.werner@mudita.com>
This commit is contained in:
Michał Kurczewski
2025-09-01 09:33:06 +02:00
committed by GitHub
parent f5bf7291ba
commit 4ab382ec6f
16 changed files with 796 additions and 569 deletions

View File

@@ -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.",

View File

@@ -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 (
<ThemeProvider theme={theme}>
<IntlProvider
defaultLocale={translationConfig.defaultLanguage}
locale={translationConfig.defaultLanguage}
messages={localeEn}
>
<ModalProvider service={modalService}>
<Normalize />
<GlobalStyle />
<AppsSwitch />
</ModalProvider>
</IntlProvider>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<ThemeProvider theme={theme}>
<IntlProvider
defaultLocale={translationConfig.defaultLanguage}
locale={translationConfig.defaultLanguage}
messages={localeEn}
>
<ModalProvider service={modalService}>
<Normalize />
<GlobalStyle />
<AppsSwitch />
</ModalProvider>
</IntlProvider>
</ThemeProvider>
</QueryClientProvider>
)
}

View File

@@ -22,10 +22,10 @@ export const generateFilesExportButtonActions = (
{
singleEntityId,
entityType,
exportActionId,
exportActionId = entityType + "Export",
}: Pick<McFileManagerConfig["categories"][number], "entityType"> & {
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",

View File

@@ -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",

View File

@@ -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
)
)
}

View File

@@ -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

View File

@@ -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<Props> = 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<string>()
({
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<FilePreviewError>()
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 (
<Modal
componentKey={"file-preview-modal"}
config={{
defaultOpened: Boolean(activeItem),
defaultOpened: opened,
width: 960,
maxHeight: 640,
padding: 0,
@@ -177,8 +175,8 @@ export const FilePreview: FunctionComponent<Props> = memo(
>
<ModalContent>
<Header>
<Typography.P1 config={{ color: "white" }}>
{entityName}
<Typography.P1 config={{ color: "white" }} id={"file-preview-name"}>
{fileName}
</Typography.P1>
<IconButton onClick={handleClose}>
<Icon
@@ -191,94 +189,80 @@ export const FilePreview: FunctionComponent<Props> = memo(
</IconButton>
</Header>
<Main>
<AnimatePresence initial={true} mode={"wait"}>
{!error && (
<Loader
<AnimatePresence initial={true} mode={"popLayout"}>
{hasError ? (
<ErrorWrapper
key={"error"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<SpinnerLoader />
</Loader>
)}
</AnimatePresence>
<AnimatePresence initial={true} mode={"wait"}>
{isFinished && (
<ErrorIcon
config={{
type: IconType.Exclamation,
size: "large",
color: "white",
}}
/>
{error?.type === FilePreviewErrorType.UnsupportedFileType ? (
<>
<Typography.P1>
{intl.formatMessage(messages.unsupportedFileType, {
type: error.details,
})}
</Typography.P1>
<ButtonSecondary
config={{
text: intl.formatMessage(messages.backButton),
actions: [
{
type: "custom",
callback: handleClose,
},
],
}}
/>
</>
) : (
<>
<Typography.P1>
{intl.formatMessage(messages.unknownError)}
</Typography.P1>
<ButtonSecondary
config={{
text: intl.formatMessage(messages.retryButton),
actions: [
{
type: "custom",
callback: handleRetry,
},
],
}}
/>
</>
)}
</ErrorWrapper>
) : inProgress ? (
<FilePreviewLoader />
) : (
<PreviewWrapper
key={fileInfo?.path}
key={data?.filePath}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
{entityType.startsWith("image") && (
{data?.fileType?.startsWith("image") && (
<ImagePreview
src={fileInfo?.path}
fileUid={fileUid}
onError={setError}
src={
data.filePath
? `${data.filePath}?t=${dataUpdatedAt}`
: ""
}
onError={handlePreviewError}
/>
)}
<AnimatePresence initial={true} mode={"wait"}>
{Boolean(error) && (
<ErrorWrapper
key={fileInfo?.path}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<ErrorIcon
config={{
type: IconType.Exclamation,
size: "large",
color: "white",
}}
/>
{error?.type ===
FilePreviewErrorType.UnsupportedFileType ? (
<>
<Typography.P1>
{intl.formatMessage(
messages.unsupportedFileType,
{
type: error.details,
}
)}
</Typography.P1>
<ButtonSecondary
config={{
text: intl.formatMessage(messages.backButton),
actions: [
{
type: "custom",
callback: handleClose,
},
],
}}
/>
</>
) : (
<>
<Typography.P1>
{intl.formatMessage(messages.unknownError)}
</Typography.P1>
<ButtonSecondary
config={{
text: intl.formatMessage(messages.retryButton),
actions: [
{
type: "custom",
callback: handleRetry,
},
],
}}
/>
</>
)}
</ErrorWrapper>
)}
</AnimatePresence>
</PreviewWrapper>
)}
</AnimatePresence>
@@ -312,8 +296,7 @@ export const FilePreview: FunctionComponent<Props> = memo(
iconSize: "large",
actions: [
...generateFilesExportButtonActions(componentKey, {
exportActionId: "previewExport",
singleEntityId: activeItem,
singleEntityId: currentItemId,
entityType: entitiesConfig.type,
}),
],
@@ -324,7 +307,7 @@ export const FilePreview: FunctionComponent<Props> = 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};
`

View File

@@ -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<Props> = ({
src,
fileUid,
onError,
}) => {
const loadedTimeoutRef = useRef<NodeJS.Timeout>()
export const ImagePreview: FunctionComponent<Props> = ({ src, onError }) => {
const imgRef = useRef<HTMLImageElement | null>(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 (
<Wrapper $loaded={loaded}>
<BackgroundImage style={{ backgroundImage: `url("${uniqueSrc}")` }} />
<MainImage
key={src}
src={uniqueSrc}
alt={""}
onLoad={handleLoad}
onError={handleError}
/>
</Wrapper>
<>
<Wrapper $loaded={loaded}>
<BackgroundImage style={{ backgroundImage: `url("${src}")` }} />
<MainImage
ref={imgRef}
key={src}
src={src}
alt={""}
aria-labelledby={"file-preview-name"}
onLoad={onLoad}
onError={onError}
decoding={"async"}
/>
</Wrapper>
<AnimatePresence initial={true} mode={"popLayout"}>
{!loaded && <FilePreviewLoader />}
</AnimatePresence>
</>
)
}

View File

@@ -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 (
<FilePreviewLoaderWrapper
key="loader"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
<SpinnerLoader />
</FilePreviewLoaderWrapper>
)
}

View File

@@ -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<FilePreviewResponse | undefined> => {
async (entityId: string): Promise<FilePreviewResponse | undefined> => {
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<ReturnType<typeof sendFiles>>
@@ -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,
}
}

View File

@@ -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<ReduxRootState>()
const dispatch = useDispatch<AppDispatch>()
const queryClient = useQueryClient()
const eventEmitterRef = useRef(new EventEmitter())
const queueBusyRef = useRef(false)
const [tempDirectoryPath, setTempDirectoryPath] = useState<string>()
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<void>((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<QueryData>[]
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<QueryData, FilePreviewError>({
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,
}
}

View File

@@ -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<AppDispatch>()
const eventEmitterRef = useRef(new EventEmitter())
const [refetchTrigger, setRefetchTrigger] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [tempDirectoryPath, setTempDirectoryPath] = useState<string>()
const [downloadedItems, setDownloadedItems] = useState<FilePreviewResponse[]>(
[]
)
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<void>((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

View File

@@ -48,6 +48,8 @@ export const Table: APIFC<TableData, TableConfig> & {
-1, -1,
])
const [previewOpened, setPreviewOpened] = useState(false)
const [initialPreviewId, setInitialPreviewId] = useState<string>()
const [activePreviewId, setActivePreviewId] = useState<string>()
const nextActivePreviewId = useRef<string | undefined>(undefined)
const { activeIdFieldName } = formOptions || {}
@@ -63,7 +65,9 @@ export const Table: APIFC<TableData, TableConfig> & {
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<TableData, TableConfig> & {
}
}, [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<TableData, TableConfig> & {
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<TableData, TableConfig> & {
return (
<FilePreview
items={data}
activeItem={activePreviewId}
initialItem={initialPreviewId}
opened={previewOpened}
onActiveItemChange={handleActivePreviewIdChange}
onClose={handlePreviewClose}
componentKey={previewOptions.componentKey}
entitiesConfig={{
type: previewOptions.entitiesType,
@@ -299,7 +298,14 @@ export const Table: APIFC<TableData, TableConfig> & {
}}
/>
)
}, [activePreviewId, data, handleActivePreviewIdChange, previewOptions])
}, [
data,
handleActivePreviewIdChange,
handlePreviewClose,
initialPreviewId,
previewOpened,
previewOptions,
])
return useMemo(
() => (

View File

@@ -11,7 +11,7 @@ import {
} from "react"
type DefaultProps = Partial<
Pick<ReactHTMLElement<HTMLElement>["props"], "className" | "style">
Pick<ReactHTMLElement<HTMLElement>["props"], "className" | "style" | "id">
> & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
componentRef?: Ref<any>

51
package-lock.json generated
View File

@@ -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",

View File

@@ -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",