Merge pull request #1802 from binwiederhier/transitions

Transitions
This commit is contained in:
Philipp C. Heckel
2026-06-26 16:01:45 -04:00
committed by GitHub
19 changed files with 424 additions and 86 deletions
+2 -2
View File
@@ -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)
+85 -1
View File
@@ -14,7 +14,7 @@
<meta name="msapplication-navbutton-color" content="#317f6f" />
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" />
<link rel="mask-icon" href="/static/images/mask-icon.svg" color="#317f6f" />
<link rel="mask-icon" href="/static/images/ntfy-mask.svg" color="#317f6f" />
<!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
@@ -44,6 +44,85 @@
<!-- PWA -->
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Splash: painted before the JS bundle loads, faded out by the app once ready (see
src/app/splash.js). Background matches MUI's grey[100]/grey[900] for a seamless handoff. -->
<style>
html {
background-color: #f5f5f5;
}
html.dark {
background-color: #212121;
}
#splash {
position: fixed;
inset: 0;
z-index: 200000;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
opacity: 1;
/* Background fade = the app fading in once the logo is gone (see src/app/splash.js). */
transition: opacity 0.1s ease-out;
}
html.dark #splash {
background-color: #212121;
}
#splash.splash-hidden {
opacity: 0;
pointer-events: none;
}
#splash img {
width: 112px;
height: 112px;
/* Gently pulse while loading; src/app/splash.js stops this and fades the logo out. */
animation: splash-pulse 1.4s ease-in-out infinite;
}
@keyframes splash-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
#splash {
transition: none;
}
#splash img {
animation: none;
}
}
</style>
<!-- Decide light/dark before first paint from the "prefcache" blob (written by PrefCache.jsx) --
avoids the async-IndexedDB theme flash. Keep the key in sync with PrefCache.jsx. -->
<script>
(function () {
try {
var cache = JSON.parse(localStorage.getItem("prefcache"));
var theme = cache && cache.theme;
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
var dark = theme === "dark" || ((!theme || theme === "system") && prefersDark);
if (dark) {
document.documentElement.classList.add("dark");
}
} catch (e) {
/* localStorage/matchMedia/JSON unavailable -- fall back to the default light splash */
}
})();
</script>
</head>
<body>
<noscript>
@@ -51,6 +130,11 @@
<a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a> or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to
subscribe.
</noscript>
<!-- Static splash, removed by src/app/splash.js once ready. Logo is a same-origin SVG (precached by the SW). -->
<div id="splash" aria-hidden="true">
<img src="/static/images/ntfy-splash.svg" alt="" />
</div>
<div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/index.jsx"></script>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="#9a9a9a">
<g transform="translate(-51.451771,-87.327048)">
<path d="m 59.291677,93.677052 c -3.579993,0 -6.646873,2.817003 -6.646873,6.398338 v 0.003 l 0.03508,27.86677 -0.899113,6.63475 12.226096,-3.24797 H 94.40052 c 3.579985,0 6.64687,-2.82079 6.64687,-6.40216 v -24.85449 c 0,-3.580312 -3.065184,-6.39668 -6.643822,-6.398338 h -0.0031 z m 0,4.516205 h 35.108844 0.0031 c 1.257851,0.0013 2.12767,0.916373 2.12767,1.882133 v 24.85442 c 0,0.9666 -0.871353,1.88213 -2.13072,1.88213 H 63.344139 l -6.211425,1.87679 0.0633,-0.36604 -0.03431,-28.2473 c 0,-0.966516 0.870609,-1.882133 2.129956,-1.882133 z" />
<g transform="matrix(2.1452134,0,0,2.5503116,-71.247407,-178.388)">
<path d="m 62.57046,116.77004 v -1.31201 l 3.280018,-1.45904 q 0.158346,-0.0679 0.305381,-0.1018 0.158346,-0.0452 0.282761,-0.0679 0.135725,-0.0113 0.271449,-0.0226 v -0.0905 q -0.135724,-0.0113 -0.271449,-0.0452 -0.124415,-0.0226 -0.282761,-0.0566 -0.147035,-0.0452 -0.305381,-0.1131 l -3.280018,-1.45904 v -1.32332 l 5.067063,2.31863 v 1.4138 z" />
<path d="m 62.308594,110.31055 v 1.90234 l 3.4375,1.5293 c 0.0073,0.003 0.0142,0.005 0.02148,0.008 -0.0073,0.003 -0.0142,0.005 -0.02148,0.008 l -3.4375,1.5293 v 1.89258 l 0.371093,-0.16992 5.220704,-2.39063 v -1.75 z m 0.52539,0.8164 4.541016,2.08008 v 1.07617 l -4.541016,2.07813 v -0.73242 l 3.119141,-1.38868 0.0039,-0.002 c 0.09141,-0.0389 0.178343,-0.0676 0.257813,-0.0859 h 0.0059 l 0.0078,-0.002 c 0.09483,-0.0271 0.176055,-0.0474 0.246093,-0.0606 l 0.498047,-0.041 v -0.57422 l -0.240234,-0.0195 c -0.07606,-0.006 -0.153294,-0.0198 -0.230469,-0.0391 l -0.0078,-0.002 -0.0078,-0.002 c -0.07608,-0.0138 -0.16556,-0.0318 -0.263672,-0.0527 -0.08398,-0.0262 -0.172736,-0.058 -0.265625,-0.0977 l -0.0039,-0.002 -3.119141,-1.38868 z" />
</g>
<g transform="matrix(2.1388566,0,0,2.4558588,-69.745456,-170.93962)">
<path d="m 69.17132,117.75404 h 5.428996 v 1.27808 H 69.17132 Z" />
<path d="m 68.908203,117.49219 v 0.26172 1.54101 h 5.955078 v -1.80273 z m 0.525391,0.52344 h 4.904297 v 0.7539 h -4.904297 z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+14 -5
View File
@@ -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) {
+7 -1
View File
@@ -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");
+1 -1
View File
@@ -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.
+58
View File
@@ -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;
+49
View File
@@ -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;
});
};
+3 -3
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
@@ -772,7 +772,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>
@@ -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) {
+15 -3
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";
@@ -172,7 +173,7 @@ const ProfileIcon = () => {
await accountApi.logout();
await db().delete();
} finally {
await session.resetAndRedirect(routes.app);
await session.resetAndRedirect(routes.app, { fade: true });
}
};
@@ -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>
)}
+64 -28
View File
@@ -28,6 +28,8 @@ 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();
@@ -38,16 +40,29 @@ 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 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;
}
document.documentElement.classList.toggle("dark", isDark);
}, [isDark, themePreference]);
// Safety net: hide the splash even if no route does (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,12 @@ const updateTitle = (newNotificationsCount) => {
updateFavicon(newNotificationsCount);
};
// Auth pages render synchronously, so the splash can be removed on mount.
const AuthLayout = () => {
useEffect(() => hideSplash(), []);
return <Outlet />;
};
const Layout = () => {
const params = useParams();
const { account, setAccount } = useContext(AccountContext);
@@ -98,6 +121,9 @@ const Layout = () => {
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
// 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);
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
@@ -112,27 +138,37 @@ const Layout = () => {
useBackgroundProcesses();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
// Hide the splash only once subscriptions have loaded, so the nav/list don't pop in empty-then-filled.
useEffect(() => {
if (subscriptions !== undefined) {
hideSplash();
}
}, [subscriptions]);
return (
<Box sx={{ display: "flex" }}>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
<Main>
<Toolbar />
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
}}
<PrefCacheProvider>
<Box sx={{ display: "flex" }}>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
<Navigation
subscriptions={subscriptionsWithoutInternal}
selectedSubscription={selected}
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)}
/>
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
</Box>
<Main>
<Toolbar />
<Outlet
context={{
subscriptions: subscriptionsWithoutInternal,
selected,
allNotifications,
}}
/>
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
</Box>
</PrefCacheProvider>
);
};
+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) {
+29 -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,25 @@ const priorityFiles = {
};
export const AllSubscriptions = () => {
const { subscriptions } = useOutletContext();
if (!subscriptions) {
return <Loading />;
// 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 <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 +83,13 @@ 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;
// 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]
);
if (notifications.length === 0) {
return <NoNotifications subscription={subscription} />;
}
@@ -669,3 +667,14 @@ const Loading = () => {
</VerticallyCenteredContainer>
);
};
// 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(() => {
const timer = setTimeout(() => setShow(true), delayMs);
return () => clearTimeout(timer);
}, [delayMs]);
return show ? <Loading /> : null;
};
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { createContext, useContext, useEffect } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import prefs, { PREF_DEFAULTS } from "../app/Prefs";
// 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(),
minPriority: await prefs.minPriority(),
deleteAfter: await prefs.deleteAfter(),
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 <PrefCacheContext.Provider value={cache}>{children}</PrefCacheContext.Provider>;
};
// Live context once resolved; else the synchronous localStorage snapshot (instant on cold load);
// else defaults.
export const usePrefCache = () => useContext(PrefCacheContext) ?? readPersistedCache();
+7 -17
View File
@@ -50,6 +50,7 @@ import { UnauthorizedError } from "../app/errors";
import { subscribeTopic } from "./SubscribeDialog";
import notifier from "../app/Notifier";
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 = useLiveQuery(async () => prefs.sound());
const { sound } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setSound(ev.target.value);
await maybeUpdateAccountSettings({
@@ -108,9 +109,6 @@ const Sound = () => {
},
});
};
if (!sound) {
return null; // While loading
}
let description;
if (sound === "none") {
description = t("prefs_notifications_sound_description_none");
@@ -143,7 +141,7 @@ const Sound = () => {
const MinPriority = () => {
const { t } = useTranslation();
const labelId = "prefMinPriority";
const minPriority = useLiveQuery(async () => prefs.minPriority());
const { minPriority } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value);
await maybeUpdateAccountSettings({
@@ -152,9 +150,6 @@ const MinPriority = () => {
},
});
};
if (!minPriority) {
return null; // While loading
}
const priorities = {
1: t("priority_min"),
2: t("priority_low"),
@@ -191,7 +186,7 @@ const MinPriority = () => {
const DeleteAfter = () => {
const { t } = useTranslation();
const labelId = "prefDeleteAfter";
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
const { deleteAfter } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value);
await maybeUpdateAccountSettings({
@@ -201,11 +196,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 +231,7 @@ const DeleteAfter = () => {
const Theme = () => {
const { t } = useTranslation();
const labelId = "prefTheme";
const theme = useLiveQuery(async () => prefs.theme());
const { theme } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setTheme(ev.target.value);
};
@@ -249,7 +239,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 +252,7 @@ const Theme = () => {
const WebPushEnabled = () => {
const { t } = useTranslation();
const labelId = "prefWebPushEnabled";
const enabled = useLiveQuery(async () => prefs.webPushEnabled());
const { webPushEnabled: enabled } = usePrefCache();
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) {