diff --git a/docs/releases.md b/docs/releases.md
index 26e0f744..1e9d749b 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -1987,8 +1987,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
**Bug fixes + maintenance:**
-* `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
-* The web app no longer issues a redundant second `GET /account` after email actions (e.g. setting a primary email); it relies on the sync-topic event for the refresh
+* Web app: Smooth transitions and loading animation, remove flickering
+* Web app: `GET /account` now reads from the primary database instead of a read replica, so the account view no longer shows stale data right after a change when replicas lag behind
### ntfy Android v1.25.x (UNRELEASED)
diff --git a/web/index.html b/web/index.html
index 462bbc1f..c714263c 100644
--- a/web/index.html
+++ b/web/index.html
@@ -14,7 +14,7 @@
-
+
@@ -44,6 +44,85 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/public/static/images/mask-icon.svg b/web/public/static/images/ntfy-mask.svg
similarity index 100%
rename from web/public/static/images/mask-icon.svg
rename to web/public/static/images/ntfy-mask.svg
diff --git a/web/public/static/images/ntfy-splash.svg b/web/public/static/images/ntfy-splash.svg
new file mode 100644
index 00000000..a2815598
--- /dev/null
+++ b/web/public/static/images/ntfy-splash.svg
@@ -0,0 +1,13 @@
+
diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js
index 4f28f87e..e77fd3cb 100644
--- a/web/src/app/Prefs.js
+++ b/web/src/app/Prefs.js
@@ -6,6 +6,15 @@ export const THEME = {
SYSTEM: "system",
};
+// Default values the getters return when a pref is unset; also used by PrefCache (PrefCache.jsx).
+export const PREF_DEFAULTS = {
+ sound: "ding",
+ minPriority: 1,
+ deleteAfter: 604800, // one week
+ theme: THEME.SYSTEM,
+ webPushEnabled: false,
+};
+
class Prefs {
constructor(dbImpl) {
this.db = dbImpl;
@@ -17,7 +26,7 @@ class Prefs {
async sound() {
const sound = await this.db.prefs.get("sound");
- return sound ? sound.value : "ding";
+ return sound ? sound.value : PREF_DEFAULTS.sound;
}
async setMinPriority(minPriority) {
@@ -26,7 +35,7 @@ class Prefs {
async minPriority() {
const minPriority = await this.db.prefs.get("minPriority");
- return minPriority ? Number(minPriority.value) : 1;
+ return minPriority ? Number(minPriority.value) : PREF_DEFAULTS.minPriority;
}
async setDeleteAfter(deleteAfter) {
@@ -35,12 +44,12 @@ class Prefs {
async deleteAfter() {
const deleteAfter = await this.db.prefs.get("deleteAfter");
- return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
+ return deleteAfter ? Number(deleteAfter.value) : PREF_DEFAULTS.deleteAfter;
}
async webPushEnabled() {
const webPushEnabled = await this.db.prefs.get("webPushEnabled");
- return webPushEnabled?.value;
+ return webPushEnabled?.value ?? PREF_DEFAULTS.webPushEnabled;
}
async setWebPushEnabled(enabled) {
@@ -49,7 +58,7 @@ class Prefs {
async theme() {
const theme = await this.db.prefs.get("theme");
- return theme?.value ?? THEME.SYSTEM;
+ return theme?.value ?? PREF_DEFAULTS.theme;
}
async setTheme(mode) {
diff --git a/web/src/app/Session.js b/web/src/app/Session.js
index 976e9f3c..8db620e7 100644
--- a/web/src/app/Session.js
+++ b/web/src/app/Session.js
@@ -1,4 +1,5 @@
import Dexie from "dexie";
+import { fadeOut } from "./transition";
/**
* Manages the logged-in user's session and access token.
@@ -42,7 +43,12 @@ class Session {
localStorage.setItem("token", token);
}
- async resetAndRedirect(url) {
+ async resetAndRedirect(url, { fade = false } = {}) {
+ // User-initiated exits (logout, account deletion) fade out first, while data's intact, so the
+ // wipe + reload doesn't flash a broken UI. Error redirects pass no options and cut straight through.
+ if (fade) {
+ await fadeOut();
+ }
await this.db.delete();
localStorage.removeItem("user");
localStorage.removeItem("token");
diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js
index 2453075a..0fb1ef96 100644
--- a/web/src/app/notificationUtils.js
+++ b/web/src/app/notificationUtils.js
@@ -49,7 +49,7 @@ export const isImage = (attachment) => {
};
export const icon = "/static/images/ntfy.png";
-export const badge = "/static/images/mask-icon.svg";
+export const badge = "/static/images/ntfy-mask.svg";
/**
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
diff --git a/web/src/app/splash.js b/web/src/app/splash.js
new file mode 100644
index 00000000..55988cf4
--- /dev/null
+++ b/web/src/app/splash.js
@@ -0,0 +1,58 @@
+// Fades out and removes the static splash (see web/index.html), called once the app has mounted and
+// its data is ready. Idempotent.
+
+// Minimum time the splash stays up, so it doesn't flash-and-vanish on warm-cache loads.
+const MIN_VISIBLE_MS = 1000;
+
+// Hide in two phases: fade the logo out, then fade the background away to reveal the app.
+// 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: freeze the pulse at its current opacity (else stopping the animation snaps to full),
+ // then fade the logo to 0.
+ 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`
+ img.style.transition = `opacity ${LOGO_FADE_MS}ms ease-out`;
+ img.style.opacity = "0";
+ }
+
+ // Phase 2: lift the background to fade the app in, then remove the node.
+ setTimeout(() => {
+ splash.classList.add("splash-hidden");
+ const remove = () => splash.remove();
+ // Ignore the logo's bubbling transitionend; only the background's own fade should remove it.
+ 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() ~= how long the splash has been visible; hold until MIN_VISIBLE_MS elapses.
+ 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..a9d5d0df
--- /dev/null
+++ b/web/src/app/transition.js
@@ -0,0 +1,49 @@
+// Fade transitions between the app and the auth pages (login/signup/reset): fade #root out, then
+// navigate client-side (fading back in) or full-reload (the splash fades the next page in).
+
+const FADE_MS = 150;
+const rootNode = () => document.getElementById("root");
+
+// Fade #root out, resolving when done. Exported so callers can await it before their own
+// teardown + reload (e.g. resetAndRedirect wiping IndexedDB).
+export const fadeOut = () =>
+ new Promise((resolve) => {
+ const node = rootNode();
+ if (!node) {
+ resolve();
+ return;
+ }
+ node.style.transition = `opacity ${FADE_MS}ms ease-out`;
+ node.style.opacity = "0";
+ setTimeout(resolve, FADE_MS);
+ });
+
+// Fade #root back in, then strip the inline styles (a lingering `transition` would animate future
+// opacity changes). setTimeout, not rAF, so a backgrounded tab can't strand #root at opacity 0.
+const fadeInRoot = () => {
+ const node = rootNode();
+ if (!node) {
+ return;
+ }
+ node.style.opacity = "1";
+ setTimeout(() => {
+ node.style.transition = "";
+ node.style.opacity = "";
+ }, FADE_MS);
+};
+
+// Fade out, navigate client-side, fade the new page in (app -> login/signup, no reload).
+export const fadeNavigate = (navigate, to) => {
+ fadeOut().then(() => {
+ navigate(to);
+ fadeInRoot();
+ });
+};
+
+// Fade out, then full-reload to `url` (login/signup -> app needs a reload for the per-user DB).
+// The splash fades it back in.
+export const fadeReload = (url) => {
+ fadeOut().then(() => {
+ window.location.href = url;
+ });
+};
diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx
index 3369e68e..be0a4a51 100644
--- a/web/src/components/Account.jsx
+++ b/web/src/components/Account.jsx
@@ -191,7 +191,7 @@ const ChangePasswordDialog = (props) => {
};
return (
-