WIP: Web app transitions

This commit is contained in:
binwiederhier
2026-06-24 15:27:40 -04:00
parent 87e9dc9da4
commit 2b1f2d6b9a
15 changed files with 543 additions and 57 deletions
+20 -1
View File
@@ -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);
}
}
+64
View File
@@ -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;
+44
View File
@@ -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);
};
+2 -2
View File
@@ -191,7 +191,7 @@ const ChangePasswordDialog = (props) => {
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
<DialogContent>
<TextField
@@ -774,7 +774,7 @@ const AddPhoneNumberDialog = (props) => {
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_phone_numbers_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("account_basics_phone_numbers_dialog_description")}</DialogContentText>
+14 -2
View File
@@ -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 = () => {
</IconButton>
)}
{!session.exists() && config.enable_login && (
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{ m: 1 }} aria-label={t("action_bar_sign_in")}>
<Button
color="inherit"
variant="text"
onClick={() => fadeNavigate(navigate, routes.login)}
sx={{ m: 1 }}
aria-label={t("action_bar_sign_in")}
>
{t("action_bar_sign_in")}
</Button>
)}
{!session.exists() && config.enable_signup && (
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}>
<Button
color="inherit"
variant="outlined"
onClick={() => fadeNavigate(navigate, routes.signup)}
aria-label={t("action_bar_sign_up")}
>
{t("action_bar_sign_up")}
</Button>
)}
+45 -9
View File
@@ -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 <html> 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 = () => {
<CssBaseline />
<ErrorBoundary>
<Routes>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} />
<Route path={routes.passwordReset} element={<PasswordReset />} />
<Route path={routes.emailVerify} element={<EmailVerify />} />
<Route element={<AuthLayout />}>
<Route path={routes.login} element={<Login />} />
<Route path={routes.signup} element={<Signup />} />
<Route path={routes.passwordResetRequest} element={<PasswordResetRequest />} />
<Route path={routes.passwordReset} element={<PasswordReset />} />
<Route path={routes.emailVerify} element={<EmailVerify />} />
</Route>
<Route element={<Layout />}>
<Route path={routes.app} element={<AllSubscriptions />} />
<Route path={routes.account} element={<Account />} />
@@ -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 <Outlet />;
};
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 (
<Box sx={{ display: "flex" }}>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
@@ -128,6 +163,7 @@ const Layout = () => {
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
allNotifications,
}}
/>
</Main>
+18 -2
View File
@@ -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 = <Avatar sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }} src={logo} variant="rounded" />;
// 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 (
<AvatarBoxContainer>
{/* The logo links back to the app, unless login is forced (no app to go back to without signing in) */}
{config.require_login ? avatar : <NavLink to={routes.app}>{avatar}</NavLink>}
{config.require_login ? (
avatar
) : (
<Box component="a" href={routes.app} onClick={handleLogoClick} sx={{ cursor: "pointer", lineHeight: 0 }}>
{avatar}
</Box>
)}
{props.children}
</AvatarBoxContainer>
);
+2 -1
View File
@@ -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) {
+34 -20
View File
@@ -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 <Loading />;
// 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 <DeferredLoading />;
}
return <AllSubscriptionsList subscriptions={subscriptions} />;
return <AllSubscriptionsList subscriptions={subscriptions} notifications={allNotifications} />;
};
export const SingleSubscription = () => {
const { subscriptions, selected } = useOutletContext();
const { subscriptions, selected, allNotifications } = useOutletContext();
useAutoSubscribe(subscriptions, selected);
if (!selected) {
return <Loading />;
if (!selected || allNotifications === null || allNotifications === undefined) {
return <DeferredLoading />;
}
return <SingleSubscriptionList subscription={selected} />;
return <SingleSubscriptionList subscription={selected} allNotifications={allNotifications} />;
};
const AllSubscriptionsList = (props) => {
const { subscriptions } = props;
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
if (notifications === null || notifications === undefined) {
return <Loading />;
}
const { subscriptions, notifications } = props;
if (subscriptions.length === 0) {
return <NoSubscriptions />;
}
@@ -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 <Loading />;
}
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 <NoNotifications subscription={subscription} />;
}
@@ -669,3 +669,17 @@ const Loading = () => {
</VerticallyCenteredContainer>
);
};
// 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 ? <Loading /> : null;
};
+7 -18
View File
@@ -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 (
<Pref labelId={labelId} title={t("prefs_appearance_theme_title")}>
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
<Select value={theme ?? THEME.SYSTEM} onChange={handleChange} aria-labelledby={labelId}>
<Select value={theme} onChange={handleChange} aria-labelledby={labelId}>
<MenuItem value={THEME.SYSTEM}>{t("prefs_appearance_theme_system")}</MenuItem>
<MenuItem value={THEME.DARK}>{t("prefs_appearance_theme_dark")}</MenuItem>
<MenuItem value={THEME.LIGHT}>{t("prefs_appearance_theme_light")}</MenuItem>
@@ -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);
};
+1 -1
View File
@@ -292,7 +292,7 @@ const PublishDialog = (props) => {
return (
<>
{dropZone && <DropArea onDrop={handleAttachFileDrop} onDragLeave={handleAttachFileDragLeave} />}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<Dialog maxWidth="md" open={open} onClose={props.onClose} fullScreen={fullScreen}>
<DialogTitle>
{baseUrl && topic
? t("publish_dialog_title_topic", {
+2 -1
View File
@@ -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) {
+33
View File
@@ -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