Improve logic to show dialog outside of components

This commit is contained in:
schroda
2025-10-03 22:34:46 +02:00
parent 60f11a2832
commit 9329ef5fdc
12 changed files with 192 additions and 65 deletions

View File

@@ -30,6 +30,7 @@ import { AuthGuard } from '@/features/authentication/components/AuthGuard.tsx';
import { SearchParam } from '@/base/Base.types.ts'; import { SearchParam } from '@/base/Base.types.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { ReactRouter } from '@/lib/react-router/ReactRouter.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 { Browse } = loadable(() => import('@/features/browse/screens/Browse.tsx'), lazyLoadFallback);
const { DownloadQueue } = loadable(() => import('@/features/downloads/screens/DownloadQueue.tsx'), lazyLoadFallback); const { DownloadQueue } = loadable(() => import('@/features/downloads/screens/DownloadQueue.tsx'), lazyLoadFallback);
@@ -295,12 +296,17 @@ const ReaderApp = () => (
export const App: React.FC = () => ( export const App: React.FC = () => (
<AppContext> <AppContext>
<ScrollToTop /> <ScrollToTop />
<GlobalDialog />
<ServerUpdateChecker /> <ServerUpdateChecker />
<WebUIUpdateChecker /> <WebUIUpdateChecker />
<InitialBackgroundRequests /> <InitialBackgroundRequests />
<BackgroundSubscriptions /> <BackgroundSubscriptions />
<ReactRouterSetter /> <ReactRouterSetter />
<CssBaseline enableColorScheme /> <CssBaseline enableColorScheme />
<AuthGuard> <AuthGuard>
<Box sx={{ display: 'flex' }}> <Box sx={{ display: 'flex' }}>
<Box sx={{ flexShrink: 0, position: 'relative', height: '100vh' }}> <Box sx={{ flexShrink: 0, position: 'relative', height: '100vh' }}>

10
src/UtilTypes.d.ts vendored
View File

@@ -53,3 +53,13 @@ type OmitNotMatching<T, K extends keyof T> = {
type ExtractCommon<T, U> = { type ExtractCommon<T, U> = {
[K in keyof T & keyof U]: T[K] extends U[K] ? T[K] : never; [K in keyof T & keyof U]: T[K] extends U[K] ? T[K] : never;
}; };
type HasRequiredKeys<T> =
Exclude<
{
[K in keyof T]: T extends Record<K, T[K]> ? K : never;
}[keyof T],
undefined
> extends never
? false
: true;

View File

@@ -13,11 +13,11 @@ import AlertTitle from '@mui/material/AlertTitle';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx'; import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts'; import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts';
import { TranslationKey } from '@/base/Base.types.ts'; import { TranslationKey } from '@/base/Base.types.ts';
import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx';
const MAX_DESCRIPTION_LENGTH = 200; const MAX_DESCRIPTION_LENGTH = 200;
@@ -83,7 +83,7 @@ export const SnackbarWithDescription = memo(
{isDescriptionTooLong || (isGraphqlException && graphqlStackTrace) ? ( {isDescriptionTooLong || (isGraphqlException && graphqlStackTrace) ? (
<Button <Button
onClick={() => { onClick={() => {
awaitConfirmation({ GlobalDialogManager.confirm({
title: title:
typeof message === 'string' typeof message === 'string'
? message ? message

View File

@@ -85,7 +85,13 @@ export const ConfirmDialog = ({
}} }}
> >
{actions.extra.show && ( {actions.extra.show && (
<Button onClick={onExtra} variant={actions.extra.contain ? 'contained' : undefined}> <Button
onClick={() => {
onCancel();
onExtra?.();
}}
variant={actions.extra.contain ? 'contained' : undefined}
>
{actions.extra.title} {actions.extra.title}
</Button> </Button>
)} )}

View File

@@ -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(),
);

View File

@@ -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<T = any> {
onCancel: () => void;
onConfirm: (value: T) => void;
}
type DialogPropsParam<Props extends {}> = Omit<Props, keyof DialogProps>;
type ExtractDialogReturnType<T> = T extends DialogProps<infer U> ? U : never;
export class GlobalDialogManager {
private static subscriberCounter = 0;
private static queue = new Set<Id>();
private static dialogs = new Map<Id, ReactNode>();
private static subscribers = new Map<Id, () => 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<AllProps extends DialogProps>(
Dialog: ComponentType<AllProps>,
...args: HasRequiredKeys<DialogPropsParam<AllProps>> extends true
? [props: DialogPropsParam<AllProps>]
: [props?: DialogPropsParam<AllProps>]
): Promise<ExtractDialogReturnType<AllProps>>;
static async show<AllProps extends DialogProps>(
id: Id,
Dialog: ComponentType<AllProps>,
...args: HasRequiredKeys<DialogPropsParam<AllProps>> extends true
? [props: DialogPropsParam<AllProps>]
: [props?: DialogPropsParam<AllProps>]
): Promise<ExtractDialogReturnType<AllProps>>;
static async show<AllProps extends DialogProps>(
idOrDialog: Id | ComponentType<AllProps>,
dialogOrProps?: ComponentType<AllProps> | DialogPropsParam<AllProps>,
propsOrNothing?: DialogPropsParam<AllProps> | never,
): Promise<ExtractDialogReturnType<AllProps>> {
const dialogPromise = new ControlledPromise<ExtractDialogReturnType<AllProps>>();
const wasIdPassed = typeof idOrDialog === 'string';
const id = wasIdPassed ? idOrDialog : this.getNextId();
const Dialog = (wasIdPassed ? dialogOrProps : idOrDialog) as ComponentType<AllProps>;
const props = (wasIdPassed ? propsOrNothing : dialogOrProps) as DialogPropsParam<AllProps> | undefined;
const dialog = (
<Dialog
{...({
...(props ?? {}),
onCancel: () => 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<ComponentProps<typeof ConfirmDialog>>,
): Promise<ExtractDialogReturnType<ComponentProps<typeof ConfirmDialog>>>;
static confirm(
id: Id,
props: DialogPropsParam<ComponentProps<typeof ConfirmDialog>>,
): Promise<ExtractDialogReturnType<ComponentProps<typeof ConfirmDialog>>>;
static confirm(
idOrProps: Id | DialogPropsParam<ComponentProps<typeof ConfirmDialog>>,
propsOrNothing?: DialogPropsParam<ComponentProps<typeof ConfirmDialog>>,
): Promise<ExtractDialogReturnType<ComponentProps<typeof ConfirmDialog>>> {
if (typeof idOrProps === 'string') {
return this.show(idOrProps, ConfirmDialog, propsOrNothing!);
}
return this.show(ConfirmDialog, idOrProps);
}
}

View File

@@ -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<React.ComponentProps<typeof ConfirmDialog>, '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(
<ThemeProvider theme={getCurrentTheme()}>
<ConfirmDialog
{...dialogProps}
onExtra={() => {
handleConfirmation(false);
dialogProps.onExtra?.();
}}
onCancel={() => handleConfirmation(false)}
onConfirm={() => handleConfirmation(true)}
/>
</ThemeProvider>,
);
return confirmationPromise.promise;
};

View File

@@ -41,8 +41,8 @@ import {
ChapterSourceOrderInfo, ChapterSourceOrderInfo,
} from '@/features/chapter/Chapter.types.ts'; } from '@/features/chapter/Chapter.types.ts';
import { assertIsDefined } from '@/base/Asserts.ts'; import { assertIsDefined } from '@/base/Asserts.ts';
import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx';
import { DirectionOffset } from '@/base/Base.types.ts'; import { DirectionOffset } from '@/base/Base.types.ts';
import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx';
export class Chapters { export class Chapters {
static getIds(chapters: { id: number }[]): number[] { static getIds(chapters: { id: number }[]): number[] {
@@ -275,7 +275,7 @@ export class Chapters {
assertIsDefined(confirmationMessage); assertIsDefined(confirmationMessage);
try { try {
await awaitConfirmation({ await GlobalDialogManager.confirm({
title: translate('global.label.are_you_sure'), title: translate('global.label.are_you_sure'),
message: translate(confirmationMessage, { count: itemCount }), message: translate(confirmationMessage, { count: itemCount }),
actions: { actions: {

View File

@@ -17,11 +17,11 @@ import { getMetadataServerSettings } from '@/features/settings/services/ServerSe
import { Categories } from '@/features/category/services/Categories.ts'; import { Categories } from '@/features/category/services/Categories.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { Mangas } from '@/features/manga/services/Mangas.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 { GetCategoriesBaseQuery, GetCategoriesBaseQueryVariables, MangaType } from '@/lib/graphql/generated/graphql.ts';
import { GET_CATEGORIES_BASE } from '@/lib/graphql/queries/CategoryQuery.ts'; import { GET_CATEGORIES_BASE } from '@/lib/graphql/queries/CategoryQuery.ts';
import { AppRoutes } from '@/base/AppRoute.constants.ts'; import { AppRoutes } from '@/base/AppRoute.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx';
export const useManageMangaLibraryState = ( export const useManageMangaLibraryState = (
manga: Pick<MangaType, 'id' | 'title'> & Partial<Pick<MangaType, 'inLibrary'>>, manga: Pick<MangaType, 'id' | 'title'> & Partial<Pick<MangaType, 'inLibrary'>>,
@@ -54,7 +54,7 @@ export const useManageMangaLibraryState = (
const removeFromLibrary = useCallback(async () => { const removeFromLibrary = useCallback(async () => {
if (confirmRemoval) { if (confirmRemoval) {
await awaitConfirmation({ await GlobalDialogManager.confirm(`manga-library-state-remove-${manga.id}`, {
title: t('global.label.are_you_sure'), title: t('global.label.are_you_sure'),
message: t('manga.action.library.remove.dialog.label.message', { title: manga.title }), message: t('manga.action.library.remove.dialog.label.message', { title: manga.title }),
actions: { actions: {
@@ -113,7 +113,7 @@ export const useManageMangaLibraryState = (
try { try {
duplicatedLibraryMangas = await Mangas.getDuplicateLibraryMangas(manga.title).response; duplicatedLibraryMangas = await Mangas.getDuplicateLibraryMangas(manga.title).response;
} catch (e) { } catch (e) {
await awaitConfirmation({ await GlobalDialogManager.confirm(`manga-library-state-add-${manga.id}`, {
title: t('global.error.label.failed_to_load_data'), title: t('global.error.label.failed_to_load_data'),
message: t('manga.action.library.add.dialog.duplicate.label.failure', { message: t('manga.action.library.add.dialog.duplicate.label.failure', {
error: getErrorMessage(e), error: getErrorMessage(e),
@@ -131,7 +131,7 @@ export const useManageMangaLibraryState = (
const doDuplicatesExist = duplicatedLibraryMangas?.data.mangas.totalCount; const doDuplicatesExist = duplicatedLibraryMangas?.data.mangas.totalCount;
if (doDuplicatesExist) { if (doDuplicatesExist) {
await awaitConfirmation({ await GlobalDialogManager.confirm(`manga-library-state-add-duplicated-${manga.id}`, {
title: t('global.label.are_you_sure'), title: t('global.label.are_you_sure'),
message: t('manga.action.library.add.dialog.duplicate.label.info'), message: t('manga.action.library.add.dialog.duplicate.label.info'),
actions: { actions: {

View File

@@ -49,8 +49,8 @@ import {
SOURCES_BY_MANGA_TYPE, SOURCES_BY_MANGA_TYPE,
} from '@/features/manga/Manga.constants.ts'; } from '@/features/manga/Manga.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx';
import { assertIsDefined } from '@/base/Asserts.ts'; import { assertIsDefined } from '@/base/Asserts.ts';
import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx';
type MangaToMigrate = NonNullable<GetMangaToMigrateQuery['manga']>; type MangaToMigrate = NonNullable<GetMangaToMigrateQuery['manga']>;
type MangaToMigrateTo = NonNullable<GetMangaToMigrateToFetchMutation['fetchManga']>['manga']; type MangaToMigrateTo = NonNullable<GetMangaToMigrateToFetchMutation['fetchManga']>['manga'];
@@ -553,7 +553,7 @@ export class Mangas {
assertIsDefined(confirmationMessage); assertIsDefined(confirmationMessage);
try { try {
await awaitConfirmation({ await GlobalDialogManager.confirm({
title: translate('global.label.are_you_sure'), title: translate('global.label.are_you_sure'),
message: translate(confirmationMessage, { count: itemCount }), message: translate(confirmationMessage, { count: itemCount }),
actions: { actions: {

View File

@@ -45,7 +45,6 @@ import {
import { Chapters } from '@/features/chapter/services/Chapters.ts'; import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts'; import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { ChapterIdInfo, TChapterReader } from '@/features/chapter/Chapter.types.ts'; import { ChapterIdInfo, TChapterReader } from '@/features/chapter/Chapter.types.ts';
import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { TReaderProgressCurrentPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts'; import { TReaderProgressCurrentPage } from '@/features/reader/overlay/progress-bar/ReaderProgressBar.types.ts';
import { import {
@@ -55,6 +54,7 @@ import {
getReaderSettingsStore, getReaderSettingsStore,
getReaderTapZoneStore, getReaderTapZoneStore,
} from '@/features/reader/stores/ReaderStore.ts'; } from '@/features/reader/stores/ReaderStore.ts';
import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx';
const getScrollDirectionInvert = ( const getScrollDirectionInvert = (
scrollDirection: ScrollDirection, scrollDirection: ScrollDirection,
@@ -304,7 +304,7 @@ export class ReaderControls {
const warningLineBreak = !isSameScanlator && !isContinuousChapter ? '\n\n' : ''; const warningLineBreak = !isSameScanlator && !isContinuousChapter ? '\n\n' : '';
const warning = `${sameScanlator}${warningLineBreak}${continuousChapter}`; const warning = `${sameScanlator}${warningLineBreak}${continuousChapter}`;
await awaitConfirmation({ await GlobalDialogManager.confirm({
title: translate('reader.chapter_transition.warning.title'), title: translate('reader.chapter_transition.warning.title'),
message: warning, message: warning,
actions: { actions: {

View File

@@ -36,9 +36,9 @@ import { TriStateFilter } from '@/features/source/browse/components/filters/TriS
import { GroupFilter } from '@/features/source/browse/components/filters/GroupFilter.tsx'; import { GroupFilter } from '@/features/source/browse/components/filters/GroupFilter.tsx';
import { SeparatorFilter } from '@/features/source/browse/components/filters/SeparatorFilter.tsx'; import { SeparatorFilter } from '@/features/source/browse/components/filters/SeparatorFilter.tsx';
import { StyledFab } from '@/base/components/buttons/StyledFab.tsx'; import { StyledFab } from '@/base/components/buttons/StyledFab.tsx';
import { awaitConfirmation } from '@/base/utils/AwaitableDialog.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { ISourceMetadata, SourceFilters } from '@/features/source/Source.types.ts'; import { ISourceMetadata, SourceFilters } from '@/features/source/Source.types.ts';
import { GlobalDialogManager } from '@/base/global-dialog/GlobalDialogManager.tsx';
interface IFilters { interface IFilters {
sourceFilter: SourceFilters[]; sourceFilter: SourceFilters[];
@@ -256,7 +256,7 @@ export function SourceOptions({
selectSavedSearch(savedSearch); selectSavedSearch(savedSearch);
}} }}
onDelete={() => { onDelete={() => {
awaitConfirmation({ GlobalDialogManager.confirm({
title: t('global.label.are_you_sure'), title: t('global.label.are_you_sure'),
message: t('source.filter.save_search.dialog.label.delete', { message: t('source.filter.save_search.dialog.label.delete', {
name: savedSearch, name: savedSearch,