[web] Dialog enhancements - Part x/x (#3657)

This commit is contained in:
Manav Rathi
2024-10-10 17:36:28 +05:30
committed by GitHub
33 changed files with 559 additions and 692 deletions

View File

@@ -1,11 +1,9 @@
import { staticAppTitle } from "@/base/app";
import { CustomHead } from "@/base/components/Head";
import {
type MiniDialogAttributes,
MiniDialog,
} from "@/base/components/MiniDialog";
import { AttributedMiniDialog } from "@/base/components/MiniDialog";
import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator";
import { AppNavbar } from "@/base/components/Navbar";
import { useAttributedMiniDialog } from "@/base/components/utils/mini-dialog";
import { setupI18n } from "@/base/i18n";
import { disableDiskLogs } from "@/base/log";
import { logUnhandledErrorsAndRejections } from "@/base/log-web";
@@ -16,7 +14,7 @@ import { CssBaseline } from "@mui/material";
import { ThemeProvider } from "@mui/material/styles";
import { t } from "i18next";
import type { AppProps } from "next/app";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { AppContext } from "../types/context";
import "styles/global.css";
@@ -24,10 +22,7 @@ import "styles/global.css";
const App: React.FC<AppProps> = ({ Component, pageProps }) => {
const [isI18nReady, setIsI18nReady] = useState<boolean>(false);
const [showNavbar, setShowNavbar] = useState(false);
const [dialogBoxAttributeV2, setDialogBoxAttributesV2] = useState<
MiniDialogAttributes | undefined
>();
const [dialogBoxV2View, setDialogBoxV2View] = useState(false);
const { showMiniDialog, miniDialogProps } = useAttributedMiniDialog();
useEffect(() => {
disableDiskLogs();
@@ -36,16 +31,13 @@ const App: React.FC<AppProps> = ({ Component, pageProps }) => {
return () => logUnhandledErrorsAndRejections(false);
}, []);
useEffect(() => {
setDialogBoxV2View(true);
}, [dialogBoxAttributeV2]);
const closeDialogBoxV2 = () => setDialogBoxV2View(false);
const appContext = {
showNavBar: setShowNavbar,
setDialogBoxAttributesV2,
};
const appContext = useMemo(
() => ({
showNavBar: setShowNavbar,
showMiniDialog,
}),
[showMiniDialog],
);
const title = isI18nReady ? t("title_accounts") : staticAppTitle;
@@ -55,12 +47,7 @@ const App: React.FC<AppProps> = ({ Component, pageProps }) => {
<ThemeProvider theme={getTheme(THEME_COLOR.DARK, "photos")}>
<CssBaseline enableColorScheme />
<MiniDialog
sx={{ zIndex: 1600 }}
open={dialogBoxV2View}
onClose={closeDialogBoxV2}
attributes={dialogBoxAttributeV2}
/>
<AttributedMiniDialog {...miniDialogProps} />
<AppContext.Provider value={appContext}>
{!isI18nReady && (

View File

@@ -1,6 +1,7 @@
import { EnteDrawer } from "@/base/components/EnteDrawer";
import { MenuItemDivider, MenuItemGroup } from "@/base/components/Menu";
import { Titlebar } from "@/base/components/Titlebar";
import { errorDialogAttributes } from "@/base/components/utils/mini-dialog";
import log from "@/base/log";
import { ensure } from "@/utils/ensure";
import { CenteredFlex } from "@ente/shared/components/Container";
@@ -34,7 +35,7 @@ import {
import { useAppContext } from "../../types/context";
const Page: React.FC = () => {
const { showNavBar, setDialogBoxAttributesV2 } = useAppContext();
const { showNavBar, showMiniDialog } = useAppContext();
const [token, setToken] = useState<string | undefined>();
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
@@ -44,12 +45,8 @@ const Page: React.FC = () => {
>();
const showPasskeyFetchFailedErrorDialog = useCallback(() => {
setDialogBoxAttributesV2({
title: t("error"),
content: t("passkey_fetch_failed"),
close: {},
});
}, [setDialogBoxAttributesV2]);
showMiniDialog(errorDialogAttributes(t("passkey_fetch_failed")));
}, [showMiniDialog]);
useEffect(() => {
showNavBar(true);
@@ -263,34 +260,26 @@ const ManagePasskeyDrawer: React.FC<ManagePasskeyDrawerProps> = ({
passkey,
onUpdateOrDeletePasskey,
}) => {
const { setDialogBoxAttributesV2 } = useAppContext();
const { showMiniDialog } = useAppContext();
const [showRenameDialog, setShowRenameDialog] = useState(false);
const showDeleteConfirmationDialog = useCallback(() => {
const handleDelete = async (setLoading: (value: boolean) => void) => {
setLoading(true);
try {
await deletePasskey(ensure(token), ensure(passkey).id);
onUpdateOrDeletePasskey();
} catch (e) {
log.error("Failed to delete passkey", e);
} finally {
setLoading(false);
}
};
setDialogBoxAttributesV2({
title: t("delete_passkey"),
content: t("delete_passkey_confirmation"),
proceed: {
text: t("delete"),
action: handleDelete,
variant: "critical",
},
close: { text: t("cancel") },
});
}, [token, passkey, onUpdateOrDeletePasskey, setDialogBoxAttributesV2]);
const showDeleteConfirmationDialog = useCallback(
() =>
showMiniDialog({
title: t("delete_passkey"),
message: t("delete_passkey_confirmation"),
continue: {
text: t("delete"),
color: "critical",
action: async () => {
await deletePasskey(ensure(token), ensure(passkey).id);
onUpdateOrDeletePasskey();
},
},
}),
[showMiniDialog, token, passkey, onUpdateOrDeletePasskey],
);
return (
<>

View File

@@ -1,15 +1,11 @@
import type { MiniDialogAttributes } from "@/base/components/MiniDialog";
import type { AccountsContextT } from "@/accounts/types/context";
import { ensure } from "@/utils/ensure";
import { createContext, useContext } from "react";
/**
* The type of the context for pages in the accounts app.
*/
interface AppContextT {
/** Show or hide the app's navigation bar. */
showNavBar: (show: boolean) => void;
setDialogBoxAttributesV2: (attrs: MiniDialogAttributes) => void;
}
type AppContextT = Omit<AccountsContextT, "logout">;
/**
* The React {@link Context} available to all nodes in the React tree.

View File

@@ -2,12 +2,13 @@ import { accountLogout } from "@/accounts/services/logout";
import type { AccountsContextT } from "@/accounts/types/context";
import { clientPackageName, staticAppTitle } from "@/base/app";
import { CustomHead } from "@/base/components/Head";
import {
type MiniDialogAttributes,
MiniDialog,
} from "@/base/components/MiniDialog";
import { AttributedMiniDialog } from "@/base/components/MiniDialog";
import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator";
import { AppNavbar } from "@/base/components/Navbar";
import {
genericErrorDialogAttributes,
useAttributedMiniDialog,
} from "@/base/components/utils/mini-dialog";
import { setupI18n } from "@/base/i18n";
import {
logStartupBanner,
@@ -39,6 +40,7 @@ import React, {
useState,
} from "react";
import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar";
import "../../public/css/global.css";
/**
@@ -68,10 +70,8 @@ const App: React.FC<AppProps> = ({ Component, pageProps }) => {
const [showNavbar, setShowNavBar] = useState(false);
const isLoadingBarRunning = useRef<boolean>(false);
const loadingBar = useRef<LoadingBarRef>(null);
const [dialogBoxAttributeV2, setDialogBoxAttributesV2] = useState<
MiniDialogAttributes | undefined
>();
const [dialogBoxV2View, setDialogBoxV2View] = useState(false);
const { showMiniDialog, miniDialogProps } = useAttributedMiniDialog();
const [themeColor, setThemeColor] = useLocalState(
LS_KEYS.THEME,
THEME_COLOR.DARK,
@@ -111,16 +111,13 @@ const App: React.FC<AppProps> = ({ Component, pageProps }) => {
};
}, []);
useEffect(() => {
setDialogBoxV2View(true);
}, [dialogBoxAttributeV2]);
const showNavBar = (show: boolean) => setShowNavBar(show);
const startLoading = () => {
!isLoadingBarRunning.current && loadingBar.current?.continuousStart();
isLoadingBarRunning.current = true;
};
const finishLoading = () => {
setTimeout(() => {
isLoadingBarRunning.current && loadingBar.current?.complete();
@@ -128,14 +125,8 @@ const App: React.FC<AppProps> = ({ Component, pageProps }) => {
}, 100);
};
const closeDialogBoxV2 = () => setDialogBoxV2View(false);
const somethingWentWrong = () =>
setDialogBoxAttributesV2({
title: t("error"),
close: { variant: "critical" },
content: t("generic_error_retry"),
});
showMiniDialog(genericErrorDialogAttributes());
const logout = () => {
void accountLogout().then(() => router.push("/"));
@@ -144,7 +135,7 @@ const App: React.FC<AppProps> = ({ Component, pageProps }) => {
const appContext = {
logout,
showNavBar,
setDialogBoxAttributesV2,
showMiniDialog,
startLoading,
finishLoading,
themeColor,
@@ -167,12 +158,7 @@ const App: React.FC<AppProps> = ({ Component, pageProps }) => {
<LoadingBar color="#51cd7c" ref={loadingBar} />
<MiniDialog
sx={{ zIndex: 1600 }}
open={dialogBoxV2View}
onClose={closeDialogBoxV2}
attributes={dialogBoxAttributeV2}
/>
<AttributedMiniDialog {...miniDialogProps} />
<AppContext.Provider value={appContext}>
{(loading || !isI18nReady) && (

View File

@@ -1,3 +1,4 @@
import { sessionExpiredDialogAttributes } from "@/accounts/components/LoginComponents";
import { stashRedirect } from "@/accounts/services/redirect";
import { EnteLogo } from "@/base/components/EnteLogo";
import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator";
@@ -7,7 +8,6 @@ import {
HorizontalFlex,
VerticallyCentered,
} from "@ente/shared/components/Container";
import { sessionExpiredDialogAttributes } from "@ente/shared/components/LoginComponents";
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages";
@@ -23,7 +23,7 @@ import { getAuthCodes } from "services/remote";
import { AppContext } from "./_app";
const Page: React.FC = () => {
const { logout, showNavBar, setDialogBoxAttributesV2 } = ensure(
const { logout, showNavBar, showMiniDialog } = ensure(
useContext(AppContext),
);
const router = useRouter();
@@ -32,7 +32,7 @@ const Page: React.FC = () => {
const [searchTerm, setSearchTerm] = useState("");
const showSessionExpiredDialog = () =>
setDialogBoxAttributesV2(sessionExpiredDialogAttributes(logout));
showMiniDialog(sessionExpiredDialogAttributes(logout));
useEffect(() => {
const fetchCodes = async () => {

View File

@@ -1,6 +1,6 @@
import { checkSessionValidity } from "@/accounts/services/session";
import {
DialogBoxV2,
TitledMiniDialog,
type MiniDialogAttributes,
} from "@/base/components/MiniDialog";
import log from "@/base/log";
@@ -24,8 +24,7 @@ export default function AuthenticateUserModal({
onClose,
onAuthenticate,
}: Iprops) {
const { setDialogMessage, setDialogBoxAttributesV2, logout } =
useContext(AppContext);
const { setDialogMessage, showMiniDialog, logout } = useContext(AppContext);
const [user, setUser] = useState<User>();
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
@@ -46,7 +45,7 @@ export default function AuthenticateUserModal({
const session = await checkSessionValidity();
if (session.status != "valid") {
onClose();
setDialogBoxAttributesV2(
showMiniDialog(
passwordChangedElsewhereDialogAttributes(logout),
);
}
@@ -55,7 +54,7 @@ export default function AuthenticateUserModal({
// potentially transient issues.
log.warn("Ignoring error when determining session validity", e);
}
}, [setDialogBoxAttributesV2, logout]);
}, [showMiniDialog, logout]);
useEffect(() => {
const main = async () => {
@@ -98,13 +97,11 @@ export default function AuthenticateUserModal({
};
return (
<DialogBoxV2
<TitledMiniDialog
open={open}
onClose={onClose}
sx={{ position: "absolute" }}
attributes={{
title: t("password"),
}}
title={t("password")}
>
<VerifyMasterPasswordForm
buttonText={t("AUTHENTICATE")}
@@ -113,7 +110,7 @@ export default function AuthenticateUserModal({
keyAttributes={keyAttributes}
submitButtonProps={{ sx: { mb: 0 } }}
/>
</DialogBoxV2>
</TitledMiniDialog>
);
}
@@ -128,11 +125,10 @@ const passwordChangedElsewhereDialogAttributes = (
onLogin: () => void,
): MiniDialogAttributes => ({
title: t("password_changed_elsewhere"),
content: t("password_changed_elsewhere_message"),
proceed: {
message: t("password_changed_elsewhere_message"),
continue: {
text: t("login"),
action: onLogin,
variant: "accent",
},
close: { text: t("cancel") },
cancel: false,
});

View File

@@ -1,8 +1,9 @@
import { DialogBoxV2 } from "@/base/components/MiniDialog";
import { TitledMiniDialog } from "@/base/components/MiniDialog";
import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator";
import { boxSeal } from "@/base/crypto/libsodium";
import log from "@/base/log";
import type { Collection } from "@/media/collection";
import { photosDialogZIndex } from "@/new/photos/components/z-index";
import { loadCast } from "@/new/photos/utils/chromecast-sender";
import SingleInputForm, {
type SingleInputFormProps,
@@ -135,11 +136,11 @@ export const AlbumCastDialog: React.FC<AlbumCastDialogProps> = ({
}, [open]);
return (
<DialogBoxV2
<TitledMiniDialog
open={open}
onClose={onClose}
attributes={{ title: t("cast_album_to_tv") }}
sx={{ zIndex: 1600 }}
title={t("cast_album_to_tv")}
sx={{ zIndex: photosDialogZIndex }}
>
{view == "choose" && (
<Stack sx={{ py: 1, gap: 4 }}>
@@ -213,6 +214,6 @@ export const AlbumCastDialog: React.FC<AlbumCastDialogProps> = ({
</Button>
</>
)}
</DialogBoxV2>
</TitledMiniDialog>
);
};

View File

@@ -1,4 +1,4 @@
import { DialogBoxV2 } from "@/base/components/MiniDialog";
import { TitledMiniDialog } from "@/base/components/MiniDialog";
import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
@@ -39,12 +39,10 @@ export default function CollectionNamer({ attributes, ...props }: Props) {
};
return (
<DialogBoxV2
<TitledMiniDialog
open={props.show}
onClose={props.onHide}
attributes={{
title: attributes.title,
}}
title={attributes.title}
>
<SingleInputForm
callback={onSubmit}
@@ -55,6 +53,6 @@ export default function CollectionNamer({ attributes, ...props }: Props) {
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
secondaryButtonAction={props.onHide}
/>
</DialogBoxV2>
</TitledMiniDialog>
);
}

View File

@@ -1,7 +1,6 @@
import { DialogBoxV2 } from "@/base/components/MiniDialog";
import { TitledMiniDialog } from "@/base/components/MiniDialog";
import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton";
import { LoadingButton } from "@/base/components/mui/LoadingButton";
import log from "@/base/log";
import { AppContext } from "@/new/photos/types/context";
import { initiateEmail } from "@/new/photos/utils/web";
import { Link, Stack, useMediaQuery } from "@mui/material";
@@ -28,7 +27,7 @@ interface FormValues {
}
const DeleteAccountModal = ({ open, onClose }: Iprops) => {
const { setDialogBoxAttributesV2, logout } = useContext(AppContext);
const { showMiniDialog, onGenericError, logout } = useContext(AppContext);
const { authenticateUser } = useContext(GalleryContext);
const [loading, setLoading] = useState(false);
@@ -39,13 +38,6 @@ const DeleteAccountModal = ({ open, onClose }: Iprops) => {
const isMobile = useMediaQuery("(max-width: 428px)");
const somethingWentWrong = () =>
setDialogBoxAttributesV2({
title: t("error"),
close: { variant: "critical" },
content: t("generic_error_retry"),
});
const initiateDelete = async (
{ reason, feedback }: FormValues,
{ setFieldError }: FormikHelpers<FormValues>,
@@ -76,80 +68,61 @@ const DeleteAccountModal = ({ open, onClose }: Iprops) => {
askToMailForDeletion();
}
} catch (e) {
log.error("Error while initiating account deletion", e);
somethingWentWrong();
onGenericError(e);
} finally {
setLoading(false);
}
};
const confirmAccountDeletion = () => {
setDialogBoxAttributesV2({
showMiniDialog({
title: t("delete_account"),
content: <Trans i18nKey="delete_account_confirm_message" />,
proceed: {
message: <Trans i18nKey="delete_account_confirm_message" />,
continue: {
text: t("delete"),
color: "critical",
action: solveChallengeAndDeleteAccount,
variant: "critical",
},
close: { text: t("cancel") },
});
};
const askToMailForDeletion = () => {
const emailID = "account-deletion@ente.io";
setDialogBoxAttributesV2({
showMiniDialog({
title: t("delete_account"),
content: (
message: (
<Trans
i18nKey="delete_account_manually_message"
components={{ a: <Link href={`mailto:${emailID}`} /> }}
values={{ emailID }}
/>
),
proceed: {
continue: {
text: t("delete"),
color: "critical",
action: () => initiateEmail(emailID),
variant: "critical",
},
close: { text: t("cancel") },
});
};
const solveChallengeAndDeleteAccount = async (
setLoading: (value: boolean) => void,
) => {
try {
setLoading(true);
const decryptedChallenge = await decryptDeleteAccountChallenge(
deleteAccountChallenge.current,
);
const { reason, feedback } = reasonAndFeedbackRef.current;
await deleteAccount(decryptedChallenge, reason, feedback);
logout();
} catch (e) {
log.error("solveChallengeAndDeleteAccount failed", e);
somethingWentWrong();
} finally {
setLoading(false);
}
const solveChallengeAndDeleteAccount = async () => {
const decryptedChallenge = await decryptDeleteAccountChallenge(
deleteAccountChallenge.current,
);
const { reason, feedback } = reasonAndFeedbackRef.current;
await deleteAccount(decryptedChallenge, reason, feedback);
logout();
};
return (
<>
<DialogBoxV2
<TitledMiniDialog
fullWidth
open={open}
onClose={onClose}
fullScreen={isMobile}
attributes={{
title: t("delete_account"),
secondary: {
action: onClose,
text: t("cancel"),
},
}}
title={t("delete_account")}
>
<Formik<FormValues>
initialValues={{
@@ -222,7 +195,7 @@ const DeleteAccountModal = ({ open, onClose }: Iprops) => {
</form>
)}
</Formik>
</DialogBoxV2>
</TitledMiniDialog>
</>
);
};

View File

@@ -1,4 +1,5 @@
import { DialogBoxV2 } from "@/base/components/MiniDialog";
import { TitledMiniDialog } from "@/base/components/MiniDialog";
import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton";
import { ItemCard, PreviewItemTile } from "@/new/photos/components/Tiles";
import { EnteFile } from "@/new/photos/types/file";
import { FlexWrapper } from "@ente/shared/components/Container";
@@ -54,20 +55,13 @@ const ExportPendingList = (props: Iprops) => {
};
return (
<DialogBoxV2
<TitledMiniDialog
open={props.isOpen}
onClose={props.onClose}
fullWidth
PaperProps={{
sx: { maxWidth: "444px" },
}}
attributes={{
title: t("PENDING_ITEMS"),
close: {
action: props.onClose,
text: t("close"),
},
}}
title={t("PENDING_ITEMS")}
>
<ItemList
maxHeight={240}
@@ -77,7 +71,14 @@ const ExportPendingList = (props: Iprops) => {
getItemTitle={getItemTitle}
generateItemKey={generateItemKey}
/>
</DialogBoxV2>
<FocusVisibleButton
fullWidth
color={"secondary"}
onClick={props.onClose}
>
{t("close")}
</FocusVisibleButton>
</TitledMiniDialog>
);
};

View File

@@ -1,3 +1,4 @@
import { photosDialogZIndex } from "@/new/photos/components/z-index";
import { AppContext } from "@/new/photos/types/context";
import Notification from "components/Notification";
import { t } from "i18next";
@@ -123,7 +124,7 @@ export const FilesDownloadProgress: React.FC<FilesDownloadProgressProps> = ({
horizontal="left"
sx={{
"&&": { bottom: `${index * 80 + 20}px` },
zIndex: 1600,
zIndex: photosDialogZIndex,
}}
open={isFilesDownloadStarted(attributes)}
onClose={handleClose(attributes)}

View File

@@ -1,4 +1,5 @@
import { DialogBoxV2 } from "@/base/components/MiniDialog";
import { TitledMiniDialog } from "@/base/components/MiniDialog";
import { photosDialogZIndex } from "@/new/photos/components/z-index";
import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
@@ -23,13 +24,11 @@ export const FileNameEditDialog = ({
}
};
return (
<DialogBoxV2
sx={{ zIndex: 1600 }}
<TitledMiniDialog
sx={{ zIndex: photosDialogZIndex }}
open={isInEditMode}
onClose={closeEditMode}
attributes={{
title: t("rename_file"),
}}
title={t("rename_file")}
>
<SingleInputForm
initialValue={filename}
@@ -41,6 +40,6 @@ export const FileNameEditDialog = ({
secondaryButtonAction={closeEditMode}
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
/>
</DialogBoxV2>
</TitledMiniDialog>
);
};

View File

@@ -1,4 +1,5 @@
import { EnteDrawer } from "@/base/components/EnteDrawer";
import type { MiniDialogAttributes } from "@/base/components/MiniDialog";
import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator";
import { Titlebar } from "@/base/components/Titlebar";
import { EllipsizedTypography } from "@/base/components/Typography";
@@ -18,7 +19,7 @@ import {
UnclusteredFaceList,
} from "@/new/photos/components/PeopleList";
import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker";
import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer";
import { fileInfoDrawerZIndex } from "@/new/photos/components/z-index";
import { tagNumericValue, type RawExifTags } from "@/new/photos/services/exif";
import {
AnnotatedFacesForFile,
@@ -53,12 +54,9 @@ import LinkButton from "components/pages/gallery/LinkButton";
import { t } from "i18next";
import { GalleryContext } from "pages/gallery";
import React, { useContext, useEffect, useMemo, useState } from "react";
import { Trans } from "react-i18next";
import { changeFileName, updateExistingFilePubMetadata } from "utils/file";
import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery";
import {
getMapDisableConfirmationDialog,
getMapEnableConfirmationDialog,
} from "utils/ui";
import { FileNameEditDialog } from "./FileNameEditDialog";
import InfoItem from "./InfoItem";
import MapBox from "./MapBox";
@@ -101,7 +99,7 @@ export const FileInfo: React.FC<FileInfoProps> = ({
closePhotoViewer,
onSelectPerson,
}) => {
const { mapEnabled, updateMapEnabled, setDialogBoxAttributesV2 } =
const { mapEnabled, updateMapEnabled, showMiniDialog } =
useContext(AppContext);
const galleryContext = useContext(GalleryContext);
const publicCollectionGalleryContext = useContext(
@@ -151,13 +149,13 @@ export const FileInfo: React.FC<FileInfoProps> = ({
};
const openEnableMapConfirmationDialog = () =>
setDialogBoxAttributesV2(
getMapEnableConfirmationDialog(() => updateMapEnabled(true)),
showMiniDialog(
confirmEnableMapsDialogAttributes(() => updateMapEnabled(true)),
);
const openDisableMapConfirmationDialog = () =>
setDialogBoxAttributesV2(
getMapDisableConfirmationDialog(() => updateMapEnabled(false)),
showMiniDialog(
confirmDisableMapsDialogAttributes(() => updateMapEnabled(false)),
);
const handleSelectFace = (annotatedFaceID: AnnotatedFaceID) => {
@@ -379,10 +377,39 @@ const parseExifInfo = (
return info;
};
const confirmEnableMapsDialogAttributes = (
onConfirm: () => void,
): MiniDialogAttributes => ({
title: t("ENABLE_MAPS"),
message: (
<Trans
i18nKey={"ENABLE_MAP_DESCRIPTION"}
components={{
a: (
<Link
target="_blank"
rel="noopener"
href="https://www.openstreetmap.org/"
/>
),
}}
/>
),
continue: { text: t("enable"), action: onConfirm },
});
const confirmDisableMapsDialogAttributes = (
onConfirm: () => void,
): MiniDialogAttributes => ({
title: t("DISABLE_MAPS"),
message: <Trans i18nKey={"DISABLE_MAP_DESCRIPTION"} />,
continue: { text: t("disable"), color: "critical", action: onConfirm },
});
const FileInfoSidebar = styled((props: DialogProps) => (
<EnteDrawer {...props} anchor="right" />
))({
zIndex: photoSwipeZIndex + 1,
zIndex: fileInfoDrawerZIndex,
"& .MuiPaper-root": {
padding: 8,
},

View File

@@ -4,8 +4,10 @@ import {
MenuItemGroup,
MenuSectionTitle,
} from "@/base/components/Menu";
import type { MiniDialogAttributes } from "@/base/components/MiniDialog";
import { nameAndExtension } from "@/base/file";
import log from "@/base/log";
import { photosDialogZIndex } from "@/new/photos/components/z-index";
import downloadManager from "@/new/photos/services/download";
import { AppContext } from "@/new/photos/types/context";
import { EnteFile } from "@/new/photos/types/file";
@@ -38,7 +40,6 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { getLocalCollections } from "services/collectionService";
import uploadManager from "services/upload/uploadManager";
import { getEditorCloseConfirmationMessage } from "utils/ui";
import ColoursMenu from "./ColoursMenu";
import CropMenu, { cropRegionOfCanvas, getCropRegionArgs } from "./CropMenu";
import FreehandCropRegion from "./FreehandCropRegion";
@@ -83,7 +84,7 @@ export interface CropBoxProps {
}
const ImageEditorOverlay = (props: IProps) => {
const appContext = useContext(AppContext);
const { showMiniDialog } = useContext(AppContext);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const originalSizeCanvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -441,9 +442,7 @@ const ImageEditorOverlay = (props: IProps) => {
const handleCloseWithConfirmation = () => {
if (transformationPerformed || coloursAdjusted) {
appContext.setDialogBoxAttributesV2(
getEditorCloseConfirmationMessage(handleClose),
);
showMiniDialog(confirmEditorCloseDialogAttributes(handleClose));
} else {
handleClose();
}
@@ -522,7 +521,7 @@ const ImageEditorOverlay = (props: IProps) => {
<Backdrop
sx={{
background: "#000",
zIndex: 1600,
zIndex: photosDialogZIndex,
width: "100%",
}}
open
@@ -732,6 +731,18 @@ const ImageEditorOverlay = (props: IProps) => {
export default ImageEditorOverlay;
const confirmEditorCloseDialogAttributes = (
onConfirm: () => void,
): MiniDialogAttributes => ({
title: t("CONFIRM_EDITOR_CLOSE_MESSAGE"),
message: t("CONFIRM_EDITOR_CLOSE_DESCRIPTION"),
continue: {
text: t("close"),
color: "critical",
action: onConfirm,
},
});
/**
* Create a new {@link File} with the contents of the given canvas.
*

View File

@@ -7,7 +7,7 @@ import log from "@/base/log";
import { savedLogs } from "@/base/log-web";
import { customAPIHost } from "@/base/origins";
import type { CollectionSummaries } from "@/new/photos/services/collection/ui";
import { AppContext } from "@/new/photos/types/context";
import { AppContext, useAppContext } from "@/new/photos/types/context";
import { initiateEmail, openURL } from "@/new/photos/utils/web";
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem";
@@ -667,7 +667,7 @@ const ExitSection: React.FC = () => {
};
const DebugSection: React.FC = () => {
const appContext = useContext(AppContext);
const { showMiniDialog } = useAppContext();
const [appVersion, setAppVersion] = useState<string | undefined>();
const [host, setHost] = useState<string | undefined>();
@@ -679,17 +679,13 @@ const DebugSection: React.FC = () => {
});
const confirmLogDownload = () =>
appContext.setDialogMessage({
showMiniDialog({
title: t("DOWNLOAD_LOGS"),
content: <Trans i18nKey={"DOWNLOAD_LOGS_MESSAGE"} />,
proceed: {
message: <Trans i18nKey={"DOWNLOAD_LOGS_MESSAGE"} />,
continue: {
text: t("download"),
variant: "accent",
action: downloadLogs,
},
close: {
text: t("cancel"),
},
});
const downloadLogs = () => {

View File

@@ -1,9 +1,12 @@
import { clientPackageName, staticAppTitle } from "@/base/app";
import { CustomHead } from "@/base/components/Head";
import type { MiniDialogAttributes } from "@/base/components/MiniDialog";
import { MiniDialog } from "@/base/components/MiniDialog";
import { AttributedMiniDialog } from "@/base/components/MiniDialog";
import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator";
import { AppNavbar } from "@/base/components/Navbar";
import {
genericErrorDialogAttributes,
useAttributedMiniDialog,
} from "@/base/components/utils/mini-dialog";
import { setupI18n } from "@/base/i18n";
import log from "@/base/log";
import {
@@ -11,6 +14,7 @@ import {
logUnhandledErrorsAndRejections,
} from "@/base/log-web";
import { AppUpdate } from "@/base/types/ipc";
import { photosDialogZIndex } from "@/new/photos/components/z-index";
import DownloadManager from "@/new/photos/services/download";
import { runMigrations } from "@/new/photos/services/migrations";
import { initML, isMLSupported } from "@/new/photos/services/ml";
@@ -70,17 +74,15 @@ export default function App({ Component, pageProps }: AppProps) {
const isLoadingBarRunning = useRef(false);
const loadingBar = useRef(null);
const [dialogMessage, setDialogMessage] = useState<DialogBoxAttributes>();
const [dialogBoxAttributeV2, setDialogBoxAttributesV2] = useState<
MiniDialogAttributes | undefined
>();
const [messageDialogView, setMessageDialogView] = useState(false);
const [dialogBoxV2View, setDialogBoxV2View] = useState(false);
const [watchFolderView, setWatchFolderView] = useState(false);
const [watchFolderFiles, setWatchFolderFiles] = useState<FileList>(null);
const [notificationView, setNotificationView] = useState(false);
const closeNotification = () => setNotificationView(false);
const [notificationAttributes, setNotificationAttributes] =
useState<NotificationAttributes>(null);
const { showMiniDialog, miniDialogProps } = useAttributedMiniDialog();
const [themeColor, setThemeColor] = useLocalState(
LS_KEYS.THEME,
THEME_COLOR.DARK,
@@ -199,10 +201,6 @@ export default function App({ Component, pageProps }: AppProps) {
setMessageDialogView(true);
}, [dialogMessage]);
useEffect(() => {
setDialogBoxV2View(true);
}, [dialogBoxAttributeV2]);
useEffect(() => {
setNotificationView(true);
}, [notificationAttributes]);
@@ -210,13 +208,9 @@ export default function App({ Component, pageProps }: AppProps) {
const showNavBar = (show: boolean) => setShowNavBar(show);
const updateMapEnabled = async (enabled: boolean) => {
try {
await updateMapEnabledStatus(enabled);
setLocalMapEnabled(enabled);
setMapEnabled(enabled);
} catch (e) {
log.error("Error while updating mapEnabled", e);
}
await updateMapEnabledStatus(enabled);
setLocalMapEnabled(enabled);
setMapEnabled(enabled);
};
const startLoading = () => {
@@ -234,7 +228,6 @@ export default function App({ Component, pageProps }: AppProps) {
() => setMessageDialogView(false),
[],
);
const closeDialogBoxV2 = () => setDialogBoxV2View(false);
// Use `onGenericError` instead.
const somethingWentWrong = useCallback(
@@ -244,20 +237,13 @@ export default function App({ Component, pageProps }: AppProps) {
close: { variant: "critical" },
content: t("generic_error_retry"),
}),
[setDialogMessage],
[],
);
const onGenericError = useCallback(
(e: unknown) => (
log.error("Error", e),
setDialogBoxAttributesV2({
title: t("error"),
content: t("generic_error"),
close: { variant: "critical" },
})
),
[setDialogBoxAttributesV2],
);
const onGenericError = useCallback((e: unknown) => {
log.error("Error", e);
showMiniDialog(genericErrorDialogAttributes());
}, []);
const logout = useCallback(() => {
void photosLogout().then(() => router.push("/"));
@@ -276,9 +262,9 @@ export default function App({ Component, pageProps }: AppProps) {
setNotificationAttributes,
themeColor,
setThemeColor,
showMiniDialog,
somethingWentWrong,
onGenericError,
setDialogBoxAttributesV2,
mapEnabled,
updateMapEnabled, // <- changes on each render
isCFProxyDisabled,
@@ -301,17 +287,15 @@ export default function App({ Component, pageProps }: AppProps) {
<LoadingBar color="#51cd7c" ref={loadingBar} />
<DialogBox
sx={{ zIndex: 1600 }}
sx={{ zIndex: photosDialogZIndex }}
size="xs"
open={messageDialogView}
onClose={closeMessageDialog}
attributes={dialogMessage}
/>
<MiniDialog
sx={{ zIndex: 1600 }}
open={dialogBoxV2View}
onClose={closeDialogBoxV2}
attributes={dialogBoxAttributeV2}
<AttributedMiniDialog
sx={{ zIndex: photosDialogZIndex }}
{...miniDialogProps}
/>
<Notification

View File

@@ -4,7 +4,6 @@ import { openURL } from "@/new/photos/utils/web";
import { DialogBoxAttributes } from "@ente/shared/components/DialogBox/types";
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
import InfoOutlined from "@mui/icons-material/InfoRounded";
import { Link } from "@mui/material";
import { t } from "i18next";
import { Trans } from "react-i18next";
import { Subscription } from "types/billing";
@@ -135,55 +134,3 @@ export const getSessionExpiredMessage = (
variant: "accent",
},
});
export const getMapEnableConfirmationDialog = (
enableMapHelper,
): DialogBoxAttributes => ({
title: t("ENABLE_MAPS"),
content: (
<Trans
i18nKey={"ENABLE_MAP_DESCRIPTION"}
components={{
a: (
<Link
target="_blank"
rel="noopener"
href="https://www.openstreetmap.org/"
/>
),
}}
/>
),
proceed: {
action: enableMapHelper,
text: t("enable"),
variant: "accent",
},
close: { text: t("cancel") },
});
export const getMapDisableConfirmationDialog = (
disableMapHelper,
): DialogBoxAttributes => ({
title: t("DISABLE_MAPS"),
content: <Trans i18nKey={"DISABLE_MAP_DESCRIPTION"} />,
proceed: {
action: disableMapHelper,
text: t("disable"),
variant: "accent",
},
close: { text: t("cancel") },
});
export const getEditorCloseConfirmationMessage = (
doClose: () => void,
): DialogBoxAttributes => ({
title: t("CONFIRM_EDITOR_CLOSE_MESSAGE"),
content: t("CONFIRM_EDITOR_CLOSE_DESCRIPTION"),
proceed: {
action: doClose,
text: t("close"),
variant: "critical",
},
close: { text: t("cancel") },
});

View File

@@ -5,16 +5,17 @@ import {
} from "@/accounts/services/passkey";
import type { MiniDialogAttributes } from "@/base/components/MiniDialog";
import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton";
import { genericErrorDialogAttributes } from "@/base/components/utils/mini-dialog";
import log from "@/base/log";
import { customAPIHost } from "@/base/origins";
import { VerticallyCentered } from "@ente/shared/components/Container";
import FormPaper from "@ente/shared/components/Form/FormPaper";
import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer";
import LinkButton from "@ente/shared/components/LinkButton";
import { CircularProgress, Stack, Typography, styled } from "@mui/material";
import { t } from "i18next";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { VerticallyCentered } from "./Container";
import FormPaper from "./Form/FormPaper";
import FormPaperFooter from "./Form/FormPaper/Footer";
import LinkButton from "./LinkButton";
export const PasswordHeader: React.FC<React.PropsWithChildren> = ({
children,
@@ -74,7 +75,7 @@ interface VerifyingPasskeyProps {
onRetry: () => void;
/** Perform the (possibly app specific) logout sequence. */
logout: () => void;
setDialogBoxAttributesV2: (attrs: MiniDialogAttributes) => void;
showMiniDialog: (attrs: MiniDialogAttributes) => void;
}
export const VerifyingPasskey: React.FC<VerifyingPasskeyProps> = ({
@@ -82,7 +83,7 @@ export const VerifyingPasskey: React.FC<VerifyingPasskeyProps> = ({
email,
onRetry,
logout,
setDialogBoxAttributesV2,
showMiniDialog,
}) => {
type VerificationStatus = "waiting" | "checking" | "pending";
const [verificationStatus, setVerificationStatus] =
@@ -104,11 +105,11 @@ export const VerifyingPasskey: React.FC<VerifyingPasskeyProps> = ({
else router.push(await saveCredentialsAndNavigateTo(response));
} catch (e) {
log.error("Passkey verification status check failed", e);
setDialogBoxAttributesV2(
showMiniDialog(
e instanceof Error &&
e.message == passkeySessionExpiredErrorMessage
? sessionExpiredDialogAttributes(logout)
: genericErrorAttributes(),
: genericErrorDialogAttributes(),
);
setVerificationStatus("waiting");
}
@@ -194,11 +195,10 @@ const ButtonStack = styled("div")`
`;
/**
* {@link DialogBoxAttributesV2} for showing the error when the user's session
* has expired.
* {@link MiniDialogAttributes} for showing asking the user to login again when
* their session has expired.
*
* It asks them to login again. There is one button, which allows them to
* logout.
* There is one button, which allows them to logout.
*
* @param onLogin Called when the user presses the "Login" button on the error
* dialog.
@@ -207,20 +207,11 @@ export const sessionExpiredDialogAttributes = (
onLogin: () => void,
): MiniDialogAttributes => ({
title: t("SESSION_EXPIRED"),
content: t("SESSION_EXPIRED_MESSAGE"),
message: t("SESSION_EXPIRED_MESSAGE"),
nonClosable: true,
proceed: {
continue: {
text: t("login"),
action: onLogin,
variant: "accent",
},
});
/**
* {@link DialogBoxAttributesV2} for showing a generic error.
*/
const genericErrorAttributes = (): MiniDialogAttributes => ({
title: t("error"),
close: { variant: "critical" },
content: t("generic_error_retry"),
cancel: false,
});

View File

@@ -7,12 +7,6 @@ import { ensure } from "@/utils/ensure";
import { VerticallyCentered } from "@ente/shared/components/Container";
import FormPaper from "@ente/shared/components/Form/FormPaper";
import LinkButton from "@ente/shared/components/LinkButton";
import {
LoginFlowFormFooter,
PasswordHeader,
VerifyingPasskey,
sessionExpiredDialogAttributes,
} from "@ente/shared/components/LoginComponents";
import VerifyMasterPasswordForm, {
type VerifyMasterPasswordFormProps,
} from "@ente/shared/components/VerifyMasterPasswordForm";
@@ -46,6 +40,12 @@ import { t } from "i18next";
import { useRouter } from "next/router";
import { useCallback, useEffect, useState } from "react";
import { getSRPAttributes } from "../api/srp";
import {
LoginFlowFormFooter,
PasswordHeader,
VerifyingPasskey,
sessionExpiredDialogAttributes,
} from "../components/LoginComponents";
import { PAGES } from "../constants/pages";
import {
openPasskeyVerificationURL,
@@ -66,7 +66,7 @@ import type { PageProps } from "../types/page";
import type { SRPAttributes } from "../types/srp";
const Page: React.FC<PageProps> = ({ appContext }) => {
const { logout, showNavBar, setDialogBoxAttributesV2 } = appContext;
const { logout, showNavBar, showMiniDialog } = appContext;
const [srpAttributes, setSrpAttributes] = useState<SRPAttributes>();
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
@@ -82,7 +82,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
const validateSession = useCallback(async () => {
const showSessionExpiredDialog = () =>
setDialogBoxAttributesV2(sessionExpiredDialogAttributes(logout));
showMiniDialog(sessionExpiredDialogAttributes(logout));
try {
const session = await checkSessionValidity();
@@ -114,7 +114,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
// potentially transient issues.
log.warn("Ignoring error when determining session validity", e);
}
}, [setDialogBoxAttributesV2, logout]);
}, [showMiniDialog, logout]);
useEffect(() => {
const main = async () => {
@@ -354,7 +354,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
onRetry={() =>
openPasskeyVerificationURL(passkeyVerificationData)
}
{...{ logout, setDialogBoxAttributesV2 }}
{...{ logout, showMiniDialog }}
/>
);
}

View File

@@ -29,7 +29,7 @@ const bip39 = require("bip39");
bip39.setDefaultWordlist("english");
const Page: React.FC<PageProps> = ({ appContext }) => {
const { showNavBar, setDialogBoxAttributesV2 } = appContext;
const { showNavBar, showMiniDialog } = appContext;
const [keyAttributes, setKeyAttributes] = useState<
KeyAttributes | undefined
@@ -98,10 +98,11 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
};
const showNoRecoveryKeyMessage = () =>
setDialogBoxAttributesV2({
showMiniDialog({
title: t("sorry"),
close: {},
content: t("NO_RECOVERY_KEY_MESSAGE"),
message: t("NO_RECOVERY_KEY_MESSAGE"),
continue: { color: "secondary" },
cancel: false,
});
return (

View File

@@ -42,7 +42,7 @@ export interface RecoverPageProps {
}
const Page: React.FC<RecoverPageProps> = ({ appContext, twoFactorType }) => {
const { logout } = appContext;
const { showMiniDialog, logout } = appContext;
const [encryptedTwoFactorSecret, setEncryptedTwoFactorSecret] =
useState<Omit<B64EncryptionResult, "key"> | null>(null);
@@ -70,10 +70,7 @@ const Page: React.FC<RecoverPageProps> = ({ appContext, twoFactorType }) => {
const resp = await recoverTwoFactor(sid, twoFactorType);
setDoesHaveEncryptedRecoveryKey(!!resp.encryptedSecret);
if (!resp.encryptedSecret) {
showContactSupportDialog({
text: t("GO_BACK"),
action: router.back,
});
showContactSupportDialog({ action: router.back });
} else {
setEncryptedTwoFactorSecret({
encryptedData: resp.encryptedSecret,
@@ -89,10 +86,7 @@ const Page: React.FC<RecoverPageProps> = ({ appContext, twoFactorType }) => {
} else {
log.error("two factor recovery page setup failed", e);
setDoesHaveEncryptedRecoveryKey(false);
showContactSupportDialog({
text: t("GO_BACK"),
action: router.back,
});
showContactSupportDialog({ action: router.back });
}
}
};
@@ -146,12 +140,11 @@ const Page: React.FC<RecoverPageProps> = ({ appContext, twoFactorType }) => {
};
const showContactSupportDialog = (
dialogClose?: MiniDialogAttributes["close"],
dialogContinue?: MiniDialogAttributes["continue"],
) => {
appContext.setDialogBoxAttributesV2({
showMiniDialog({
title: t("contact_support"),
close: dialogClose ?? {},
content: (
message: (
<Trans
i18nKey={"NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE"}
components={{
@@ -160,6 +153,8 @@ const Page: React.FC<RecoverPageProps> = ({ appContext, twoFactorType }) => {
values={{ emailID: "support@ente.io" }}
/>
),
continue: { color: "secondary", ...(dialogContinue ?? {}) },
cancel: false,
});
};

View File

@@ -6,10 +6,6 @@ import { VerticallyCentered } from "@ente/shared/components/Container";
import FormPaper from "@ente/shared/components/Form/FormPaper";
import FormPaperTitle from "@ente/shared/components/Form/FormPaper/Title";
import LinkButton from "@ente/shared/components/LinkButton";
import {
LoginFlowFormFooter,
VerifyingPasskey,
} from "@ente/shared/components/LoginComponents";
import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
@@ -35,6 +31,10 @@ import { useEffect, useState } from "react";
import { Trans } from "react-i18next";
import { getSRPAttributes } from "../api/srp";
import { putAttributes, sendOtt, verifyOtt } from "../api/user";
import {
LoginFlowFormFooter,
VerifyingPasskey,
} from "../components/LoginComponents";
import { PAGES } from "../constants/pages";
import {
openPasskeyVerificationURL,
@@ -46,7 +46,7 @@ import type { PageProps } from "../types/page";
import type { SRPAttributes, SRPSetupAttributes } from "../types/srp";
const Page: React.FC<PageProps> = ({ appContext }) => {
const { logout, showNavBar, setDialogBoxAttributesV2 } = appContext;
const { logout, showNavBar, showMiniDialog } = appContext;
const [email, setEmail] = useState("");
const [resend, setResend] = useState(0);
@@ -202,7 +202,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
onRetry={() =>
openPasskeyVerificationURL(passkeyVerificationData)
}
{...{ logout, setDialogBoxAttributesV2 }}
{...{ logout, showMiniDialog }}
/>
);
}

View File

@@ -5,9 +5,20 @@ import type { MiniDialogAttributes } from "@/base/components/MiniDialog";
* defer to the pages provided by the accounts package.
*/
export interface AccountsContextT {
/** Perform the (possibly app specific) logout sequence. */
/**
* Perform the (possibly app specific) logout sequence.
*/
logout: () => void;
/** Show or hide the app's navigation bar. */
/**
* Show or hide the app's navigation bar.
*/
showNavBar: (show: boolean) => void;
setDialogBoxAttributesV2: (attrs: MiniDialogAttributes) => void;
/**
* Show a "mini dialog" with the given attributes.
*
* Mini dialogs (see {@link AttributedMiniDialog}) are meant for simple
* confirmation or notications. Their appearance and functionality can be
* customized by providing appropriate {@link MiniDialogAttributes}.
*/
showMiniDialog: (attributes: MiniDialogAttributes) => void;
}

View File

@@ -1,381 +1,290 @@
// TODO:
/* eslint-disable @typescript-eslint/prefer-optional-chain */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton";
import { LoadingButton } from "@/base/components/mui/LoadingButton";
import { dialogCloseHandler } from "@ente/shared/components/DialogBox/TitleWithCloseButton";
import type { ButtonProps } from "@mui/material";
import {
Box,
Dialog,
DialogContent,
DialogTitle,
Stack,
Typography,
type DialogProps,
} from "@mui/material";
import { t } from "i18next";
import React, { useState } from "react";
import log from "../log";
/**
* Customize the properties of the dialog box.
*
* Our custom dialog box helpers are meant for small message boxes, usually
* meant to confirm some user action. If more customization is needed, it might
* be a better idea to reach out for a bespoke MUI {@link DialogBox} instead.
* Customize the contents of an {@link AttributedMiniDialog}.
*/
export interface MiniDialogAttributes {
icon?: React.ReactNode;
/**
* The dialog's title
* The dialog's title.
*
* Usually this will be a string, but it can be any {@link ReactNode}. Note
* that it always gets wrapped in a Typography element to set the font
* style, so if your ReactNode wants to do its own thing, it'll need to
* reset or override these customizations.
* This will be usually be a string, but the prop accepts any React node to
* allow passing a i18next <Trans /> component.
*/
title?: React.ReactNode;
/**
* An optional component shown next to the title.
*/
icon?: React.ReactNode;
/**
* The dialog's message.
*
* This will be usually be a string, but the prop accepts any React node to
* allow passing a i18next <Trans /> component.
*/
message?: React.ReactNode;
/**
* If `true`, then clicks in the backdrop are ignored. The default behaviour
* is to close the dialog when the background is clicked.
*/
staticBackdrop?: boolean;
/**
* If `true`, then the dialog cannot be closed (e.g. with the ESC key, or
* clicking on the backdrop) except through one of the explicitly provided
* actions.
*/
nonClosable?: boolean;
/**
* The dialog's content.
*/
content?: React.ReactNode;
/**
* Customize the cancel (dismiss) action button offered by the dialog box.
* Customize the primary action button shown in the dialog.
*
* Usually dialog boxes should have a cancel action, but this can be skipped
* to only show one of the other types of buttons.
* This is provided by boxes which serve as some sort of confirmation. For
* dialogs which are informational notifications, this is usually skipped,
* only the {@link close} action button is configured.
*/
close?: {
/** The string to use as the label for the cancel button. */
text?: string;
/** The color of the button. */
variant?: ButtonProps["color"];
continue?: {
/**
* The function to call when the user cancels.
* The string to use as the label for the primary action button.
*
* If provided, this callback is invoked before closing the dialog.
* Default is `t("ok")`.
*/
action?: () => void;
text?: string;
/**
* The color of the button.
*
* Default is "accent".
*/
color?: ButtonProps["color"];
/**
* If `true`, the primary action button is auto focused when the dialog
* is opened, allowing the user to confirm just by pressing ENTER.
*/
autoFocus?: ButtonProps["autoFocus"];
/**
* The function to call when the user activates the button.
*
* If this function returns a promise, then an activity indicator will
* be shown on the button until the promise settles.
*
* If this function is not provided, or if the function completes /
* fullfills, then then the dialog is automatically closed.
*
* Otherwise (that is, if the provided function throws), the dialog
* remains open, showing a generic error.
*
* That's quite a mouthful, here's a flowchart:
*
* - Not provided: Close
* - Provided sync:
* - Success: Close
* - Failure: Remain open, showing generic error
* - Provided async:
* - Success: Close
* - Failure: Remain open, showing generic error
*/
action?: () => void | Promise<void>;
};
/**
* Customize the primary action button offered by the dialog box.
* The string to use as the label for the cancel button.
*
* Default is `t("cancel")`.
*
* Set this to `false` to omit the cancel button altogether.
*/
proceed?: {
/** The string to use as the label for the primary action. */
text: string;
/**
* The function to call when the user presses the primary action button.
*
* It is passed a {@link setLoading} function that can be used to show
* or hide loading indicator or the primary action button.
*/
action:
| (() => void | Promise<void>)
| ((setLoading: (value: boolean) => void) => void | Promise<void>);
variant?: ButtonProps["color"];
disabled?: boolean;
};
secondary?: {
text: string;
action: () => void;
variant?: ButtonProps["color"];
disabled?: boolean;
};
buttons?: {
text: string;
action: () => void;
variant: ButtonProps["color"];
disabled?: boolean;
}[];
cancel?: string | false;
/** The direction in which the buttons are stacked. Default is "column". */
buttonDirection?: "row" | "column";
}
type MiniDialogProps = React.PropsWithChildren<
Omit<DialogProps, "onClose"> & {
onClose: () => void;
attributes?: MiniDialogAttributes;
}
>;
type MiniDialogProps = Omit<DialogProps, "onClose"> & {
onClose: () => void;
attributes?: MiniDialogAttributes;
};
/**
* A small, mostly predefined, MUI {@link Dialog} that can be used to notify the
* user, or ask for confirmation before actions.
*
* The rendered dialog can be customized by modifying the {@link attributes}
* prop. If you find yourself wanting to customize it further, consider just
* creating a new bespoke instantiation of a {@link Dialog}.
* prop. If you find yourself wanting to customize it further, consider either
* using a {@link TitledMiniDialog} or {@link Dialog}.
*/
export function MiniDialog({
attributes,
children,
open,
onClose,
...props
}: MiniDialogProps) {
const [loading, setLoading] = useState(false);
export const AttributedMiniDialog: React.FC<
React.PropsWithChildren<MiniDialogProps>
> = ({ open, onClose, attributes, children, ...props }) => {
const [phase, setPhase] = useState<"loading" | "failed" | undefined>();
if (!attributes) {
return <></>;
}
const handleClose = dialogCloseHandler({
staticBackdrop: attributes.staticBackdrop,
nonClosable: attributes.nonClosable,
onClose: onClose,
});
const resetPhaseAndClose = () => {
setPhase(undefined);
onClose();
};
const handleClose = () => {
if (attributes.nonClosable) return;
resetPhaseAndClose();
};
const { PaperProps, ...rest } = props;
return (
<Dialog
open={open}
onClose={handleClose}
fullWidth
PaperProps={{
...PaperProps,
sx: {
padding: "8px 12px",
maxWidth: "360px",
...PaperProps?.sx,
},
}}
onClose={handleClose}
// This is required to prevent console errors about aria-hiding a
// focused button when the dialog is closed.
//
// https://github.com/mui/material-ui/issues/43106#issuecomment-2314809028
closeAfterTransition={false}
{...rest}
>
<Stack spacing={"36px"} p={"16px"}>
<Stack spacing={"19px"}>
{attributes.icon && (
<Box
sx={{
"& > svg": {
fontSize: "32px",
},
}}
>
{attributes.icon}
</Box>
)}
{(attributes.icon ?? attributes.title) && (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
"& > svg": {
fontSize: "32px",
color: "text.faint",
},
padding: "24px 16px 16px 16px",
}}
>
{attributes.title && (
<Typography variant="large" fontWeight={"bold"}>
<DialogTitle sx={{ "&&&": { padding: 0 } }}>
{attributes.title}
</DialogTitle>
)}
{attributes.icon}
</Box>
)}
<DialogContent>
{attributes.message && (
<Typography
component={
typeof attributes.message == "string" ? "p" : "div"
}
color="text.muted"
>
{attributes.message}
</Typography>
)}
{children}
<Stack
sx={{ paddingBlockStart: "24px", gap: "12px" }}
direction={
attributes.buttonDirection == "row"
? "row-reverse"
: "column"
}
>
{phase == "failed" && (
<Typography variant="small" color="critical.main">
{t("generic_error")}
</Typography>
)}
{children ||
(attributes?.content && (
<Typography color="text.muted">
{attributes.content}
</Typography>
))}
{attributes.continue && (
<LoadingButton
loading={phase == "loading"}
fullWidth
color={attributes.continue.color ?? "accent"}
autoFocus={attributes.continue.autoFocus}
onClick={async () => {
setPhase("loading");
try {
await attributes.continue?.action?.();
resetPhaseAndClose();
} catch (e) {
log.error("Error", e);
setPhase("failed");
}
}}
>
{attributes.continue.text ?? t("ok")}
</LoadingButton>
)}
{attributes.cancel !== false && (
<FocusVisibleButton
fullWidth
color="secondary"
onClick={resetPhaseAndClose}
>
{attributes.cancel ?? t("cancel")}
</FocusVisibleButton>
)}
</Stack>
{(attributes.proceed ||
attributes.close ||
attributes.buttons?.length) && (
<Stack
spacing={"8px"}
direction={
attributes.buttonDirection === "row"
? "row-reverse"
: "column"
}
flex={1}
>
{attributes.proceed && (
<LoadingButton
loading={loading}
size="large"
color={attributes.proceed?.variant}
onClick={async () => {
await attributes.proceed?.action(
setLoading,
);
onClose();
}}
disabled={attributes.proceed.disabled}
>
{attributes.proceed.text}
</LoadingButton>
)}
{attributes.close && (
<FocusVisibleButton
size="large"
color={attributes.close?.variant ?? "secondary"}
onClick={() => {
attributes.close?.action &&
attributes.close?.action();
onClose();
}}
>
{attributes.close?.text ?? t("ok")}
</FocusVisibleButton>
)}
{attributes.buttons &&
attributes.buttons.map((b) => (
<FocusVisibleButton
size="large"
key={b.text}
color={b.variant}
onClick={() => {
b.action();
onClose();
}}
disabled={b.disabled}
>
{b.text}
</FocusVisibleButton>
))}
</Stack>
)}
</Stack>
</DialogContent>
</Dialog>
);
}
};
// TODO: Sketch of a possible approach to using this. Haven't throught this
// through, just noting down the outline inspired by an API I saw.
// /**
// * A React hook for simplifying use of MiniDialog within the photos app context.
// *
// * It relies on the presence of the {@link setDialogBoxAttributesV2} function
// * provided by the Photos app's {@link AppContext}.
// */
// export const useConfirm = (attr) => {
// const {setDialogBoxAttributesV2} = useAppContext();
// return () => {
// new Promise((resolve) => {
// setDialogBoxAttributesV2(
// proceed: {
// action: attr.action.then(resolve)
// }
// )
// }
// }
type TitledMiniDialogProps = Omit<DialogProps, "onClose"> & {
onClose: () => void;
/**
* The dialog's title.
*/
title?: React.ReactNode;
};
/**
* TODO This is a duplicate of MiniDialog. This is for use by call sites that
* were using the MiniDialog not as a dialog but as a base container. Such use
* cases are better served by directly using the MUI {@link Dialog}, so these
* are considered deprecated. Splitting these here so that we can streamline the
* API for the notify/confirm case separately.
* MiniDialog in a "shell" form.
*
* This is a {@link Dialog} for use at places which need more customization than
* what {@link AttributedMiniDialog} provides, but wish to retain a similar look
* and feel without duplicating code.
*
* It does three things:
*
* - Sets a fixed size and padding similar to {@link AttributedMiniDialog}.
* - Takes the title as a prop, and wraps it in a {@link DialogTitle}.
* - Wraps children in a scrollable {@link DialogContent}.
*/
export function DialogBoxV2({
attributes,
children,
open,
onClose,
...props
}: MiniDialogProps) {
const [loading, setLoading] = useState(false);
if (!attributes) {
return <></>;
}
const handleClose = dialogCloseHandler({
staticBackdrop: attributes.staticBackdrop,
nonClosable: attributes.nonClosable,
onClose: onClose,
});
export const TitledMiniDialog: React.FC<
React.PropsWithChildren<TitledMiniDialogProps>
> = ({ open, onClose, title, children, ...props }) => {
const { PaperProps, ...rest } = props;
return (
<Dialog
open={open}
onClose={handleClose}
onClose={onClose}
fullWidth
PaperProps={{
...PaperProps,
sx: {
padding: "8px 12px",
maxWidth: "360px",
...PaperProps?.sx,
},
}}
{...rest}
>
<Stack spacing={"36px"} p={"16px"}>
<Stack spacing={"19px"}>
{attributes.icon && (
<Box
sx={{
"& > svg": {
fontSize: "32px",
},
}}
>
{attributes.icon}
</Box>
)}
{attributes.title && (
<Typography variant="large" fontWeight={"bold"}>
{attributes.title}
</Typography>
)}
{children ||
(attributes?.content && (
<Typography color="text.muted">
{attributes.content}
</Typography>
))}
</Stack>
{(attributes.proceed ||
attributes.close ||
attributes.buttons?.length) && (
<Stack
spacing={"8px"}
direction={
attributes.buttonDirection === "row"
? "row-reverse"
: "column"
}
flex={1}
>
{attributes.proceed && (
<LoadingButton
loading={loading}
size="large"
color={attributes.proceed?.variant}
onClick={async () => {
await attributes.proceed?.action(
setLoading,
);
onClose();
}}
disabled={attributes.proceed.disabled}
>
{attributes.proceed.text}
</LoadingButton>
)}
{attributes.close && (
<FocusVisibleButton
size="large"
color={attributes.close?.variant ?? "secondary"}
onClick={() => {
attributes.close?.action &&
attributes.close?.action();
onClose();
}}
>
{attributes.close?.text ?? t("ok")}
</FocusVisibleButton>
)}
{attributes.buttons &&
attributes.buttons.map((b) => (
<FocusVisibleButton
size="large"
key={b.text}
color={b.variant}
onClick={() => {
b.action();
onClose();
}}
disabled={b.disabled}
>
{b.text}
</FocusVisibleButton>
))}
</Stack>
)}
</Stack>
<DialogTitle sx={{ "&&&": { paddingBlock: "24px 16px" } }}>
{title}
</DialogTitle>
<DialogContent>{children}</DialogContent>
</Dialog>
);
}
};

View File

@@ -0,0 +1,63 @@
import ErrorOutline from "@mui/icons-material/ErrorOutline";
import { t } from "i18next";
import { useCallback, useState } from "react";
import type { MiniDialogAttributes } from "../MiniDialog";
/**
* A React hook for simplifying the provisioning of a {@link showMiniDialog}
* function to inject in app contexts, and the other props that are needed for
* to pass on to the {@link AttributedMiniDialog}.
*/
export const useAttributedMiniDialog = () => {
const [miniDialogAttributes, setMiniDialogAttributes] = useState<
MiniDialogAttributes | undefined
>();
const [openMiniDialog, setOpenMiniDialog] = useState(false);
const showMiniDialog = useCallback((attributes: MiniDialogAttributes) => {
setMiniDialogAttributes(attributes);
setOpenMiniDialog(true);
}, []);
const onCloseMiniDialog = useCallback(() => setOpenMiniDialog(false), []);
return {
showMiniDialog,
miniDialogProps: {
open: openMiniDialog,
onClose: onCloseMiniDialog,
attributes: miniDialogAttributes,
},
};
};
/**
* A convenience function to construct {@link MiniDialogAttributes} for showing
* error dialogs.
*
* It takes one or two arguments.
*
* - If both are provided, then the first one is taken as the title and the
* second one as the message.
*
* - Otherwise it sets a default title and use the only argument as the message.
*/
export const errorDialogAttributes = (
messageOrTitle: string,
optionalMessage?: string,
): MiniDialogAttributes => {
const title = optionalMessage ? messageOrTitle : t("error");
const message = optionalMessage ? optionalMessage : messageOrTitle;
return {
title,
icon: <ErrorOutline />,
message,
continue: { color: "critical" },
cancel: false,
};
};
export const genericErrorDialogAttributes = () =>
errorDialogAttributes(t("generic_error"));

View File

@@ -13,9 +13,14 @@ module.exports = {
"react/jsx-no-target-blank": ["warn", { allowReferrer: true }],
/* Otherwise we need to do unnecessary boilerplating when using memo. */
"react/display-name": "off",
/* Apparently Fast refresh only works if a file only exports components,
and this rule warns about that. Constants are okay though (otherwise
we'll need to create unnecessary helper files). */
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
/* Next.js supports the JSX transform introduced in React 17 */
"react/react-in-jsx-scope": "off",
},
};

View File

@@ -27,7 +27,7 @@ import {
import { t } from "i18next";
import React, { useEffect, useState, useSyncExternalStore } from "react";
import { Trans } from "react-i18next";
import { useAppContext, type AppContextT } from "../types/context";
import { useAppContext } from "../types/context";
import { openURL } from "../utils/web";
import { useWrapAsyncOperation } from "./use-wrap-async";
@@ -36,8 +36,6 @@ export const MLSettings: React.FC<NestedDrawerVisibilityProps> = ({
onClose,
onRootClose,
}) => {
const { setDialogBoxAttributesV2 } = useAppContext();
const mlStatus = useSyncExternalStore(mlStatusSubscribe, mlStatusSnapshot);
const [openFaceConsent, setOpenFaceConsent] = useState(false);
@@ -68,10 +66,7 @@ export const MLSettings: React.FC<NestedDrawerVisibilityProps> = ({
component = <EnableML onEnable={handleEnableML} />;
} else {
component = (
<ManageML
{...{ mlStatus, setDialogBoxAttributesV2 }}
onDisableML={handleDisableML}
/>
<ManageML {...{ mlStatus }} onDisableML={handleDisableML} />
);
}
@@ -252,15 +247,11 @@ interface ManageMLProps {
mlStatus: Exclude<MLStatus, { phase: "disabled" }>;
/** Called when the user wants to disable ML. */
onDisableML: () => void;
/** Subset of appContext. */
setDialogBoxAttributesV2: AppContextT["setDialogBoxAttributesV2"];
}
const ManageML: React.FC<ManageMLProps> = ({
mlStatus,
onDisableML,
setDialogBoxAttributesV2,
}) => {
const ManageML: React.FC<ManageMLProps> = ({ mlStatus, onDisableML }) => {
const { showMiniDialog } = useAppContext();
const { phase, nSyncedFiles, nTotalFiles } = mlStatus;
let status: string;
@@ -289,19 +280,17 @@ const ManageML: React.FC<ManageMLProps> = ({
? `${Math.round((100 * nSyncedFiles) / nTotalFiles)}%`
: `${nSyncedFiles} / ${nTotalFiles}`;
const confirmDisableML = () => {
setDialogBoxAttributesV2({
const confirmDisableML = () =>
showMiniDialog({
title: t("ml_search_disable"),
content: t("ml_search_disable_confirm"),
close: { text: t("cancel") },
proceed: {
variant: "critical",
message: t("ml_search_disable_confirm"),
continue: {
text: t("disable"),
color: "critical",
action: onDisableML,
},
buttonDirection: "row",
});
};
return (
<Stack px={"16px"} py={"20px"} gap={4}>

View File

@@ -1,5 +1,4 @@
import type { ParsedMetadataDate } from "@/media/file-metadata";
import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer";
import {
LocalizationProvider,
MobileDateTimePicker,
@@ -7,6 +6,7 @@ import {
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import React, { useState } from "react";
import { fileInfoDrawerZIndex } from "./z-index";
interface PhotoDateTimePickerProps {
/**
@@ -105,7 +105,7 @@ export const PhotoDateTimePicker: React.FC<PhotoDateTimePickerProps> = ({
photo viewer and the info drawer */
dialog: {
sx: {
zIndex: photoSwipeZIndex + 2,
zIndex: fileInfoDrawerZIndex + 1,
},
},
}}

View File

@@ -1,5 +0,0 @@
/**
* PhotoSwipe sets the zIndex of its "pswp" class to 1500. We need to go higher
* than that for our drawers and dialogs to show above it.
*/
export const photoSwipeZIndex = 1500;

View File

@@ -92,7 +92,7 @@ const CGroupPersonOptions: React.FC<CGroupPersonOptionsProps> = ({
cgroup,
onSelectPerson,
}) => {
const { setDialogBoxAttributesV2 } = useAppContext();
const { showMiniDialog } = useAppContext();
const [openAddNameInput, setOpenAddNameInput] = useState(false);
@@ -103,17 +103,16 @@ const CGroupPersonOptions: React.FC<CGroupPersonOptionsProps> = ({
);
const handleDeletePerson = () =>
setDialogBoxAttributesV2({
showMiniDialog({
title: pt("Reset person?"),
content: pt(
message: pt(
"The name, face groupings and suggestions for this person will be reset",
),
close: { text: t("cancel") },
proceed: {
continue: {
text: t("reset"),
color: "primary",
action: deletePerson,
},
buttonDirection: "row",
});
const deletePerson = useWrapAsyncOperation(async () => {

View File

@@ -0,0 +1,17 @@
/**
* PhotoSwipe sets the zIndex of its "pswp" class to 1500. We need to go higher
* than that for our drawers and dialogs to get them to show above it.
*/
export const photoSwipeZIndex = 1500;
/**
* The file info drawer needs to be higher than the photo viewer.
*/
export const fileInfoDrawerZIndex = photoSwipeZIndex + 1;
/**
* Dialogs (not necessarily always) need to be higher still so to ensure they
* are visible above the drawer in case they are shown in response to some
* action taken in the file info drawer.
*/
export const photosDialogZIndex = 1600;

View File

@@ -17,10 +17,10 @@ export type AppContextT = AccountsContextT & {
* Hide the global activity indicator.
*/
finishLoading: () => void;
onGenericError: (error: unknown) => void;
somethingWentWrong: () => void;
setDialogMessage: SetDialogBoxAttributes;
setNotificationAttributes: SetNotificationAttributes;
onGenericError: (error: unknown) => void;
closeMessageDialog: () => void;
mapEnabled: boolean;
updateMapEnabled: (enabled: boolean) => Promise<void>;

View File

@@ -52,7 +52,7 @@ export const getComponents = (
// This is not a great choice either, usually most dialogs, for
// one reason or the other, will need to customize this padding
// anyway. But not resetting it to 16px leaves it at the MUI
// defaults, which just don't work with our designs.
// defaults, which just doesn't work well with our designs.
"& .MuiDialogTitle-root": {
// MUI default is '16px 24px'.
padding: "16px",
@@ -66,9 +66,9 @@ export const getComponents = (
overflowY: "auto",
},
"& .MuiDialogActions-root": {
// MUI default is way since they cluster the buttons to the
// right, our designs usually want the buttons to align with
// the heading / content.
// MUI default is way off for us since they cluster the
// buttons to the right, while our designs usually want the
// buttons to align with the heading / content.
padding: "16px",
},
".MuiDialogTitle-root + .MuiDialogContent-root": {