diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 32d3d382bd..9abf0b80aa 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -424,10 +424,11 @@ "uploads": "Uploads", "deleteSelection": "Delete Selection", "downloadSelection": "Download Selection", - "preparingDownload": "Preparing Download", - "preparingDownloadFailed": "Problem Preparing Download", - "bulkDownloadStarting": "Beginning Download", - "bulkDownloadFailed": "Problem Preparing Download", + "bulkDownloadRequested": "Preparing Download", + "bulkDownloadRequestedDesc": "Your download request is being prepared. This may take a few moments.", + "bulkDownloadRequestFailed": "Problem Preparing Download", + "bulkDownloadStarting": "Download Starting", + "bulkDownloadFailed": "Download Failed", "problemDeletingImages": "Problem Deleting Images", "problemDeletingImagesDesc": "One or more images could not be deleted" }, diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 07d9bb5df5..23e23c1140 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -1,5 +1,6 @@ import type { ListenerEffect, TypedAddListener, TypedStartListening, UnknownAction } from '@reduxjs/toolkit'; import { addListener, createListenerMiddleware } from '@reduxjs/toolkit'; +import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload'; import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; import type { AppDispatch, RootState } from 'app/store/store'; @@ -48,8 +49,6 @@ import { addInitialImageSelectedListener } from './listeners/initialImageSelecte import { addModelSelectedListener } from './listeners/modelSelected'; import { addModelsLoadedListener } from './listeners/modelsLoaded'; import { addDynamicPromptsListener } from './listeners/promptChanged'; -import { addBulkDownloadCompleteEventListener } from './listeners/socketio/socketBulkDownloadComplete'; -import { addBulkDownloadFailedEventListener } from './listeners/socketio/socketBulkDownloadFailed'; import { addSocketConnectedEventListener as addSocketConnectedListener } from './listeners/socketio/socketConnected'; import { addSocketDisconnectedEventListener as addSocketDisconnectedListener } from './listeners/socketio/socketDisconnected'; import { addGeneratorProgressEventListener as addGeneratorProgressListener } from './listeners/socketio/socketGeneratorProgress'; @@ -139,8 +138,7 @@ addModelLoadEventListener(); addSessionRetrievalErrorEventListener(); addInvocationRetrievalErrorEventListener(); addSocketQueueItemStatusChangedEventListener(); -addBulkDownloadCompleteEventListener(); -addBulkDownloadFailedEventListener(); +addBulkDownloadListeners(); // ControlNet addControlNetImageProcessedListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.ts new file mode 100644 index 0000000000..39d7e574c2 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.ts @@ -0,0 +1,118 @@ +import type { UseToastOptions } from '@invoke-ai/ui-library'; +import { createStandaloneToast, theme, TOAST_OPTIONS } from '@invoke-ai/ui-library'; +import { logger } from 'app/logging/logger'; +import { startAppListening } from 'app/store/middleware/listenerMiddleware'; +import { t } from 'i18next'; +import { imagesApi } from 'services/api/endpoints/images'; +import { + socketBulkDownloadCompleted, + socketBulkDownloadFailed, + socketBulkDownloadStarted, +} from 'services/events/actions'; + +const log = logger('images'); + +const { toast } = createStandaloneToast({ + theme: theme, + defaultOptions: TOAST_OPTIONS.defaultOptions, +}); + +export const addBulkDownloadListeners = () => { + startAppListening({ + matcher: imagesApi.endpoints.bulkDownloadImages.matchFulfilled, + effect: async (action) => { + log.debug(action.payload, 'Bulk download requested'); + + // If we have an item name, we are processing the bulk download locally and should use it as the toast id to + // prevent multiple toasts for the same item. + toast({ + id: action.payload.bulk_download_item_name ?? undefined, + title: t('gallery.bulkDownloadRequested'), + status: 'success', + // Show the response message if it exists, otherwise show the default message + description: action.payload.response || t('gallery.bulkDownloadRequestedDesc'), + duration: null, + isClosable: true, + }); + }, + }); + + startAppListening({ + matcher: imagesApi.endpoints.bulkDownloadImages.matchRejected, + effect: async () => { + log.debug('Bulk download request failed'); + + // There isn't any toast to update if we get this event. + toast({ + title: t('gallery.bulkDownloadRequestFailed'), + status: 'success', + isClosable: true, + }); + }, + }); + + startAppListening({ + actionCreator: socketBulkDownloadStarted, + effect: async (action) => { + // This should always happen immediately after the bulk download request, so we don't need to show a toast here. + log.debug(action.payload.data, 'Bulk download preparation started'); + }, + }); + + startAppListening({ + actionCreator: socketBulkDownloadCompleted, + effect: async (action) => { + log.debug(action.payload.data, 'Bulk download preparation completed'); + + const { bulk_download_item_name } = action.payload.data; + + // TODO(psyche): This URL may break in in some environments (e.g. Nvidia workbench) but we need to test it first + const url = `/api/v1/images/download/${bulk_download_item_name}`; + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = bulk_download_item_name; + document.body.appendChild(a); + a.click(); + + const toastOptions: UseToastOptions = { + id: bulk_download_item_name, + title: t('gallery.bulkDownloadStarting'), + status: 'success', + description: bulk_download_item_name, + duration: 5000, + isClosable: true, + }; + + if (toast.isActive(bulk_download_item_name)) { + toast.update(bulk_download_item_name, toastOptions); + } else { + toast(toastOptions); + } + }, + }); + + startAppListening({ + actionCreator: socketBulkDownloadFailed, + effect: async (action) => { + log.debug(action.payload.data, 'Bulk download preparation failed'); + + const { bulk_download_item_name } = action.payload.data; + + const toastOptions: UseToastOptions = { + id: bulk_download_item_name, + title: t('gallery.bulkDownloadFailed'), + status: 'error', + description: action.payload.data.error, + duration: null, + isClosable: true, + }; + + if (toast.isActive(bulk_download_item_name)) { + toast.update(bulk_download_item_name, toastOptions); + } else { + toast(toastOptions); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketBulkDownloadComplete.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketBulkDownloadComplete.ts deleted file mode 100644 index acdb61ff25..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketBulkDownloadComplete.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { logger } from 'app/logging/logger'; -import { addToast } from 'features/system/store/systemSlice'; -import { t } from 'i18next'; -import { socketBulkDownloadCompleted } from 'services/events/actions'; - -import { startAppListening } from '../..'; - -const log = logger('socketio'); - -export const addBulkDownloadCompleteEventListener = () => { - startAppListening({ - actionCreator: socketBulkDownloadCompleted, - effect: async (action, { dispatch }) => { - log.debug(action.payload, 'Bulk download complete'); - - const bulk_download_item_name = action.payload.data.bulk_download_item_name; - - const url = `/api/v1/images/download/${bulk_download_item_name}`; - const a = document.createElement('a'); - a.style.display = 'none'; - a.href = url; - a.download = bulk_download_item_name; - document.body.appendChild(a); - a.click(); - - dispatch( - addToast({ - title: t('gallery.bulkDownloadStarting'), - status: 'success', - ...(action.payload - ? { - description: bulk_download_item_name, - duration: null, - isClosable: true, - } - : {}), - }) - ); - }, - }); -}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketBulkDownloadFailed.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketBulkDownloadFailed.ts deleted file mode 100644 index a9a45c42ae..0000000000 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketBulkDownloadFailed.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { logger } from 'app/logging/logger'; -import { addToast } from 'features/system/store/systemSlice'; -import { t } from 'i18next'; -import { socketBulkDownloadFailed } from 'services/events/actions'; - -import { startAppListening } from '../..'; - -const log = logger('socketio'); - -export const addBulkDownloadFailedEventListener = () => { - startAppListening({ - actionCreator: socketBulkDownloadFailed, - effect: async (action, { dispatch }) => { - log.debug(action.payload, 'Bulk download error'); - - - dispatch( - addToast({ - title: t('gallery.bulkDownloadFailed'), - status: 'error', - ...(action.payload - ? { - description: action.payload.data.error, - duration: null, - isClosable: true, - } - : {}), - }) - ); - }, - }); -}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx index 490e8eac9e..ad6c37532e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx @@ -5,7 +5,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { autoAddBoardIdChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import type { BoardId } from 'features/gallery/store/types'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { addToast } from 'features/system/store/systemSlice'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiDownloadBold, PiPlusBold } from 'react-icons/pi'; @@ -41,35 +40,9 @@ const BoardContextMenu = ({ board, board_id, setBoardToDelete, children }: Props dispatch(autoAddBoardIdChanged(board_id)); }, [board_id, dispatch]); - const handleBulkDownload = useCallback(async () => { - try { - const response = await bulkDownload({ - image_names: [], - board_id: board_id, - }).unwrap(); - - dispatch( - addToast({ - title: t('gallery.preparingDownload'), - status: 'success', - ...(response.response - ? { - description: response.response, - duration: null, - isClosable: true, - } - : {}), - }) - ); - } catch { - dispatch( - addToast({ - title: t('gallery.preparingDownloadFailed'), - status: 'error', - }) - ); - } - }, [t, board_id, bulkDownload, dispatch]); + const handleBulkDownload = useCallback(() => { + bulkDownload({ image_names: [], board_id: board_id }); + }, [board_id, bulkDownload]); const renderMenuFunc = useCallback( () => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx index e8f71c02f3..7b1fa73472 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx @@ -5,7 +5,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice'; import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { addToast } from 'features/system/store/systemSlice'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiDownloadSimpleBold, PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi'; @@ -44,34 +43,9 @@ const MultipleSelectionMenuItems = () => { unstarImages({ imageDTOs: selection }); }, [unstarImages, selection]); - const handleBulkDownload = useCallback(async () => { - try { - const response = await bulkDownload({ - image_names: selection.map((img) => img.image_name), - }).unwrap(); - - dispatch( - addToast({ - title: t('gallery.preparingDownload'), - status: 'success', - ...(response.response - ? { - description: response.response, - duration: null, - isClosable: true, - } - : {}), - }) - ); - } catch { - dispatch( - addToast({ - title: t('gallery.preparingDownloadFailed'), - status: 'error', - }) - ); - } - }, [t, selection, bulkDownload, dispatch]); + const handleBulkDownload = useCallback(() => { + bulkDownload({ image_names: selection.map((img) => img.image_name) }); + }, [selection, bulkDownload]); const areAllStarred = useMemo(() => { return selection.every((img) => img.starred);