diff --git a/src/App.tsx b/src/App.tsx index f0d4f00a..26155fe4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { AuthGuard } from '@/features/authentication/components/AuthGuard.tsx'; import { SearchParam } from '@/base/Base.types.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { ReactRouter } from '@/lib/react-router/ReactRouter.ts'; +import { GlobalDialog } from '@/base/global-dialog/GlobalDialog.tsx'; const { Browse } = loadable(() => import('@/features/browse/screens/Browse.tsx'), lazyLoadFallback); const { DownloadQueue } = loadable(() => import('@/features/downloads/screens/DownloadQueue.tsx'), lazyLoadFallback); @@ -295,12 +296,17 @@ const ReaderApp = () => ( export const App: React.FC = () => ( + + + + + diff --git a/src/UtilTypes.d.ts b/src/UtilTypes.d.ts index 82303bf5..a4484f95 100644 --- a/src/UtilTypes.d.ts +++ b/src/UtilTypes.d.ts @@ -53,3 +53,13 @@ type OmitNotMatching = { type ExtractCommon = { [K in keyof T & keyof U]: T[K] extends U[K] ? T[K] : never; }; + +type HasRequiredKeys = + Exclude< + { + [K in keyof T]: T extends Record ? K : never; + }[keyof T], + undefined + > extends never + ? false + : true; diff --git a/src/base/components/feedback/SnackbarWithDescription.tsx b/src/base/components/feedback/SnackbarWithDescription.tsx index baaf0aec..75e0328c 100644 --- a/src/base/components/feedback/SnackbarWithDescription.tsx +++ b/src/base/components/feedback/SnackbarWithDescription.tsx @@ -13,11 +13,11 @@ import AlertTitle from '@mui/material/AlertTitle'; import Button from '@mui/material/Button'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@mui/material/styles'; -import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { MediaQuery } from '@/base/utils/MediaQuery.tsx'; import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts'; import { TranslationKey } from '@/base/Base.types.ts'; +import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx'; const MAX_DESCRIPTION_LENGTH = 200; @@ -83,7 +83,7 @@ export const SnackbarWithDescription = memo( {isDescriptionTooLong || (isGraphqlException && graphqlStackTrace) ? ( )} diff --git a/src/base/global-dialog/GlobalDialog.tsx b/src/base/global-dialog/GlobalDialog.tsx new file mode 100644 index 00000000..d2dfd554 --- /dev/null +++ b/src/base/global-dialog/GlobalDialog.tsx @@ -0,0 +1,15 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { useSyncExternalStore } from 'react'; +import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx'; + +export const GlobalDialog = () => + useSyncExternalStore(GlobalDialogManager.subscribe.bind(GlobalDialogManager), () => + GlobalDialogManager.getActive(), + ); diff --git a/src/base/global-dialog/GlobalDialogManager.tsx b/src/base/global-dialog/GlobalDialogManager.tsx new file mode 100644 index 00000000..a7448dee --- /dev/null +++ b/src/base/global-dialog/GlobalDialogManager.tsx @@ -0,0 +1,140 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { ComponentProps, ComponentType, ReactNode } from 'react'; +import { ControlledPromise } from '@/lib/ControlledPromise.ts'; +import { ConfirmDialog } from '@/base/components/modals/ConfirmDialog.tsx'; + +type Id = string; + +export interface DialogProps { + onCancel: () => void; + onConfirm: (value: T) => void; +} + +type DialogPropsParam = Omit; + +type ExtractDialogReturnType = T extends DialogProps ? U : never; + +export class GlobalDialogManager { + private static subscriberCounter = 0; + + private static queue = new Set(); + + private static dialogs = new Map(); + + private static subscribers = new Map void>(); + + private static getNextId = (): Id => { + this.subscriberCounter = (this.subscriberCounter + 1) % Number.MAX_SAFE_INTEGER; + + return this.subscriberCounter.toString(); + }; + + private static notify(): void { + this.subscribers.forEach((callback) => { + callback(); + }); + } + + private static add(id: Id, dialog: ReactNode): void { + this.queue.delete(id); + this.queue.add(id); + this.dialogs.set(id, dialog); + + const showDialog = !(this.dialogs.size - 1); + if (showDialog) { + this.notify(); + } + } + + private static remove(id: Id): void { + this.queue.delete(id); + this.dialogs.delete(id); + this.notify(); + } + + private static unsubscribe(key: Id): void { + this.subscribers.delete(key); + } + + static subscribe(callback: () => void): () => void { + const key = this.getNextId(); + this.subscribers.set(key, callback); + + return () => this.unsubscribe(key); + } + + static getActive(): ReactNode { + const id = [...this.queue].slice(-1)[0]; + + return this.dialogs.get(id); + } + + static async show( + Dialog: ComponentType, + ...args: HasRequiredKeys> extends true + ? [props: DialogPropsParam] + : [props?: DialogPropsParam] + ): Promise>; + static async show( + id: Id, + Dialog: ComponentType, + ...args: HasRequiredKeys> extends true + ? [props: DialogPropsParam] + : [props?: DialogPropsParam] + ): Promise>; + static async show( + idOrDialog: Id | ComponentType, + dialogOrProps?: ComponentType | DialogPropsParam, + propsOrNothing?: DialogPropsParam | never, + ): Promise> { + const dialogPromise = new ControlledPromise>(); + + const wasIdPassed = typeof idOrDialog === 'string'; + const id = wasIdPassed ? idOrDialog : this.getNextId(); + const Dialog = (wasIdPassed ? dialogOrProps : idOrDialog) as ComponentType; + const props = (wasIdPassed ? propsOrNothing : dialogOrProps) as DialogPropsParam | undefined; + + const dialog = ( + dialogPromise.reject(new Error('Dialog was cancelled')), + onConfirm: dialogPromise.resolve.bind(dialogPromise), + } as unknown as AllProps)} + /> + ); + + this.add(id, dialog); + + try { + return await dialogPromise.promise; + } finally { + this.remove(id); + } + } + + static confirm( + props: DialogPropsParam>, + ): Promise>>; + static confirm( + id: Id, + props: DialogPropsParam>, + ): Promise>>; + static confirm( + idOrProps: Id | DialogPropsParam>, + propsOrNothing?: DialogPropsParam>, + ): Promise>> { + if (typeof idOrProps === 'string') { + return this.show(idOrProps, ConfirmDialog, propsOrNothing!); + } + + return this.show(ConfirmDialog, idOrProps); + } +} diff --git a/src/base/utils/AwaitableDialog.tsx b/src/base/utils/AwaitableDialog.tsx deleted file mode 100644 index b5e8b2ce..00000000 --- a/src/base/utils/AwaitableDialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (C) Contributors to the Suwayomi project - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import { createRoot } from 'react-dom/client'; -import { ThemeProvider } from '@mui/material/styles'; -import { ConfirmDialog } from '@/base/components/modals/ConfirmDialog.tsx'; -import { ControlledPromise } from '@/lib/ControlledPromise.ts'; -import { getCurrentTheme } from '@/features/theme/services/ThemeCreator.ts'; - -export const awaitConfirmation = async ( - dialogProps: Omit, 'onCancel' | 'onConfirm'>, -) => { - const dialogContainer = document.createElement('div'); - document.body.appendChild(dialogContainer); - - const root = createRoot(dialogContainer); - - const confirmationPromise = new ControlledPromise(); - const handleConfirmation = (accepted: boolean) => { - if (accepted) { - confirmationPromise.resolve(); - } else { - confirmationPromise.reject(new Error('Confirmation declined')); - } - - root.unmount(); - document.body.removeChild(dialogContainer); - }; - - root.render( - - { - handleConfirmation(false); - dialogProps.onExtra?.(); - }} - onCancel={() => handleConfirmation(false)} - onConfirm={() => handleConfirmation(true)} - /> - , - ); - - return confirmationPromise.promise; -}; diff --git a/src/features/chapter/services/Chapters.ts b/src/features/chapter/services/Chapters.ts index 9fcbdd50..ce734320 100644 --- a/src/features/chapter/services/Chapters.ts +++ b/src/features/chapter/services/Chapters.ts @@ -41,8 +41,8 @@ import { ChapterSourceOrderInfo, } from '@/features/chapter/Chapter.types.ts'; import { assertIsDefined } from '@/base/Asserts.ts'; -import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx'; import { DirectionOffset } from '@/base/Base.types.ts'; +import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx'; export class Chapters { static getIds(chapters: { id: number }[]): number[] { @@ -275,7 +275,7 @@ export class Chapters { assertIsDefined(confirmationMessage); try { - await awaitConfirmation({ + await GlobalDialogManager.confirm({ title: translate('global.label.are_you_sure'), message: translate(confirmationMessage, { count: itemCount }), actions: { diff --git a/src/features/manga/hooks/useManageMangaLibraryState.tsx b/src/features/manga/hooks/useManageMangaLibraryState.tsx index 727cb2dd..29e26de5 100644 --- a/src/features/manga/hooks/useManageMangaLibraryState.tsx +++ b/src/features/manga/hooks/useManageMangaLibraryState.tsx @@ -17,11 +17,11 @@ import { getMetadataServerSettings } from '@/features/settings/services/ServerSe import { Categories } from '@/features/category/services/Categories.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { Mangas } from '@/features/manga/services/Mangas.ts'; -import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx'; import { GetCategoriesBaseQuery, GetCategoriesBaseQueryVariables, MangaType } from '@/lib/graphql/generated/graphql.ts'; import { GET_CATEGORIES_BASE } from '@/lib/graphql/queries/CategoryQuery.ts'; import { AppRoutes } from '@/base/AppRoute.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; +import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx'; export const useManageMangaLibraryState = ( manga: Pick & Partial>, @@ -54,7 +54,7 @@ export const useManageMangaLibraryState = ( const removeFromLibrary = useCallback(async () => { if (confirmRemoval) { - await awaitConfirmation({ + await GlobalDialogManager.confirm(`manga-library-state-remove-${manga.id}`, { title: t('global.label.are_you_sure'), message: t('manga.action.library.remove.dialog.label.message', { title: manga.title }), actions: { @@ -113,7 +113,7 @@ export const useManageMangaLibraryState = ( try { duplicatedLibraryMangas = await Mangas.getDuplicateLibraryMangas(manga.title).response; } catch (e) { - await awaitConfirmation({ + await GlobalDialogManager.confirm(`manga-library-state-add-${manga.id}`, { title: t('global.error.label.failed_to_load_data'), message: t('manga.action.library.add.dialog.duplicate.label.failure', { error: getErrorMessage(e), @@ -131,7 +131,7 @@ export const useManageMangaLibraryState = ( const doDuplicatesExist = duplicatedLibraryMangas?.data.mangas.totalCount; if (doDuplicatesExist) { - await awaitConfirmation({ + await GlobalDialogManager.confirm(`manga-library-state-add-duplicated-${manga.id}`, { title: t('global.label.are_you_sure'), message: t('manga.action.library.add.dialog.duplicate.label.info'), actions: { diff --git a/src/features/manga/services/Mangas.ts b/src/features/manga/services/Mangas.ts index 12ae8e43..11f32a0c 100644 --- a/src/features/manga/services/Mangas.ts +++ b/src/features/manga/services/Mangas.ts @@ -49,8 +49,8 @@ import { SOURCES_BY_MANGA_TYPE, } from '@/features/manga/Manga.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; -import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx'; import { assertIsDefined } from '@/base/Asserts.ts'; +import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx'; type MangaToMigrate = NonNullable; type MangaToMigrateTo = NonNullable['manga']; @@ -553,7 +553,7 @@ export class Mangas { assertIsDefined(confirmationMessage); try { - await awaitConfirmation({ + await GlobalDialogManager.confirm({ title: translate('global.label.are_you_sure'), message: translate(confirmationMessage, { count: itemCount }), actions: { diff --git a/src/features/reader/services/ReaderControls.ts b/src/features/reader/services/ReaderControls.ts index 3dae0b90..2e7e5818 100644 --- a/src/features/reader/services/ReaderControls.ts +++ b/src/features/reader/services/ReaderControls.ts @@ -45,7 +45,6 @@ import { import { Chapters } from '@/features/chapter/services/Chapters.ts'; import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts'; import { ChapterIdInfo, TChapterReader } from '@/features/chapter/Chapter.types.ts'; -import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { TReaderProgressCurrentPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts'; import { @@ -55,6 +54,7 @@ import { getReaderSettingsStore, getReaderTapZoneStore, } from '@/features/reader/stores/ReaderStore.ts'; +import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx'; const getScrollDirectionInvert = ( scrollDirection: ScrollDirection, @@ -304,7 +304,7 @@ export class ReaderControls { const warningLineBreak = !isSameScanlator && !isContinuousChapter ? '\n\n' : ''; const warning = `${sameScanlator}${warningLineBreak}${continuousChapter}`; - await awaitConfirmation({ + await GlobalDialogManager.confirm({ title: translate('reader.chapter_transition.warning.title'), message: warning, actions: { diff --git a/src/features/source/browse/components/SourceOptions.tsx b/src/features/source/browse/components/SourceOptions.tsx index f70f3695..39e10fb2 100644 --- a/src/features/source/browse/components/SourceOptions.tsx +++ b/src/features/source/browse/components/SourceOptions.tsx @@ -36,9 +36,9 @@ import { TriStateFilter } from '@/features/source/browse/components/filters/TriS import { GroupFilter } from '@/features/source/browse/components/filters/GroupFilter.tsx'; import { SeparatorFilter } from '@/features/source/browse/components/filters/SeparatorFilter.tsx'; import { StyledFab } from '@/base/components/buttons/StyledFab.tsx'; -import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { ISourceMetadata, SourceFilters } from '@/features/source/Source.types.ts'; +import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx'; interface IFilters { sourceFilter: SourceFilters[]; @@ -256,7 +256,7 @@ export function SourceOptions({ selectSavedSearch(savedSearch); }} onDelete={() => { - awaitConfirmation({ + GlobalDialogManager.confirm({ title: t('global.label.are_you_sure'), message: t('source.filter.save_search.dialog.label.delete', { name: savedSearch,