diff --git a/web/public/static/images/main_list_icon.svg b/web/public/static/images/main_list_icon.svg
new file mode 100644
index 00000000..12adf157
--- /dev/null
+++ b/web/public/static/images/main_list_icon.svg
@@ -0,0 +1,171 @@
+
+
+
+
diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js
index 4f28f87e..287aab61 100644
--- a/web/src/app/Prefs.js
+++ b/web/src/app/Prefs.js
@@ -6,6 +6,19 @@ export const THEME = {
SYSTEM: "system",
};
+// Key under which the theme preference is mirrored to localStorage. The inline script in
+// index.html reads it synchronously to pick the splash background before first paint.
+export const THEME_LOCALSTORAGE_KEY = "theme";
+
+const mirrorThemeToLocalStorage = (value) => {
+ try {
+ localStorage.setItem(THEME_LOCALSTORAGE_KEY, value);
+ } catch (e) {
+ // localStorage may be unavailable (private mode, disabled cookies); the splash just falls
+ // back to the system color scheme in that case.
+ }
+};
+
class Prefs {
constructor(dbImpl) {
this.db = dbImpl;
@@ -49,11 +62,17 @@ class Prefs {
async theme() {
const theme = await this.db.prefs.get("theme");
- return theme?.value ?? THEME.SYSTEM;
+ const value = theme?.value ?? THEME.SYSTEM;
+ // Mirror to localStorage so the inline script in index.html can pick the splash background
+ // synchronously before first paint. Self-heals for users who set their theme before the
+ // mirror existed.
+ mirrorThemeToLocalStorage(value);
+ return value;
}
async setTheme(mode) {
await this.db.prefs.put({ key: "theme", value: mode });
+ mirrorThemeToLocalStorage(mode);
}
}
diff --git a/web/src/app/splash.js b/web/src/app/splash.js
new file mode 100644
index 00000000..11af12e8
--- /dev/null
+++ b/web/src/app/splash.js
@@ -0,0 +1,64 @@
+// Fades out and removes the static splash screen baked into index.html (see web/index.html).
+// The splash paints before the JS bundle loads to avoid the white flash + spinner flicker on
+// first load; the app calls this once it has mounted and the initial data is ready. Idempotent --
+// safe to call from multiple routes/effects.
+
+// Keep the splash up for at least this long so it doesn't flash-and-vanish on fast (warm-cache)
+// loads -- the logo gets a beat to be seen (and to pulse) before fading out.
+const MIN_VISIBLE_MS = 1000;
+
+// Hide in two phases: first fade the (pulsing) logo all the way out, then fade the background away
+// to reveal -- "fade in" -- the app underneath. APP_FADE_MS must match the #splash opacity
+// transition in index.html.
+const LOGO_FADE_MS = 300;
+const APP_FADE_MS = 100;
+
+let removed = false;
+
+const fadeOutAndRemove = () => {
+ const splash = document.getElementById("splash");
+ if (!splash) {
+ return;
+ }
+
+ // Phase 1: fade the logo out completely. Freeze the pulse at its current opacity first, then
+ // transition to 0 -- otherwise stopping the animation would snap the logo to full opacity.
+ const img = splash.querySelector("img");
+ if (img) {
+ const current = getComputedStyle(img).opacity;
+ img.style.opacity = current;
+ img.style.animation = "none";
+ img.getBoundingClientRect(); // force reflow so the fade starts from `current`, not the snapped value
+ img.style.transition = `opacity ${LOGO_FADE_MS}ms ease-out`;
+ img.style.opacity = "0";
+ }
+
+ // Phase 2: once the logo is gone, lift the background to fade the app in, then remove the node.
+ setTimeout(() => {
+ splash.classList.add("ntfy-splash-hidden");
+ const remove = () => splash.remove();
+ // Only react to the background's own opacity transition -- the logo's transitionend bubbles up
+ // here too, and would otherwise remove the splash before the app has finished fading in.
+ const onEnd = (event) => {
+ if (event.target === splash) {
+ splash.removeEventListener("transitionend", onEnd);
+ remove();
+ }
+ };
+ splash.addEventListener("transitionend", onEnd);
+ setTimeout(remove, APP_FADE_MS + 100); // fallback if transitionend never fires
+ }, LOGO_FADE_MS);
+};
+
+const hideSplash = () => {
+ if (removed) {
+ return;
+ }
+ removed = true;
+ // performance.now() is the time since the page started loading, i.e. roughly how long the splash
+ // has been visible. Hold it until MIN_VISIBLE_MS has elapsed before fading out.
+ const remaining = Math.max(0, MIN_VISIBLE_MS - performance.now());
+ setTimeout(fadeOutAndRemove, remaining);
+};
+
+export default hideSplash;
diff --git a/web/src/app/transition.js b/web/src/app/transition.js
new file mode 100644
index 00000000..314ea218
--- /dev/null
+++ b/web/src/app/transition.js
@@ -0,0 +1,44 @@
+// Fade transitions for navigating between the main app and the auth pages (login/signup/reset).
+// We fade the whole app (#root) out, then either navigate client-side and fade back in, or do a
+// full reload (where the splash screen in index.html fades the next page in).
+
+const FADE_MS = 150;
+
+// Fade #root out and return it (or null if not found / no document).
+const fadeOutRoot = () => {
+ const node = document.getElementById("root");
+ if (node) {
+ node.style.transition = `opacity ${FADE_MS}ms ease-out`;
+ node.style.opacity = "0";
+ }
+ return node;
+};
+
+// Fade the app out, run a client-side navigation, then fade the new page back in. Used for
+// app -> login/signup, which stay within the same document (no reload).
+export const fadeNavigate = (navigate, to) => {
+ const node = fadeOutRoot();
+ if (!node) {
+ navigate(to);
+ return;
+ }
+ setTimeout(() => {
+ navigate(to);
+ // Fade back in on the next frame, once the new page has rendered.
+ requestAnimationFrame(() => {
+ node.style.opacity = "1";
+ });
+ }, FADE_MS);
+};
+
+// Fade the app out, then do a full page reload to `url`. Used for login/signup -> app, which must
+// reload (the per-user IndexedDB changes). The splash screen fades the reloaded app back in.
+export const fadeReload = (url) => {
+ if (!fadeOutRoot()) {
+ window.location.href = url;
+ return;
+ }
+ setTimeout(() => {
+ window.location.href = url;
+ }, FADE_MS);
+};
diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx
index ffbe32a9..c5bbd9c7 100644
--- a/web/src/components/Account.jsx
+++ b/web/src/components/Account.jsx
@@ -191,7 +191,7 @@ const ChangePasswordDialog = (props) => {
};
return (
-