mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
WIP: Web app transitions
This commit is contained in:
@@ -44,6 +44,86 @@
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Splash screen: painted instantly (before the JS bundle loads) and faded out by the app
|
||||
once the initial data is ready (see src/app/splash.js). Avoids the white flash + spinner
|
||||
flicker on first load. Background colors match MUI's grey[100]/grey[900] (the app's main
|
||||
background) so the handoff to the app is seamless. -->
|
||||
<style>
|
||||
html {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
html.ntfy-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.ntfy-dark #splash {
|
||||
background-color: #212121;
|
||||
}
|
||||
|
||||
#splash.ntfy-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: ntfy-splash-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ntfy-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, reading the theme preference mirrored to
|
||||
localStorage by Prefs.js. Avoids the light/dark flash caused by the async IndexedDB read. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("theme");
|
||||
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");
|
||||
}
|
||||
} catch (e) {
|
||||
/* localStorage/matchMedia unavailable -- fall back to the default light splash */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
@@ -51,6 +131,12 @@
|
||||
<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 the app is ready. The logo is a same-origin
|
||||
SVG precached by the service worker, so it paints right after the (instant) background. -->
|
||||
<div id="splash" aria-hidden="true">
|
||||
<img src="/static/images/main_list_icon.svg" alt="" />
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="50mm"
|
||||
height="50mm"
|
||||
viewBox="0 0 50 49.999999"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
|
||||
sodipodi:docname="main-list-icon.svg"
|
||||
inkscape:export-filename="/home/pheckel/Code/ntfy-android/assets/launcher_full_bg.png"
|
||||
inkscape:export-xdpi="260.10001"
|
||||
inkscape:export-ydpi="260.10001"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2">
|
||||
<filter
|
||||
style="color-interpolation-filters:sRGB"
|
||||
inkscape:label="Drop Shadow"
|
||||
id="filter3958"
|
||||
x="-0.076083146"
|
||||
y="-0.091641665"
|
||||
width="1.1759423"
|
||||
height="1.2114791">
|
||||
<feFlood
|
||||
flood-opacity="0.192157"
|
||||
flood-color="rgb(0,0,0)"
|
||||
result="flood"
|
||||
id="feFlood3948" />
|
||||
<feComposite
|
||||
in="flood"
|
||||
in2="SourceGraphic"
|
||||
operator="in"
|
||||
result="composite1"
|
||||
id="feComposite3950" />
|
||||
<feGaussianBlur
|
||||
in="composite1"
|
||||
stdDeviation="4"
|
||||
result="blur"
|
||||
id="feGaussianBlur3952" />
|
||||
<feOffset
|
||||
dx="3"
|
||||
dy="2.95367"
|
||||
result="offset"
|
||||
id="feOffset3954" />
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="offset"
|
||||
operator="over"
|
||||
result="composite2"
|
||||
id="feComposite3956" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="2.6887315"
|
||||
inkscape:cx="42.40716"
|
||||
inkscape:cy="53.189293"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:measure-start="0,0"
|
||||
inkscape:measure-end="0,0"
|
||||
inkscape:snap-text-baseline="true"
|
||||
inkscape:window-width="1863"
|
||||
inkscape:window-height="1025"
|
||||
inkscape:window-x="57"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
showguides="false"
|
||||
inkscape:guide-bbox="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:rotation="-1">
|
||||
<sodipodi:guide
|
||||
position="9.8690703,86.715698"
|
||||
orientation="1,0"
|
||||
id="guide1770" />
|
||||
<sodipodi:guide
|
||||
position="39.661132,81.074874"
|
||||
orientation="1,0"
|
||||
id="guide1772" />
|
||||
<sodipodi:guide
|
||||
position="9.8690703,58.786381"
|
||||
orientation="0,-1"
|
||||
id="guide1774" />
|
||||
<sodipodi:guide
|
||||
position="-2.6121775,28.943566"
|
||||
orientation="0,-1"
|
||||
id="guide1776" />
|
||||
<sodipodi:guide
|
||||
position="14.686182,55.195651"
|
||||
orientation="1,0"
|
||||
id="guide4020" />
|
||||
<sodipodi:guide
|
||||
position="34.626283,58.786381"
|
||||
orientation="1,0"
|
||||
id="guide4022" />
|
||||
<sodipodi:guide
|
||||
position="12.398156,51.002016"
|
||||
orientation="0,-1"
|
||||
id="guide4024" />
|
||||
<sodipodi:guide
|
||||
position="11.073267,36.978591"
|
||||
orientation="0,-1"
|
||||
id="guide4026" />
|
||||
</sodipodi:namedview>
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="foreground"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-51.451771,-87.327048)"
|
||||
style="display:inline">
|
||||
<path
|
||||
id="path2498"
|
||||
style="color:#000000;fill:#9a9a9a;fill-opacity:1;stroke:none;stroke-width:0.754022;-inkscape-stroke:none"
|
||||
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
|
||||
id="path1011-6-2"
|
||||
transform="matrix(2.1452134,0,0,2.5503116,-71.247407,-178.388)"
|
||||
style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#9a9a9a;fill-opacity:1;stroke:none;stroke-width:0.525121">
|
||||
<path
|
||||
style="color:#000000;-inkscape-font-specification:'JetBrains Mono, Bold';fill:#9a9a9a;fill-opacity:1;stroke:none;-inkscape-stroke:none"
|
||||
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"
|
||||
id="path7553" />
|
||||
<path
|
||||
style="color:#000000;-inkscape-font-specification:'JetBrains Mono, Bold';fill:#9a9a9a;fill-opacity:1;stroke:none;-inkscape-stroke:none"
|
||||
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"
|
||||
id="path7555" />
|
||||
</g>
|
||||
<g
|
||||
id="g1224"
|
||||
transform="matrix(2.1388566,0,0,2.4558588,-69.745456,-170.93962)"
|
||||
style="font-size:8.48274px;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#9a9a9a;fill-opacity:1;stroke:none;stroke-width:0.525121">
|
||||
<path
|
||||
style="color:#000000;-inkscape-font-specification:'JetBrains Mono, Bold';fill:#9a9a9a;fill-opacity:1;stroke:none;-inkscape-stroke:none"
|
||||
d="m 69.17132,117.75404 h 5.428996 v 1.27808 H 69.17132 Z"
|
||||
id="path1220" />
|
||||
<path
|
||||
style="color:#000000;-inkscape-font-specification:'JetBrains Mono, Bold';fill:#9a9a9a;fill-opacity:1;stroke:none;-inkscape-stroke:none"
|
||||
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"
|
||||
id="path1222" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
+20
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user