[photosd] Add a "What's new" dialog (#2124)
This commit is contained in:
@@ -46,7 +46,12 @@ import {
|
||||
computeCLIPTextEmbeddingIfAvailable,
|
||||
} from "./services/ml-clip";
|
||||
import { computeFaceEmbeddings, detectFaces } from "./services/ml-face";
|
||||
import { encryptionKey, saveEncryptionKey } from "./services/store";
|
||||
import {
|
||||
encryptionKey,
|
||||
lastShownChangelogVersion,
|
||||
saveEncryptionKey,
|
||||
setLastShownChangelogVersion,
|
||||
} from "./services/store";
|
||||
import {
|
||||
clearPendingUploads,
|
||||
listZipItems,
|
||||
@@ -101,11 +106,19 @@ export const attachIPCHandlers = () => {
|
||||
|
||||
ipcMain.handle("selectDirectory", () => selectDirectory());
|
||||
|
||||
ipcMain.handle("encryptionKey", () => encryptionKey());
|
||||
|
||||
ipcMain.handle("saveEncryptionKey", (_, encryptionKey: string) =>
|
||||
saveEncryptionKey(encryptionKey),
|
||||
);
|
||||
|
||||
ipcMain.handle("encryptionKey", () => encryptionKey());
|
||||
ipcMain.handle("lastShownChangelogVersion", () =>
|
||||
lastShownChangelogVersion(),
|
||||
);
|
||||
|
||||
ipcMain.handle("setLastShownChangelogVersion", (_, version: number) =>
|
||||
setLastShownChangelogVersion(version),
|
||||
);
|
||||
|
||||
// - App update
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { safeStorage } from "electron/main";
|
||||
import { safeStorageStore } from "../stores/safe-storage";
|
||||
import { uploadStatusStore } from "../stores/upload-status";
|
||||
import { userPreferences } from "../stores/user-preferences";
|
||||
import { watchStore } from "../stores/watch";
|
||||
|
||||
/**
|
||||
* Clear all stores except user preferences.
|
||||
*
|
||||
* This is useful to reset state when the user logs out.
|
||||
* This function is useful to reset state when the user logs out. User
|
||||
* preferences are preserved since they contain things tied to the person using
|
||||
* the app or other machine specific state not tied to the account they were
|
||||
* using inside the app.
|
||||
*/
|
||||
export const clearStores = () => {
|
||||
safeStorageStore.clear();
|
||||
@@ -32,3 +36,9 @@ export const encryptionKey = (): string | undefined => {
|
||||
const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
|
||||
return safeStorage.decryptString(keyBuffer);
|
||||
};
|
||||
|
||||
export const lastShownChangelogVersion = (): number | undefined =>
|
||||
userPreferences.get("lastShownChangelogVersion");
|
||||
|
||||
export const setLastShownChangelogVersion = (version: number) =>
|
||||
userPreferences.set("lastShownChangelogVersion", version);
|
||||
|
||||
@@ -9,6 +9,12 @@ interface UserPreferences {
|
||||
hideDockIcon?: boolean;
|
||||
skipAppVersion?: string;
|
||||
muteUpdateNotificationVersion?: string;
|
||||
/**
|
||||
* The changelog version for which we last showed the "What's new" screen.
|
||||
*
|
||||
* See: [Note: Conditions for showing "What's new"]
|
||||
*/
|
||||
lastShownChangelogVersion?: number;
|
||||
/**
|
||||
* The last position and size of our app's window.
|
||||
*
|
||||
@@ -33,6 +39,7 @@ const userPreferencesSchema: Schema<UserPreferences> = {
|
||||
hideDockIcon: { type: "boolean" },
|
||||
skipAppVersion: { type: "string" },
|
||||
muteUpdateNotificationVersion: { type: "string" },
|
||||
lastShownChangelogVersion: { type: "number" },
|
||||
windowBounds: {
|
||||
properties: {
|
||||
x: { type: "number" },
|
||||
|
||||
@@ -72,12 +72,18 @@ const encryptionKey = () => ipcRenderer.invoke("encryptionKey");
|
||||
const saveEncryptionKey = (encryptionKey: string) =>
|
||||
ipcRenderer.invoke("saveEncryptionKey", encryptionKey);
|
||||
|
||||
const onMainWindowFocus = (cb?: () => void) => {
|
||||
const lastShownChangelogVersion = () =>
|
||||
ipcRenderer.invoke("lastShownChangelogVersion");
|
||||
|
||||
const setLastShownChangelogVersion = (version: number) =>
|
||||
ipcRenderer.invoke("setLastShownChangelogVersion", version);
|
||||
|
||||
const onMainWindowFocus = (cb: (() => void) | undefined) => {
|
||||
ipcRenderer.removeAllListeners("mainWindowFocus");
|
||||
if (cb) ipcRenderer.on("mainWindowFocus", cb);
|
||||
};
|
||||
|
||||
const onOpenURL = (cb?: (url: string) => void) => {
|
||||
const onOpenURL = (cb: ((url: string) => void) | undefined) => {
|
||||
ipcRenderer.removeAllListeners("openURL");
|
||||
if (cb) ipcRenderer.on("openURL", (_, url: string) => cb(url));
|
||||
};
|
||||
@@ -85,7 +91,7 @@ const onOpenURL = (cb?: (url: string) => void) => {
|
||||
// - App update
|
||||
|
||||
const onAppUpdateAvailable = (
|
||||
cb?: ((update: AppUpdate) => void) | undefined,
|
||||
cb: ((update: AppUpdate) => void) | undefined,
|
||||
) => {
|
||||
ipcRenderer.removeAllListeners("appUpdateAvailable");
|
||||
if (cb) {
|
||||
@@ -311,6 +317,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
logout,
|
||||
encryptionKey,
|
||||
saveEncryptionKey,
|
||||
lastShownChangelogVersion,
|
||||
setLastShownChangelogVersion,
|
||||
onMainWindowFocus,
|
||||
onOpenURL,
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WhatsNew } from "@/new/photos/components/WhatsNew";
|
||||
import { CustomHead } from "@/next/components/Head";
|
||||
import { setAppNameForAuthenticatedRequests } from "@/next/http";
|
||||
import { setupI18n } from "@/next/i18n";
|
||||
@@ -130,10 +129,6 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
>();
|
||||
useState<DialogBoxAttributes>(null);
|
||||
const [messageDialogView, setMessageDialogView] = useState(false);
|
||||
// TODO(MR): This is never true currently, this is the WIP ability to show
|
||||
// what's new dialog on desktop app updates. The UI is done, need to hook
|
||||
// this up to logic to trigger it.
|
||||
const [openWhatsNew, setOpenWhatsNew] = useState(false);
|
||||
const [dialogBoxV2View, setDialogBoxV2View] = useState(false);
|
||||
const [watchFolderView, setWatchFolderView] = useState(false);
|
||||
const [watchFolderFiles, setWatchFolderFiles] = useState<FileList>(null);
|
||||
@@ -407,11 +402,6 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
attributes={dialogBoxAttributeV2}
|
||||
/>
|
||||
|
||||
<WhatsNew
|
||||
open={openWhatsNew}
|
||||
onClose={() => setOpenWhatsNew(false)}
|
||||
/>
|
||||
|
||||
<Notification
|
||||
open={notificationView}
|
||||
onClose={closeNotification}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { WhatsNew } from "@/new/photos/components/WhatsNew";
|
||||
import { shouldShowWhatsNew } from "@/new/photos/services/changelog";
|
||||
import { fetchAndSaveFeatureFlagsIfNeeded } from "@/new/photos/services/feature-flags";
|
||||
import log from "@/next/log";
|
||||
import { CenteredFlex } from "@ente/shared/components/Container";
|
||||
@@ -193,6 +195,10 @@ export default function Gallery() {
|
||||
const [search, setSearch] = useState<Search>(null);
|
||||
const [shouldDisableDropzone, setShouldDisableDropzone] = useState(false);
|
||||
const [isPhotoSwipeOpen, setIsPhotoSwipeOpen] = useState(false);
|
||||
// TODO(MR): This is never true currently, this is the WIP ability to show
|
||||
// what's new dialog on desktop app updates. The UI is done, need to hook
|
||||
// this up to logic to trigger it.
|
||||
const [openWhatsNew, setOpenWhatsNew] = useState(false);
|
||||
|
||||
const {
|
||||
// A function to call to get the props we should apply to the container,
|
||||
@@ -386,6 +392,7 @@ export default function Gallery() {
|
||||
if (electron) {
|
||||
// void clipService.setupOnFileUploadListener();
|
||||
electron.onMainWindowFocus(() => syncWithRemote(false, true));
|
||||
if (await shouldShowWhatsNew()) setOpenWhatsNew(true);
|
||||
}
|
||||
};
|
||||
main();
|
||||
@@ -1154,6 +1161,10 @@ export default function Gallery() {
|
||||
sidebarView={sidebarView}
|
||||
closeSidebar={closeSidebar}
|
||||
/>
|
||||
<WhatsNew
|
||||
open={openWhatsNew}
|
||||
onClose={() => setOpenWhatsNew(false)}
|
||||
/>
|
||||
{!isInSearchMode &&
|
||||
!isFirstLoad &&
|
||||
!files?.length &&
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Typography,
|
||||
styled,
|
||||
useMediaQuery,
|
||||
} from "@mui/material";
|
||||
import Slide from "@mui/material/Slide";
|
||||
import type { TransitionProps } from "@mui/material/transitions";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { didShowWhatsNew } from "../services/changelog";
|
||||
|
||||
interface WhatsNewProps {
|
||||
/** If `true`, then the dialog is shown. */
|
||||
@@ -21,12 +23,16 @@ interface WhatsNewProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a dialog showing a short summary of interesting-for-the-user things in
|
||||
* this release of the desktop app.
|
||||
* Show a dialog showing a short summary of interesting-for-the-user things
|
||||
* since the last time this dialog was shown.
|
||||
*/
|
||||
export const WhatsNew: React.FC<WhatsNewProps> = ({ open, onClose }) => {
|
||||
const fullScreen = useMediaQuery("(max-width: 428px)");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void didShowWhatsNew();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...{ open, fullScreen }}
|
||||
@@ -36,12 +42,7 @@ export const WhatsNew: React.FC<WhatsNewProps> = ({ open, onClose }) => {
|
||||
<DialogTitle>{"What's new"}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<StyledUL>
|
||||
<li>
|
||||
The app will remember its position and size when it
|
||||
is closed, and will reopen the same way.
|
||||
</li>
|
||||
</StyledUL>
|
||||
<ChangelogContent />
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -68,11 +69,37 @@ const SlideTransition = React.forwardRef(function Transition(
|
||||
return <Slide direction="up" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
const ChangelogContent: React.FC = () => {
|
||||
// NOTE: Remember to update changelogVersion when changing the content
|
||||
// below.
|
||||
|
||||
return (
|
||||
<StyledUL>
|
||||
<li>
|
||||
<Typography>
|
||||
<Typography color="primary">
|
||||
Support for Passkeys
|
||||
</Typography>
|
||||
Passkeys can now be used as a second factor authentication
|
||||
mechanism.
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography color="primary">Window size</Typography>
|
||||
<Typography>
|
||||
{"The app's window will remember its size and position."}
|
||||
</Typography>
|
||||
</li>
|
||||
</StyledUL>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledUL = styled("ul")`
|
||||
padding-inline: 1rem;
|
||||
list-style-type: circle;
|
||||
|
||||
margin-block-end: 20px;
|
||||
li {
|
||||
margin-block: 2rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
|
||||
39
web/packages/new/photos/services/changelog.ts
Normal file
39
web/packages/new/photos/services/changelog.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ensureElectron } from "@/next/electron";
|
||||
|
||||
/**
|
||||
* The current changelog version.
|
||||
*
|
||||
* [Note: Conditions for showing "What's new"]
|
||||
*
|
||||
* We maintain a "changelog version". This version is an incrementing positive
|
||||
* integer, we increment it whenever we want to show this dialog again. Usually
|
||||
* we'd do this for each app update, but not necessarily.
|
||||
*
|
||||
* The "What's new" dialog is shown when either we do not have a previously
|
||||
* saved changelog version, or if the saved changelog version is less than the
|
||||
* current {@link changelogVersion}.
|
||||
*
|
||||
* The shown changelog version is persisted on the Node.js layer since there we
|
||||
* can store it in the user preferences store, which is not cleared on logout.
|
||||
*
|
||||
* On app start, the Node.js layer waits for the {@link onShowWhatsNew} callback
|
||||
* to get attached. When a callback is attached, it checks the above conditions
|
||||
* and if they are satisfied, it invokes the callback. The callback should
|
||||
* return the current {@link changelogVersion} to allow the Node.js layer to
|
||||
* update the persisted state.
|
||||
*/
|
||||
const changelogVersion = 1;
|
||||
|
||||
/**
|
||||
* Return true if we should show the {@link WhatsNew} dialog.
|
||||
*/
|
||||
export const shouldShowWhatsNew = async () => {
|
||||
const electron = globalThis.electron;
|
||||
if (!electron) return false;
|
||||
const lastShownVersion = (await electron.lastShownChangelogVersion()) ?? 0;
|
||||
return lastShownVersion < changelogVersion;
|
||||
};
|
||||
|
||||
export const didShowWhatsNew = async () =>
|
||||
// We should only have been called if we're in electron.
|
||||
ensureElectron().setLastShownChangelogVersion(changelogVersion);
|
||||
@@ -87,12 +87,12 @@ export interface Electron {
|
||||
* into the foreground. More precisely, the callback gets invoked when the
|
||||
* main window gets focus.
|
||||
*
|
||||
* Note: Setting a callback clears any previous callbacks.
|
||||
* Setting a callback clears any previous callbacks.
|
||||
*
|
||||
* @param cb The function to call when the main window gets focus. Pass
|
||||
* `undefined` to clear the callback.
|
||||
*/
|
||||
onMainWindowFocus: (cb?: () => void) => void;
|
||||
onMainWindowFocus: (cb: (() => void) | undefined) => void;
|
||||
|
||||
/**
|
||||
* Set or clear the callback {@link cb} to invoke whenever the app gets
|
||||
@@ -103,13 +103,13 @@ export interface Electron {
|
||||
* In particular, this is necessary for handling passkey authentication.
|
||||
* See: [Note: Passkey verification in the desktop app]
|
||||
*
|
||||
* Note: Setting a callback clears any previous callbacks.
|
||||
* Setting a callback clears any previous callbacks.
|
||||
*
|
||||
* @param cb The function to call when the app gets asked to open a
|
||||
* "ente://" URL. The URL string (a.k.a. "deeplink") we were asked to open
|
||||
* is passed to the function verbatim.
|
||||
*/
|
||||
onOpenURL: (cb?: (url: string) => void) => void;
|
||||
onOpenURL: (cb: ((url: string) => void) | undefined) => void;
|
||||
|
||||
// - App update
|
||||
|
||||
@@ -118,10 +118,10 @@ export interface Electron {
|
||||
* (actionable) app update is available. This allows the Node.js layer to
|
||||
* ask the renderer to show an "Update available" dialog to the user.
|
||||
*
|
||||
* Note: Setting a callback clears any previous callbacks.
|
||||
* Setting a callback clears any previous callbacks.
|
||||
*/
|
||||
onAppUpdateAvailable: (
|
||||
cb?: ((update: AppUpdate) => void) | undefined,
|
||||
cb: ((update: AppUpdate) => void) | undefined,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
@@ -148,6 +148,23 @@ export interface Electron {
|
||||
*/
|
||||
skipAppUpdate: (version: string) => void;
|
||||
|
||||
/**
|
||||
* Get the persisted version for the last shown changelog.
|
||||
*
|
||||
* See: [Note: Conditions for showing "What's new"]
|
||||
*/
|
||||
lastShownChangelogVersion: () => Promise<number | undefined>;
|
||||
|
||||
/**
|
||||
* Save the given {@link version} to disk as the version of the last shown
|
||||
* changelog.
|
||||
*
|
||||
* The value is saved to a store which is not cleared during logout.
|
||||
*
|
||||
* @see {@link lastShownChangelogVersion}
|
||||
*/
|
||||
setLastShownChangelogVersion: (version: number) => Promise<void>;
|
||||
|
||||
// - FS
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user