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