From 2b1f2d6b9a3250ace81709cd566bdde9625e3821 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 24 Jun 2026 15:27:40 -0400 Subject: [PATCH 1/8] WIP: Web app transitions --- web/index.html | 86 ++++++++++ web/public/static/images/main_list_icon.svg | 171 ++++++++++++++++++++ web/src/app/Prefs.js | 21 ++- web/src/app/splash.js | 64 ++++++++ web/src/app/transition.js | 44 +++++ web/src/components/Account.jsx | 4 +- web/src/components/ActionBar.jsx | 16 +- web/src/components/App.jsx | 54 +++++-- web/src/components/AvatarBox.jsx | 20 ++- web/src/components/Login.jsx | 3 +- web/src/components/Notifications.jsx | 54 ++++--- web/src/components/Preferences.jsx | 25 +-- web/src/components/PublishDialog.jsx | 2 +- web/src/components/Signup.jsx | 3 +- web/src/components/hooks.js | 33 ++++ 15 files changed, 543 insertions(+), 57 deletions(-) create mode 100644 web/public/static/images/main_list_icon.svg create mode 100644 web/src/app/splash.js create mode 100644 web/src/app/transition.js diff --git a/web/index.html b/web/index.html index 462bbc1f..0267263e 100644 --- a/web/index.html +++ b/web/index.html @@ -44,6 +44,86 @@ + + + + + + + + +
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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + 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 ( - + {t("account_basics_password_dialog_title")} { }; return ( - + {t("account_basics_phone_numbers_dialog_title")} {t("account_basics_phone_numbers_dialog_description")} diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx index baef18c5..d3afe2c6 100644 --- a/web/src/components/ActionBar.jsx +++ b/web/src/components/ActionBar.jsx @@ -18,6 +18,7 @@ import subscriptionManager from "../app/SubscriptionManager"; import routes from "./routes"; import db from "../app/db"; import { topicDisplayName } from "../app/utils"; +import { fadeNavigate } from "../app/transition"; import Navigation from "./Navigation"; import accountApi from "../app/AccountApi"; import PopupMenu from "./PopupMenu"; @@ -184,12 +185,23 @@ const ProfileIcon = () => { )} {!session.exists() && config.enable_login && ( - )} {!session.exists() && config.enable_signup && ( - )} diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index b6893f20..0bc8c3c4 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -28,6 +28,7 @@ import prefs from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; import session from "../app/Session"; import AccountContext from "./AccountContext"; +import hideSplash from "../app/splash"; initI18n(); @@ -38,16 +39,30 @@ const App = () => { const accountMemo = useMemo(() => ({ account, setAccount }), [account, setAccount]); const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); const themePreference = useLiveQuery(() => prefs.theme()); - const theme = React.useMemo( - () => createTheme({ ...(darkModeEnabled(prefersDarkMode, themePreference) ? darkTheme : lightTheme), direction: languageDir }), - [prefersDarkMode, themePreference, languageDir] - ); + const isDark = darkModeEnabled(prefersDarkMode, themePreference); + const theme = React.useMemo(() => createTheme({ ...(isDark ? darkTheme : lightTheme), direction: languageDir }), [isDark, languageDir]); useEffect(() => { document.documentElement.setAttribute("lang", getKebabCaseLangStr(i18n.language)); document.dir = languageDir; }, [i18n.language, languageDir]); + // Keep the background (visible behind the app, e.g. overscroll) in sync with the resolved + // theme once the stored preference has loaded. The inline script in index.html sets the initial + // class before first paint; don't override it while the preference is still loading (undefined). + useEffect(() => { + if (themePreference === undefined) { + return; + } + document.documentElement.classList.toggle("ntfy-dark", isDark); + }, [isDark, themePreference]); + + // Safety net: never let the splash trap the UI if no route hides it (e.g. an unmatched path). + useEffect(() => { + const timer = setTimeout(() => hideSplash(), 3000); + return () => clearTimeout(timer); + }, []); + useEffect(() => { if (!session.exists() && config.require_login && window.location.pathname !== routes.login) { window.location.href = routes.login; @@ -63,11 +78,13 @@ const App = () => { - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + }> } /> } /> @@ -91,6 +108,13 @@ const updateTitle = (newNotificationsCount) => { updateFavicon(newNotificationsCount); }; +// Wraps the auth pages (login, signup, ...). They render synchronously with no async data, so the +// splash can be removed as soon as the page mounts. +const AuthLayout = () => { + useEffect(() => hideSplash(), []); + return ; +}; + const Layout = () => { const params = useParams(); const { account, setAccount } = useContext(AccountContext); @@ -98,6 +122,9 @@ const Layout = () => { const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); const users = useLiveQuery(() => userManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all()); + // Preloaded here (not in AllSubscriptions) so the "All notifications" view has its data ready on + // mount -- otherwise switching from a topic to All flashes an empty frame while the query runs. + const allNotifications = useLiveQuery(() => subscriptionManager.getAllNotifications()); const webPushTopics = useWebPushTopics(); const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal); const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; @@ -112,6 +139,14 @@ const Layout = () => { useBackgroundProcesses(); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); + // Reveal the app only once the subscriptions have loaded from IndexedDB, so the navigation and + // message list don't pop in empty-then-filled behind the splash. + useEffect(() => { + if (subscriptions !== undefined) { + hideSplash(); + } + }, [subscriptions]); + return ( setMobileDrawerOpen(!mobileDrawerOpen)} /> @@ -128,6 +163,7 @@ const Layout = () => { context={{ subscriptions: subscriptionsWithoutInternal, selected, + allNotifications, }} /> diff --git a/web/src/components/AvatarBox.jsx b/web/src/components/AvatarBox.jsx index d924a1c3..9f11f912 100644 --- a/web/src/components/AvatarBox.jsx +++ b/web/src/components/AvatarBox.jsx @@ -1,8 +1,9 @@ import * as React from "react"; import { Avatar, Box, styled } from "@mui/material"; -import { NavLink } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import logo from "../img/ntfy-filled.svg"; import routes from "./routes"; +import { fadeNavigate } from "../app/transition"; const AvatarBoxContainer = styled(Box)` display: flex; @@ -16,11 +17,26 @@ const AvatarBoxContainer = styled(Box)` margin: auto; `; const AvatarBox = (props) => { + const navigate = useNavigate(); const avatar = ; + // Fade back to the app instead of a hard cut. Let modifier-clicks (open in new tab, etc.) through. + const handleLogoClick = (ev) => { + if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) { + return; + } + ev.preventDefault(); + fadeNavigate(navigate, routes.app); + }; return ( {/* The logo links back to the app, unless login is forced (no app to go back to without signing in) */} - {config.require_login ? avatar : {avatar}} + {config.require_login ? ( + avatar + ) : ( + + {avatar} + + )} {props.children} ); diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index 31b61cd7..18eec626 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -11,6 +11,7 @@ import AvatarBox from "./AvatarBox"; import session from "../app/Session"; import routes from "./routes"; import { UnauthorizedError } from "../app/errors"; +import { fadeReload } from "../app/transition"; const Login = () => { const { t } = useTranslation(); @@ -26,7 +27,7 @@ const Login = () => { const token = await accountApi.login(user); console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); await session.store(user.username, token); - window.location.href = routes.app; + fadeReload(routes.app); } catch (e) { console.log(`[Login] User auth for user ${user.username} failed`, e); if (e instanceof UnauthorizedError) { diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index fa3050fa..7f5a845d 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -17,10 +17,9 @@ import { Button, } from "@mui/material"; import * as React from "react"; -import { Suspense, lazy, useEffect, useState } from "react"; +import { Suspense, lazy, useEffect, useMemo, useState } from "react"; import CheckIcon from "@mui/icons-material/Check"; import CloseIcon from "@mui/icons-material/Close"; -import { useLiveQuery } from "dexie-react-hooks"; import InfiniteScroll from "react-infinite-scroll-component"; import { Trans, useTranslation } from "react-i18next"; import { useOutletContext } from "react-router-dom"; @@ -55,28 +54,26 @@ const priorityFiles = { }; export const AllSubscriptions = () => { - const { subscriptions } = useOutletContext(); - if (!subscriptions) { - return ; + // allNotifications is preloaded in Layout (App.jsx) so this view has its data ready on mount and + // doesn't flash an empty frame when switching to it from a topic. + const { subscriptions, allNotifications } = useOutletContext(); + if (!subscriptions || allNotifications === null || allNotifications === undefined) { + return ; } - return ; + return ; }; export const SingleSubscription = () => { - const { subscriptions, selected } = useOutletContext(); + const { subscriptions, selected, allNotifications } = useOutletContext(); useAutoSubscribe(subscriptions, selected); - if (!selected) { - return ; + if (!selected || allNotifications === null || allNotifications === undefined) { + return ; } - return ; + return ; }; const AllSubscriptionsList = (props) => { - const { subscriptions } = props; - const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); - if (notifications === null || notifications === undefined) { - return ; - } + const { subscriptions, notifications } = props; if (subscriptions.length === 0) { return ; } @@ -87,11 +84,14 @@ const AllSubscriptionsList = (props) => { }; const SingleSubscriptionList = (props) => { - const { subscription } = props; - const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); - if (notifications === null || notifications === undefined) { - return ; - } + const { subscription, allNotifications } = props; + // Derived from the preloaded allNotifications by filtering -- getNotifications(id) is exactly + // getAllNotifications() filtered by subscriptionId, so this is the same data with no per-topic + // IndexedDB query, making switches to/between topics instant (no empty frame on mount). + const notifications = useMemo( + () => allNotifications.filter((notification) => notification.subscriptionId === subscription.id), + [allNotifications, subscription.id] + ); if (notifications.length === 0) { return ; } @@ -669,3 +669,17 @@ const Loading = () => { ); }; + +// Reading notifications from IndexedDB takes only tens of milliseconds, but switching topics (or +// going from "All notifications" to a single topic) remounts the list and briefly re-runs the +// query, which would flash the centered Loading spinner each time. Render nothing until the load +// has taken at least `delayMs`, so the spinner only appears for genuinely slow loads (large DB, +// slow device) and normal switches just swap content directly. +const DeferredLoading = ({ delayMs = 250 }) => { + const [show, setShow] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setShow(true), delayMs); + return () => clearTimeout(timer); + }, [delayMs]); + return show ? : null; +}; diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index b23e2713..e617deed 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -49,7 +49,7 @@ import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./Rese import { UnauthorizedError } from "../app/errors"; import { subscribeTopic } from "./SubscribeDialog"; import notifier from "../app/Notifier"; -import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks"; +import { useCachedPref, useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks"; const maybeUpdateAccountSettings = async (payload) => { if (!session.exists()) { @@ -99,7 +99,7 @@ const Notifications = () => { const Sound = () => { const { t } = useTranslation(); const labelId = "prefSound"; - const sound = useLiveQuery(async () => prefs.sound()); + const sound = useCachedPref(() => prefs.sound(), "pref.sound", "ding"); const handleChange = async (ev) => { await prefs.setSound(ev.target.value); await maybeUpdateAccountSettings({ @@ -108,9 +108,6 @@ const Sound = () => { }, }); }; - if (!sound) { - return null; // While loading - } let description; if (sound === "none") { description = t("prefs_notifications_sound_description_none"); @@ -143,7 +140,7 @@ const Sound = () => { const MinPriority = () => { const { t } = useTranslation(); const labelId = "prefMinPriority"; - const minPriority = useLiveQuery(async () => prefs.minPriority()); + const minPriority = useCachedPref(() => prefs.minPriority(), "pref.minPriority", 1); const handleChange = async (ev) => { await prefs.setMinPriority(ev.target.value); await maybeUpdateAccountSettings({ @@ -152,9 +149,6 @@ const MinPriority = () => { }, }); }; - if (!minPriority) { - return null; // While loading - } const priorities = { 1: t("priority_min"), 2: t("priority_low"), @@ -191,7 +185,7 @@ const MinPriority = () => { const DeleteAfter = () => { const { t } = useTranslation(); const labelId = "prefDeleteAfter"; - const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); + const deleteAfter = useCachedPref(() => prefs.deleteAfter(), "pref.deleteAfter", 604800); const handleChange = async (ev) => { await prefs.setDeleteAfter(ev.target.value); await maybeUpdateAccountSettings({ @@ -201,11 +195,6 @@ const DeleteAfter = () => { }); }; - if (deleteAfter === null || deleteAfter === undefined) { - // !deleteAfter will not work with "0" - return null; // While loading - } - const description = (() => { switch (deleteAfter) { case 0: @@ -241,7 +230,7 @@ const DeleteAfter = () => { const Theme = () => { const { t } = useTranslation(); const labelId = "prefTheme"; - const theme = useLiveQuery(async () => prefs.theme()); + const theme = useCachedPref(() => prefs.theme(), "pref.theme", THEME.SYSTEM); const handleChange = async (ev) => { await prefs.setTheme(ev.target.value); }; @@ -249,7 +238,7 @@ const Theme = () => { return ( - {t("prefs_appearance_theme_system")} {t("prefs_appearance_theme_dark")} {t("prefs_appearance_theme_light")} @@ -262,7 +251,7 @@ const Theme = () => { const WebPushEnabled = () => { const { t } = useTranslation(); const labelId = "prefWebPushEnabled"; - const enabled = useLiveQuery(async () => prefs.webPushEnabled()); + const enabled = useCachedPref(() => prefs.webPushEnabled(), "pref.webPushEnabled", false); const handleChange = async (ev) => { await prefs.setWebPushEnabled(ev.target.value); }; diff --git a/web/src/components/PublishDialog.jsx b/web/src/components/PublishDialog.jsx index aaa4e4ed..18844b65 100644 --- a/web/src/components/PublishDialog.jsx +++ b/web/src/components/PublishDialog.jsx @@ -292,7 +292,7 @@ const PublishDialog = (props) => { return ( <> {dropZone && } - + {baseUrl && topic ? t("publish_dialog_title_topic", { diff --git a/web/src/components/Signup.jsx b/web/src/components/Signup.jsx index ced9f7f5..81379d5b 100644 --- a/web/src/components/Signup.jsx +++ b/web/src/components/Signup.jsx @@ -11,6 +11,7 @@ import AvatarBox from "./AvatarBox"; import session from "../app/Session"; import routes from "./routes"; import { AccountActionLimitReachedError, UserExistsError } from "../app/errors"; +import { fadeReload } from "../app/transition"; const Signup = () => { const { t } = useTranslation(); @@ -30,7 +31,7 @@ const Signup = () => { const token = await accountApi.login(user); console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); await session.store(user.username, token); - window.location.href = routes.app; + fadeReload(routes.app); } catch (e) { console.log(`[Signup] Signup for user ${user.username} failed`, e); if (e instanceof UserExistsError) { diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index b2502b48..a4158c87 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -15,6 +15,39 @@ import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR, SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG } from "../app/events"; +/** + * Reads a preference via a Dexie live query, but renders the last known value from localStorage + * synchronously on first paint (and mirrors new values back to localStorage). This avoids the + * Settings page flicker where rows "pop in" one by one while IndexedDB resolves -- the cached value + * shows instantly and is confirmed/updated from IndexedDB in the background. + */ +export const useCachedPref = (reader, cacheKey, fallback) => { + const live = useLiveQuery(reader); + + useEffect(() => { + if (live !== undefined && live !== null) { + try { + localStorage.setItem(cacheKey, JSON.stringify(live)); + } catch (e) { + // localStorage may be unavailable (private mode); the value just isn't cached for next time + } + } + }, [live, cacheKey]); + + if (live !== undefined && live !== null) { + return live; + } + try { + const cached = localStorage.getItem(cacheKey); + if (cached !== null) { + return JSON.parse(cached); + } + } catch (e) { + // ignore parse/storage errors and fall back + } + return fallback; +}; + /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection * state changes. Conversely, when the subscription changes, the connection is refreshed (which may lead From 047a1258f13e3332d0df8877915f48ac46d7a393 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 24 Jun 2026 17:08:32 -0400 Subject: [PATCH 2/8] Transitions --- web/index.html | 16 +- web/public/static/images/main_list_icon.svg | 171 ------------------ .../images/{mask-icon.svg => ntfy-mask.svg} | 0 web/public/static/images/ntfy-splash.svg | 13 ++ web/src/app/notificationUtils.js | 2 +- web/src/app/splash.js | 2 +- web/src/app/transition.js | 24 ++- web/src/components/App.jsx | 2 +- 8 files changed, 42 insertions(+), 188 deletions(-) delete mode 100644 web/public/static/images/main_list_icon.svg rename web/public/static/images/{mask-icon.svg => ntfy-mask.svg} (100%) create mode 100644 web/public/static/images/ntfy-splash.svg diff --git a/web/index.html b/web/index.html index 0267263e..a2293b49 100644 --- a/web/index.html +++ b/web/index.html @@ -14,7 +14,7 @@ - + @@ -54,7 +54,7 @@ background-color: #f5f5f5; } - html.ntfy-dark { + html.dark { background-color: #212121; } @@ -71,11 +71,11 @@ transition: opacity 0.1s ease-out; } - html.ntfy-dark #splash { + html.dark #splash { background-color: #212121; } - #splash.ntfy-splash-hidden { + #splash.splash-hidden { opacity: 0; pointer-events: none; } @@ -84,10 +84,10 @@ width: 112px; height: 112px; /* Gently pulse while loading; src/app/splash.js stops this and fades the logo out. */ - animation: ntfy-splash-pulse 1.4s ease-in-out infinite; + animation: splash-pulse 1.4s ease-in-out infinite; } - @keyframes ntfy-splash-pulse { + @keyframes splash-pulse { 0%, 100% { opacity: 1; @@ -117,7 +117,7 @@ var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; var dark = stored === "dark" || ((!stored || stored === "system") && prefersDark); if (dark) { - document.documentElement.classList.add("ntfy-dark"); + document.documentElement.classList.add("dark"); } } catch (e) { /* localStorage/matchMedia unavailable -- fall back to the default light splash */ @@ -135,7 +135,7 @@
diff --git a/web/public/static/images/main_list_icon.svg b/web/public/static/images/main_list_icon.svg deleted file mode 100644 index 12adf157..00000000 --- a/web/public/static/images/main_list_icon.svg +++ /dev/null @@ -1,171 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - 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/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 index 11af12e8..85d9fffe 100644 --- a/web/src/app/splash.js +++ b/web/src/app/splash.js @@ -35,7 +35,7 @@ const fadeOutAndRemove = () => { // 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"); + splash.classList.add("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. diff --git a/web/src/app/transition.js b/web/src/app/transition.js index 314ea218..641d22a7 100644 --- a/web/src/app/transition.js +++ b/web/src/app/transition.js @@ -14,20 +14,32 @@ const fadeOutRoot = () => { return node; }; +// Fade #root back in, then remove the inline styles we added so nothing is left behind on the +// element -- otherwise the lingering `transition` would silently animate any future opacity change +// to #root. Uses setTimeout (not requestAnimationFrame) so a backgrounded tab can't strand #root +// at opacity 0; rAF can be paused entirely in background tabs, while timers still fire. +const fadeInRoot = () => { + const node = document.getElementById("root"); + if (!node) { + return; + } + node.style.opacity = "1"; + setTimeout(() => { + node.style.transition = ""; + node.style.opacity = ""; + }, FADE_MS); +}; + // 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) { + if (!fadeOutRoot()) { navigate(to); return; } setTimeout(() => { navigate(to); - // Fade back in on the next frame, once the new page has rendered. - requestAnimationFrame(() => { - node.style.opacity = "1"; - }); + fadeInRoot(); }, FADE_MS); }; diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 0bc8c3c4..cf22158e 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -54,7 +54,7 @@ const App = () => { if (themePreference === undefined) { return; } - document.documentElement.classList.toggle("ntfy-dark", isDark); + document.documentElement.classList.toggle("dark", isDark); }, [isDark, themePreference]); // Safety net: never let the splash trap the UI if no route hides it (e.g. an unmatched path). From a47eef50065dc73838c98959f1e4f2db34c784ae Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 25 Jun 2026 22:33:25 -0400 Subject: [PATCH 3/8] Centralize fade out --- web/src/app/Session.js | 9 +++++++- web/src/app/transition.js | 35 ++++++++++++++---------------- web/src/components/Account.jsx | 2 +- web/src/components/ActionBar.jsx | 2 +- web/src/components/Preferences.jsx | 7 ++++-- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 976e9f3c..58c69ceb 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,13 @@ class Session { localStorage.setItem("token", token); } - async resetAndRedirect(url) { + async resetAndRedirect(url, { fade = false } = {}) { + // For user-initiated exits (logout, account deletion) fade the app out first -- while the data + // is still intact -- so the wipe + reload below doesn't flash a broken/empty UI. Error-driven + // redirects (e.g. an expired session) pass no options and cut straight to the target page. + if (fade) { + await fadeOut(); + } await this.db.delete(); localStorage.removeItem("user"); localStorage.removeItem("token"); diff --git a/web/src/app/transition.js b/web/src/app/transition.js index 641d22a7..173348c5 100644 --- a/web/src/app/transition.js +++ b/web/src/app/transition.js @@ -4,15 +4,20 @@ 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) { +// Fade #root out over FADE_MS, resolving once the fade has finished (immediately if #root isn't in +// the document). Exported so callers that need to run their own teardown between the fade-out and +// the reload -- e.g. session.resetAndRedirect() wiping IndexedDB -- can await it first. +export const fadeOut = () => + new Promise((resolve) => { + const node = document.getElementById("root"); + if (!node) { + resolve(); + return; + } node.style.transition = `opacity ${FADE_MS}ms ease-out`; node.style.opacity = "0"; - } - return node; -}; + setTimeout(resolve, FADE_MS); + }); // Fade #root back in, then remove the inline styles we added so nothing is left behind on the // element -- otherwise the lingering `transition` would silently animate any future opacity change @@ -33,24 +38,16 @@ const fadeInRoot = () => { // 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) => { - if (!fadeOutRoot()) { - navigate(to); - return; - } - setTimeout(() => { + fadeOut().then(() => { navigate(to); fadeInRoot(); - }, 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()) { + fadeOut().then(() => { 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 b22d0167..be0a4a51 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -1396,7 +1396,7 @@ const DeleteAccountDialog = (props) => { await accountApi.delete(password); await db().delete(); console.debug(`[Account] Account deleted`); - await session.resetAndRedirect(routes.app); + await session.resetAndRedirect(routes.app, { fade: true }); } catch (e) { console.log(`[Account] Error deleting account`, e); if (e instanceof IncorrectPasswordError) { diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx index d3afe2c6..13483f9c 100644 --- a/web/src/components/ActionBar.jsx +++ b/web/src/components/ActionBar.jsx @@ -173,7 +173,7 @@ const ProfileIcon = () => { await accountApi.logout(); await db().delete(); } finally { - await session.resetAndRedirect(routes.app); + await session.resetAndRedirect(routes.app, { fade: true }); } }; diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index e617deed..f057ed31 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -43,7 +43,7 @@ import accountApi, { Permission, Role } from "../app/AccountApi"; import { Pref, PrefGroup } from "./Pref"; import AccountContext from "./AccountContext"; import { Paragraph } from "./styles"; -import prefs, { THEME } from "../app/Prefs"; +import prefs, { THEME, THEME_LOCALSTORAGE_KEY } from "../app/Prefs"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; @@ -230,7 +230,10 @@ const DeleteAfter = () => { const Theme = () => { const { t } = useTranslation(); const labelId = "prefTheme"; - const theme = useCachedPref(() => prefs.theme(), "pref.theme", THEME.SYSTEM); + // Reuse the same localStorage key Prefs already mirrors for the splash (THEME_LOCALSTORAGE_KEY), + // rather than a second cache key, so the value isn't stored twice. The cached value renders the + // dropdown instantly; the live query then confirms it from IndexedDB. + const theme = useLiveQuery(() => prefs.theme()) ?? localStorage.getItem(THEME_LOCALSTORAGE_KEY) ?? THEME.SYSTEM; const handleChange = async (ev) => { await prefs.setTheme(ev.target.value); }; From af66cc3ddc1c72e7b162b43eaf513d5740d86f7d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 25 Jun 2026 22:40:34 -0400 Subject: [PATCH 4/8] Undo theme weirdness --- web/src/components/Preferences.jsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index f057ed31..e617deed 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -43,7 +43,7 @@ import accountApi, { Permission, Role } from "../app/AccountApi"; import { Pref, PrefGroup } from "./Pref"; import AccountContext from "./AccountContext"; import { Paragraph } from "./styles"; -import prefs, { THEME, THEME_LOCALSTORAGE_KEY } from "../app/Prefs"; +import prefs, { THEME } from "../app/Prefs"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; @@ -230,10 +230,7 @@ const DeleteAfter = () => { const Theme = () => { const { t } = useTranslation(); const labelId = "prefTheme"; - // Reuse the same localStorage key Prefs already mirrors for the splash (THEME_LOCALSTORAGE_KEY), - // rather than a second cache key, so the value isn't stored twice. The cached value renders the - // dropdown instantly; the live query then confirms it from IndexedDB. - const theme = useLiveQuery(() => prefs.theme()) ?? localStorage.getItem(THEME_LOCALSTORAGE_KEY) ?? THEME.SYSTEM; + const theme = useCachedPref(() => prefs.theme(), "pref.theme", THEME.SYSTEM); const handleChange = async (ev) => { await prefs.setTheme(ev.target.value); }; From 9114d1e996fb366ab3be3318260278257db9b095 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 26 Jun 2026 06:20:58 -0400 Subject: [PATCH 5/8] Pref context --- web/src/app/Prefs.js | 20 ++++++++++---- web/src/components/App.jsx | 43 ++++++++++++++++-------------- web/src/components/PrefCache.jsx | 26 ++++++++++++++++++ web/src/components/Preferences.jsx | 13 ++++----- web/src/components/hooks.js | 33 ----------------------- 5 files changed, 71 insertions(+), 64 deletions(-) create mode 100644 web/src/components/PrefCache.jsx diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index 287aab61..762a48ef 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -10,6 +10,16 @@ export const THEME = { // index.html reads it synchronously to pick the splash background before first paint. export const THEME_LOCALSTORAGE_KEY = "theme"; +// Default values returned by the getters below when a preference hasn't been set. Single source of +// truth, also used by the PrefCache (see PrefCache.jsx) for its loading-state placeholder. +export const PREF_DEFAULTS = { + sound: "ding", + minPriority: 1, + deleteAfter: 604800, // one week + theme: THEME.SYSTEM, + webPushEnabled: false, +}; + const mirrorThemeToLocalStorage = (value) => { try { localStorage.setItem(THEME_LOCALSTORAGE_KEY, value); @@ -30,7 +40,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) { @@ -39,7 +49,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) { @@ -48,12 +58,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) { @@ -62,7 +72,7 @@ class Prefs { async theme() { const theme = await this.db.prefs.get("theme"); - const value = theme?.value ?? THEME.SYSTEM; + const value = theme?.value ?? PREF_DEFAULTS.theme; // 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. diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index cf22158e..e6aaeed5 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -28,6 +28,7 @@ import prefs from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; import session from "../app/Session"; import AccountContext from "./AccountContext"; +import { PrefCacheProvider } from "./PrefCache"; import hideSplash from "../app/splash"; initI18n(); @@ -148,27 +149,29 @@ const Layout = () => { }, [subscriptions]); return ( - - setMobileDrawerOpen(!mobileDrawerOpen)} /> - setMobileDrawerOpen(!mobileDrawerOpen)} - onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} - /> -
- - + + setMobileDrawerOpen(!mobileDrawerOpen)} /> + setMobileDrawerOpen(!mobileDrawerOpen)} + onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} /> -
- -
+
+ + +
+ +
+ ); }; diff --git a/web/src/components/PrefCache.jsx b/web/src/components/PrefCache.jsx new file mode 100644 index 00000000..7fe68438 --- /dev/null +++ b/web/src/components/PrefCache.jsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { createContext, useContext } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; +import prefs, { PREF_DEFAULTS } from "../app/Prefs"; + +// A render-time CACHE of the user's preferences -- NOT the source of truth. The source of truth is +// the `prefs` table in IndexedDB, always read/written via Prefs.js. This context preloads all prefs +// once (mounted by Layout, above the routed content), so the Settings page renders its controls +// with the right values immediately instead of each one flickering in as its own IndexedDB read +// resolves. By the time you navigate to Settings the query has already resolved. +const PrefCacheContext = createContext(undefined); + +export const PrefCacheProvider = ({ children }) => { + const cache = useLiveQuery(async () => ({ + sound: await prefs.sound(), + minPriority: await prefs.minPriority(), + deleteAfter: await prefs.deleteAfter(), + theme: await prefs.theme(), + webPushEnabled: await prefs.webPushEnabled(), + })); + return {children}; +}; + +// While the cache is still loading (only on the very first paint, mostly hidden by the splash) fall +// back to the same defaults the Prefs getters use, so controls never render with no value. +export const usePrefCache = () => useContext(PrefCacheContext) ?? PREF_DEFAULTS; diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index e617deed..6b5f3e03 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -49,7 +49,8 @@ import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./Rese import { UnauthorizedError } from "../app/errors"; import { subscribeTopic } from "./SubscribeDialog"; import notifier from "../app/Notifier"; -import { useCachedPref, useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks"; +import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks"; +import { usePrefCache } from "./PrefCache"; const maybeUpdateAccountSettings = async (payload) => { if (!session.exists()) { @@ -99,7 +100,7 @@ const Notifications = () => { const Sound = () => { const { t } = useTranslation(); const labelId = "prefSound"; - const sound = useCachedPref(() => prefs.sound(), "pref.sound", "ding"); + const { sound } = usePrefCache(); const handleChange = async (ev) => { await prefs.setSound(ev.target.value); await maybeUpdateAccountSettings({ @@ -140,7 +141,7 @@ const Sound = () => { const MinPriority = () => { const { t } = useTranslation(); const labelId = "prefMinPriority"; - const minPriority = useCachedPref(() => prefs.minPriority(), "pref.minPriority", 1); + const { minPriority } = usePrefCache(); const handleChange = async (ev) => { await prefs.setMinPriority(ev.target.value); await maybeUpdateAccountSettings({ @@ -185,7 +186,7 @@ const MinPriority = () => { const DeleteAfter = () => { const { t } = useTranslation(); const labelId = "prefDeleteAfter"; - const deleteAfter = useCachedPref(() => prefs.deleteAfter(), "pref.deleteAfter", 604800); + const { deleteAfter } = usePrefCache(); const handleChange = async (ev) => { await prefs.setDeleteAfter(ev.target.value); await maybeUpdateAccountSettings({ @@ -230,7 +231,7 @@ const DeleteAfter = () => { const Theme = () => { const { t } = useTranslation(); const labelId = "prefTheme"; - const theme = useCachedPref(() => prefs.theme(), "pref.theme", THEME.SYSTEM); + const { theme } = usePrefCache(); const handleChange = async (ev) => { await prefs.setTheme(ev.target.value); }; @@ -251,7 +252,7 @@ const Theme = () => { const WebPushEnabled = () => { const { t } = useTranslation(); const labelId = "prefWebPushEnabled"; - const enabled = useCachedPref(() => prefs.webPushEnabled(), "pref.webPushEnabled", false); + const { webPushEnabled: enabled } = usePrefCache(); const handleChange = async (ev) => { await prefs.setWebPushEnabled(ev.target.value); }; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index a4158c87..b2502b48 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -15,39 +15,6 @@ import notifier from "../app/Notifier"; import prefs from "../app/Prefs"; import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR, SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG } from "../app/events"; -/** - * Reads a preference via a Dexie live query, but renders the last known value from localStorage - * synchronously on first paint (and mirrors new values back to localStorage). This avoids the - * Settings page flicker where rows "pop in" one by one while IndexedDB resolves -- the cached value - * shows instantly and is confirmed/updated from IndexedDB in the background. - */ -export const useCachedPref = (reader, cacheKey, fallback) => { - const live = useLiveQuery(reader); - - useEffect(() => { - if (live !== undefined && live !== null) { - try { - localStorage.setItem(cacheKey, JSON.stringify(live)); - } catch (e) { - // localStorage may be unavailable (private mode); the value just isn't cached for next time - } - } - }, [live, cacheKey]); - - if (live !== undefined && live !== null) { - return live; - } - try { - const cached = localStorage.getItem(cacheKey); - if (cached !== null) { - return JSON.parse(cached); - } - } catch (e) { - // ignore parse/storage errors and fall back - } - return fallback; -}; - /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection * state changes. Conversely, when the subscription changes, the connection is refreshed (which may lead From 7a99bfc717086398830769fe8e5e110fa9b47e9b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 26 Jun 2026 07:58:28 -0400 Subject: [PATCH 6/8] COndense comments, better prefcache use for theme --- web/index.html | 20 ++++++------ web/src/app/Prefs.js | 24 ++------------- web/src/app/Session.js | 5 ++- web/src/app/splash.js | 28 +++++++---------- web/src/app/transition.js | 23 ++++++-------- web/src/components/App.jsx | 17 +++++----- web/src/components/Notifications.jsx | 15 +++------ web/src/components/PrefCache.jsx | 46 ++++++++++++++++++++++------ 8 files changed, 82 insertions(+), 96 deletions(-) diff --git a/web/index.html b/web/index.html index a2293b49..c714263c 100644 --- a/web/index.html +++ b/web/index.html @@ -45,10 +45,8 @@ - + - + @@ -132,8 +131,7 @@ subscribe. - + diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index 762a48ef..e77fd3cb 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -6,12 +6,7 @@ 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"; - -// Default values returned by the getters below when a preference hasn't been set. Single source of -// truth, also used by the PrefCache (see PrefCache.jsx) for its loading-state placeholder. +// Default values the getters return when a pref is unset; also used by PrefCache (PrefCache.jsx). export const PREF_DEFAULTS = { sound: "ding", minPriority: 1, @@ -20,15 +15,6 @@ export const PREF_DEFAULTS = { webPushEnabled: false, }; -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; @@ -72,17 +58,11 @@ class Prefs { async theme() { const theme = await this.db.prefs.get("theme"); - const value = theme?.value ?? PREF_DEFAULTS.theme; - // 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; + return theme?.value ?? PREF_DEFAULTS.theme; } async setTheme(mode) { await this.db.prefs.put({ key: "theme", value: mode }); - mirrorThemeToLocalStorage(mode); } } diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 58c69ceb..8db620e7 100644 --- a/web/src/app/Session.js +++ b/web/src/app/Session.js @@ -44,9 +44,8 @@ class Session { } async resetAndRedirect(url, { fade = false } = {}) { - // For user-initiated exits (logout, account deletion) fade the app out first -- while the data - // is still intact -- so the wipe + reload below doesn't flash a broken/empty UI. Error-driven - // redirects (e.g. an expired session) pass no options and cut straight to the target page. + // 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(); } diff --git a/web/src/app/splash.js b/web/src/app/splash.js index 85d9fffe..55988cf4 100644 --- a/web/src/app/splash.js +++ b/web/src/app/splash.js @@ -1,15 +1,11 @@ -// 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. +// Fades out and removes the static splash (see web/index.html), called once the app has mounted and +// its data is ready. Idempotent. -// 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. +// 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: 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. +// 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; @@ -21,24 +17,23 @@ const fadeOutAndRemove = () => { 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. + // 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`, not the snapped value + 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: once the logo is gone, lift the background to fade the app in, then remove the node. + // Phase 2: lift the background to fade the app in, then remove the node. setTimeout(() => { splash.classList.add("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. + // 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); @@ -55,8 +50,7 @@ const hideSplash = () => { 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. + // 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); }; diff --git a/web/src/app/transition.js b/web/src/app/transition.js index 173348c5..51e0e168 100644 --- a/web/src/app/transition.js +++ b/web/src/app/transition.js @@ -1,12 +1,10 @@ -// 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). +// 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; -// Fade #root out over FADE_MS, resolving once the fade has finished (immediately if #root isn't in -// the document). Exported so callers that need to run their own teardown between the fade-out and -// the reload -- e.g. session.resetAndRedirect() wiping IndexedDB -- can await it first. +// 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 = document.getElementById("root"); @@ -19,10 +17,8 @@ export const fadeOut = () => setTimeout(resolve, FADE_MS); }); -// Fade #root back in, then remove the inline styles we added so nothing is left behind on the -// element -- otherwise the lingering `transition` would silently animate any future opacity change -// to #root. Uses setTimeout (not requestAnimationFrame) so a backgrounded tab can't strand #root -// at opacity 0; rAF can be paused entirely in background tabs, while timers still fire. +// 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 = document.getElementById("root"); if (!node) { @@ -35,8 +31,7 @@ const fadeInRoot = () => { }, FADE_MS); }; -// 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). +// Fade out, navigate client-side, fade the new page in (app -> login/signup, no reload). export const fadeNavigate = (navigate, to) => { fadeOut().then(() => { navigate(to); @@ -44,8 +39,8 @@ export const fadeNavigate = (navigate, to) => { }); }; -// 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. +// 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/App.jsx b/web/src/components/App.jsx index e6aaeed5..a68d56f7 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -48,9 +48,8 @@ const App = () => { document.dir = languageDir; }, [i18n.language, languageDir]); - // Keep the background (visible behind the app, e.g. overscroll) in sync with the resolved - // theme once the stored preference has loaded. The inline script in index.html sets the initial - // class before first paint; don't override it while the preference is still loading (undefined). + // Keep the background in sync with the theme once loaded. The splash script sets the + // initial class; don't override it while the preference is still loading. useEffect(() => { if (themePreference === undefined) { return; @@ -58,7 +57,7 @@ const App = () => { document.documentElement.classList.toggle("dark", isDark); }, [isDark, themePreference]); - // Safety net: never let the splash trap the UI if no route hides it (e.g. an unmatched path). + // Safety net: hide the splash even if no route does (e.g. an unmatched path). useEffect(() => { const timer = setTimeout(() => hideSplash(), 3000); return () => clearTimeout(timer); @@ -109,8 +108,7 @@ const updateTitle = (newNotificationsCount) => { updateFavicon(newNotificationsCount); }; -// Wraps the auth pages (login, signup, ...). They render synchronously with no async data, so the -// splash can be removed as soon as the page mounts. +// Auth pages render synchronously, so the splash can be removed on mount. const AuthLayout = () => { useEffect(() => hideSplash(), []); return ; @@ -123,8 +121,8 @@ const Layout = () => { const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); const users = useLiveQuery(() => userManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all()); - // Preloaded here (not in AllSubscriptions) so the "All notifications" view has its data ready on - // mount -- otherwise switching from a topic to All flashes an empty frame while the query runs. + // Preloaded here so the All view (and single topics, via filter) have data on mount -- no empty + // frame when switching. const allNotifications = useLiveQuery(() => subscriptionManager.getAllNotifications()); const webPushTopics = useWebPushTopics(); const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal); @@ -140,8 +138,7 @@ const Layout = () => { useBackgroundProcesses(); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); - // Reveal the app only once the subscriptions have loaded from IndexedDB, so the navigation and - // message list don't pop in empty-then-filled behind the splash. + // Hide the splash only once subscriptions have loaded, so the nav/list don't pop in empty-then-filled. useEffect(() => { if (subscriptions !== undefined) { hideSplash(); diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 7f5a845d..37c7b270 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -54,8 +54,7 @@ const priorityFiles = { }; export const AllSubscriptions = () => { - // allNotifications is preloaded in Layout (App.jsx) so this view has its data ready on mount and - // doesn't flash an empty frame when switching to it from a topic. + // allNotifications is preloaded in Layout, so this view has its data on mount (no empty frame on switch). const { subscriptions, allNotifications } = useOutletContext(); if (!subscriptions || allNotifications === null || allNotifications === undefined) { return ; @@ -85,9 +84,8 @@ const AllSubscriptionsList = (props) => { const SingleSubscriptionList = (props) => { const { subscription, allNotifications } = props; - // Derived from the preloaded allNotifications by filtering -- getNotifications(id) is exactly - // getAllNotifications() filtered by subscriptionId, so this is the same data with no per-topic - // IndexedDB query, making switches to/between topics instant (no empty frame on mount). + // Filter the preloaded allNotifications instead of a per-topic query (getNotifications(id) == + // getAllNotifications() filtered by id), so topic switches are instant. const notifications = useMemo( () => allNotifications.filter((notification) => notification.subscriptionId === subscription.id), [allNotifications, subscription.id] @@ -670,11 +668,8 @@ const Loading = () => { ); }; -// Reading notifications from IndexedDB takes only tens of milliseconds, but switching topics (or -// going from "All notifications" to a single topic) remounts the list and briefly re-runs the -// query, which would flash the centered Loading spinner each time. Render nothing until the load -// has taken at least `delayMs`, so the spinner only appears for genuinely slow loads (large DB, -// slow device) and normal switches just swap content directly. +// Render nothing until a load takes at least `delayMs`, so the centered spinner only shows on +// genuinely slow loads -- normal sub-frame IndexedDB reads don't flash it on every remount. const DeferredLoading = ({ delayMs = 250 }) => { const [show, setShow] = useState(false); useEffect(() => { diff --git a/web/src/components/PrefCache.jsx b/web/src/components/PrefCache.jsx index 7fe68438..09c491af 100644 --- a/web/src/components/PrefCache.jsx +++ b/web/src/components/PrefCache.jsx @@ -1,15 +1,31 @@ import * as React from "react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect } from "react"; import { useLiveQuery } from "dexie-react-hooks"; import prefs, { PREF_DEFAULTS } from "../app/Prefs"; -// A render-time CACHE of the user's preferences -- NOT the source of truth. The source of truth is -// the `prefs` table in IndexedDB, always read/written via Prefs.js. This context preloads all prefs -// once (mounted by Layout, above the routed content), so the Settings page renders its controls -// with the right values immediately instead of each one flickering in as its own IndexedDB read -// resolves. By the time you navigate to Settings the query has already resolved. +// A CACHE of the user's prefs -- not the source of truth (that's the `prefs` IndexedDB table, via +// Prefs.js). Preloaded once in Layout so Settings renders instantly, and written through to +// localStorage on every change so (a) Settings is instant even on a cold load and (b) the inline +// splash script in index.html can read the theme synchronously before the bundle loads. The +// "prefcache" key is duplicated in that script -- keep them in sync. +const PREFCACHE_LOCALSTORAGE_KEY = "prefcache"; + const PrefCacheContext = createContext(undefined); +// Synchronous fallback before the live query resolves. Merged over PREF_DEFAULTS so a newly-added +// pref still has a value. +const readPersistedCache = () => { + try { + const raw = localStorage.getItem(PREFCACHE_LOCALSTORAGE_KEY); + if (raw) { + return { ...PREF_DEFAULTS, ...JSON.parse(raw) }; + } + } catch (e) { + // malformed or unavailable storage -- fall back to defaults + } + return PREF_DEFAULTS; +}; + export const PrefCacheProvider = ({ children }) => { const cache = useLiveQuery(async () => ({ sound: await prefs.sound(), @@ -18,9 +34,21 @@ export const PrefCacheProvider = ({ children }) => { theme: await prefs.theme(), webPushEnabled: await prefs.webPushEnabled(), })); + + // Write through to localStorage on change (prefs change rarely). + useEffect(() => { + if (cache !== undefined) { + try { + localStorage.setItem(PREFCACHE_LOCALSTORAGE_KEY, JSON.stringify(cache)); + } catch (e) { + // localStorage may be unavailable (private mode) -- the cache just isn't persisted + } + } + }, [cache]); + return {children}; }; -// While the cache is still loading (only on the very first paint, mostly hidden by the splash) fall -// back to the same defaults the Prefs getters use, so controls never render with no value. -export const usePrefCache = () => useContext(PrefCacheContext) ?? PREF_DEFAULTS; +// Live context once resolved; else the synchronous localStorage snapshot (instant on cold load); +// else defaults. +export const usePrefCache = () => useContext(PrefCacheContext) ?? readPersistedCache(); From 5765be78925dd1e1badee7ca02412bbfcf1b1b02 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 26 Jun 2026 15:59:34 -0400 Subject: [PATCH 7/8] Root ref --- web/src/app/transition.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/app/transition.js b/web/src/app/transition.js index 51e0e168..a9d5d0df 100644 --- a/web/src/app/transition.js +++ b/web/src/app/transition.js @@ -2,12 +2,13 @@ // 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 = document.getElementById("root"); + const node = rootNode(); if (!node) { resolve(); return; @@ -20,7 +21,7 @@ export const fadeOut = () => // 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 = document.getElementById("root"); + const node = rootNode(); if (!node) { return; } From c9105dad09d15b3705ed58df83a75a7866516b8b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 26 Jun 2026 16:01:18 -0400 Subject: [PATCH 8/8] Release notes --- docs/releases.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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)