Add server-generated /config.js; add error boundary
This commit is contained in:
		
							parent
							
								
									04ee6b8be2
								
							
						
					
					
						commit
						840cb5b182
					
				
					 14 changed files with 184 additions and 85 deletions
				
			
		|  | @ -7,7 +7,7 @@ import Typography from "@mui/material/Typography"; | |||
| import * as React from "react"; | ||||
| import {useEffect, useRef, useState} from "react"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import {subscriptionRoute, topicShortUrl} from "../app/utils"; | ||||
| import {topicShortUrl} from "../app/utils"; | ||||
| import {useLocation, useNavigate} from "react-router-dom"; | ||||
| import ClickAwayListener from '@mui/material/ClickAwayListener'; | ||||
| import Grow from '@mui/material/Grow'; | ||||
|  | @ -19,6 +19,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert"; | |||
| import NotificationsIcon from '@mui/icons-material/Notifications'; | ||||
| import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; | ||||
| import api from "../app/Api"; | ||||
| import routes from "./routes"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import logo from "../img/ntfy.svg" | ||||
| 
 | ||||
|  | @ -98,9 +99,9 @@ const SettingsIcons = (props) => { | |||
|         await subscriptionManager.remove(props.subscription.id); | ||||
|         const newSelected = await subscriptionManager.first(); // May be undefined
 | ||||
|         if (newSelected) { | ||||
|             navigate(subscriptionRoute(newSelected)); | ||||
|             navigate(routes.forSubscription(newSelected)); | ||||
|         } else { | ||||
|             navigate("/"); | ||||
|             navigate(routes.root); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,10 +14,16 @@ import Preferences from "./Preferences"; | |||
| import {useLiveQuery} from "dexie-react-hooks"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import userManager from "../app/UserManager"; | ||||
| import {BrowserRouter, Outlet, Route, Routes, useNavigate, useOutletContext, useParams} from "react-router-dom"; | ||||
| import {expandSecureUrl, expandUrl, subscriptionRoute, topicUrl} from "../app/utils"; | ||||
| import poller from "../app/Poller"; | ||||
| import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from "react-router-dom"; | ||||
| import {expandUrl} from "../app/utils"; | ||||
| import ErrorBoundary from "./ErrorBoundary"; | ||||
| import routes from "./routes"; | ||||
| import {useAutoSubscribe, useConnectionListeners} from "./hooks"; | ||||
| 
 | ||||
| // TODO iPhone blank screen
 | ||||
| // TODO better "send test message" (a la android app)
 | ||||
| // TODO docs
 | ||||
| // TODO screenshot on homepage
 | ||||
| // TODO "copy url" toast
 | ||||
| // TODO "copy link url" button
 | ||||
| // TODO races when two tabs are open
 | ||||
|  | @ -25,19 +31,21 @@ import poller from "../app/Poller"; | |||
| 
 | ||||
| const App = () => { | ||||
|     return ( | ||||
|         <BrowserRouter> | ||||
|             <ThemeProvider theme={theme}> | ||||
|                 <CssBaseline/> | ||||
|                 <Routes> | ||||
|                     <Route element={<Layout/>}> | ||||
|                         <Route path="/" element={<AllSubscriptions/>} /> | ||||
|                         <Route path="settings" element={<Preferences/>} /> | ||||
|                         <Route path=":topic" element={<SingleSubscription/>} /> | ||||
|                         <Route path=":baseUrl/:topic" element={<SingleSubscription/>} /> | ||||
|                     </Route> | ||||
|                 </Routes> | ||||
|             </ThemeProvider> | ||||
|         </BrowserRouter> | ||||
|         <ErrorBoundary> | ||||
|             <BrowserRouter> | ||||
|                 <ThemeProvider theme={theme}> | ||||
|                     <CssBaseline/> | ||||
|                     <Routes> | ||||
|                         <Route element={<Layout/>}> | ||||
|                             <Route path={routes.root} element={<AllSubscriptions/>} /> | ||||
|                             <Route path={routes.settings} element={<Preferences/>} /> | ||||
|                             <Route path={routes.subscription} element={<SingleSubscription/>} /> | ||||
|                             <Route path={routes.subscriptionExternal} element={<SingleSubscription/>} /> | ||||
|                         </Route> | ||||
|                     </Routes> | ||||
|                 </ThemeProvider> | ||||
|             </BrowserRouter> | ||||
|         </ErrorBoundary> | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
|  | @ -65,7 +73,6 @@ const Layout = () => { | |||
|     }); | ||||
| 
 | ||||
|     useConnectionListeners(); | ||||
| 
 | ||||
|     useEffect(() => connectionManager.refresh(subscriptions, users), [subscriptions, users]); | ||||
|     useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); | ||||
| 
 | ||||
|  | @ -113,52 +120,8 @@ const Main = (props) => { | |||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const useConnectionListeners = () => { | ||||
|     const navigate = useNavigate(); | ||||
|     useEffect(() => { | ||||
|         const handleNotification = async (subscriptionId, notification) => { | ||||
|             const added = await subscriptionManager.addNotification(subscriptionId, notification); | ||||
|             if (added) { | ||||
|                 const defaultClickAction = (subscription) => navigate(subscriptionRoute(subscription)); | ||||
|                 await notifier.notify(subscriptionId, notification, defaultClickAction) | ||||
|             } | ||||
|         }; | ||||
|         connectionManager.registerStateListener(subscriptionManager.updateState); | ||||
|         connectionManager.registerNotificationListener(handleNotification); | ||||
|         return () => { | ||||
|             connectionManager.resetStateListener(); | ||||
|             connectionManager.resetNotificationListener(); | ||||
|         } | ||||
|     }, | ||||
|     // We have to disable dep checking for "navigate". This is fine, it never changes.
 | ||||
|     // eslint-disable-next-line
 | ||||
|     []); | ||||
| }; | ||||
| 
 | ||||
| const useAutoSubscribe = (subscriptions, selected) => { | ||||
|     const [hasRun, setHasRun] = useState(false); | ||||
|     const params = useParams(); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         const loaded = subscriptions !== null && subscriptions !== undefined; | ||||
|         if (!loaded || hasRun) { | ||||
|             return; | ||||
|         } | ||||
|         setHasRun(true); | ||||
|         const eligible = params.topic && !selected; | ||||
|         if (eligible) { | ||||
|             const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin; | ||||
|             console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); | ||||
|             (async () => { | ||||
|                 const subscription = await subscriptionManager.add(baseUrl, params.topic, true); | ||||
|                 poller.pollInBackground(subscription); // Dangle!
 | ||||
|             })(); | ||||
|         } | ||||
|     }, [params, subscriptions, selected, hasRun]); | ||||
| }; | ||||
| 
 | ||||
| const updateTitle = (newNotificationsCount) => { | ||||
|     document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy web` : "ntfy web"; | ||||
|     document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy"; | ||||
| } | ||||
| 
 | ||||
| export default App; | ||||
|  |  | |||
							
								
								
									
										32
									
								
								web/src/components/ErrorBoundary.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								web/src/components/ErrorBoundary.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | |||
| import * as React from "react"; | ||||
| 
 | ||||
| class ErrorBoundary extends React.Component { | ||||
|     constructor(props) { | ||||
|         super(props); | ||||
|         this.state = { error: null, info: null }; | ||||
|     } | ||||
| 
 | ||||
|     componentDidCatch(error, info) { | ||||
|         this.setState({ error, info }); | ||||
|         console.error("[ErrorBoundary] A horrible error occurred", info); | ||||
|     } | ||||
| 
 | ||||
|     static getDerivedStateFromError(error) { | ||||
|         return { error: true, errorMessage: error.toString() } | ||||
|     } | ||||
| 
 | ||||
|     render() { | ||||
|         if (this.state.info) { | ||||
|             return ( | ||||
|                 <div> | ||||
|                     <h2>Something went wrong.</h2> | ||||
|                     <pre>{this.state.error && this.state.error.toString()}</pre> | ||||
|                     <pre>{this.state.info.componentStack}</pre> | ||||
|                 </div> | ||||
|             ); | ||||
|         } | ||||
|         return this.props.children; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export default ErrorBoundary; | ||||
|  | @ -14,13 +14,15 @@ import SubscribeDialog from "./SubscribeDialog"; | |||
| import {Alert, AlertTitle, Badge, CircularProgress, ListSubheader} from "@mui/material"; | ||||
| import Button from "@mui/material/Button"; | ||||
| import Typography from "@mui/material/Typography"; | ||||
| import {subscriptionRoute, topicShortUrl, topicUrl} from "../app/utils"; | ||||
| import {topicShortUrl, topicUrl} from "../app/utils"; | ||||
| import routes from "./routes"; | ||||
| import {ConnectionState} from "../app/Connection"; | ||||
| import {useLocation, useNavigate} from "react-router-dom"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import {ChatBubble, NotificationsOffOutlined} from "@mui/icons-material"; | ||||
| import Box from "@mui/material/Box"; | ||||
| import notifier from "../app/Notifier"; | ||||
| import config from "../app/config"; | ||||
| 
 | ||||
| const navWidth = 280; | ||||
| 
 | ||||
|  | @ -71,7 +73,7 @@ const NavList = (props) => { | |||
|     const handleSubscribeSubmit = (subscription) => { | ||||
|         console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); | ||||
|         handleSubscribeReset(); | ||||
|         navigate(subscriptionRoute(subscription)); | ||||
|         navigate(routes.forSubscription(subscription)); | ||||
|         handleRequestNotificationPermission(); | ||||
|     } | ||||
| 
 | ||||
|  | @ -88,14 +90,14 @@ const NavList = (props) => { | |||
|             <List component="nav" sx={{ paddingTop: (showGrantPermissionsBox) ? '0' : '' }}> | ||||
|                 {showGrantPermissionsBox && <PermissionAlert onRequestPermissionClick={handleRequestNotificationPermission}/>} | ||||
|                 {!showSubscriptionsList && | ||||
|                     <ListItemButton onClick={() => navigate("/")} selected={location.pathname === "/"}> | ||||
|                     <ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}> | ||||
|                         <ListItemIcon><ChatBubble/></ListItemIcon> | ||||
|                         <ListItemText primary="All notifications"/> | ||||
|                     </ListItemButton>} | ||||
|                 {showSubscriptionsList && | ||||
|                     <> | ||||
|                         <ListSubheader>Subscribed topics</ListSubheader> | ||||
|                         <ListItemButton onClick={() => navigate("/")} selected={location.pathname === "/"}> | ||||
|                         <ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}> | ||||
|                             <ListItemIcon><ChatBubble/></ListItemIcon> | ||||
|                             <ListItemText primary="All notifications"/> | ||||
|                         </ListItemButton> | ||||
|  | @ -105,7 +107,7 @@ const NavList = (props) => { | |||
|                         /> | ||||
|                         <Divider sx={{my: 1}}/> | ||||
|                     </>} | ||||
|                 <ListItemButton onClick={() => navigate("/settings")} selected={location.pathname === "/settings"}> | ||||
|                 <ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}> | ||||
|                     <ListItemIcon><SettingsIcon/></ListItemIcon> | ||||
|                     <ListItemText primary="Settings"/> | ||||
|                 </ListItemButton> | ||||
|  | @ -152,7 +154,7 @@ const SubscriptionItem = (props) => { | |||
|         ? subscription.topic | ||||
|         : topicShortUrl(subscription.baseUrl, subscription.topic); | ||||
|     const handleClick = async () => { | ||||
|         navigate(subscriptionRoute(subscription)); | ||||
|         navigate(routes.forSubscription(subscription)); | ||||
|         await subscriptionManager.markNotificationsRead(subscription.id); | ||||
|     }; | ||||
|     return ( | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ const SubscribeDialog = (props) => { | |||
|     const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||
|     const handleSuccess = async () => { | ||||
|         const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin; | ||||
|         const subscription = await subscriptionManager.add(actualBaseUrl, topic, false); | ||||
|         const subscription = await subscriptionManager.add(actualBaseUrl, topic); | ||||
|         poller.pollInBackground(subscription); // Dangle!
 | ||||
|         props.onSuccess(subscription); | ||||
|     } | ||||
|  |  | |||
							
								
								
									
										52
									
								
								web/src/components/hooks.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								web/src/components/hooks.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,52 @@ | |||
| import {useNavigate, useParams} from "react-router-dom"; | ||||
| import {useEffect, useState} from "react"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils"; | ||||
| import notifier from "../app/Notifier"; | ||||
| import routes from "./routes"; | ||||
| import connectionManager from "../app/ConnectionManager"; | ||||
| import poller from "../app/Poller"; | ||||
| 
 | ||||
| export const useConnectionListeners = () => { | ||||
|   const navigate = useNavigate(); | ||||
|   useEffect(() => { | ||||
|         const handleNotification = async (subscriptionId, notification) => { | ||||
|           const added = await subscriptionManager.addNotification(subscriptionId, notification); | ||||
|           if (added) { | ||||
|             const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); | ||||
|             await notifier.notify(subscriptionId, notification, defaultClickAction) | ||||
|           } | ||||
|         }; | ||||
|         connectionManager.registerStateListener(subscriptionManager.updateState); | ||||
|         connectionManager.registerNotificationListener(handleNotification); | ||||
|         return () => { | ||||
|           connectionManager.resetStateListener(); | ||||
|           connectionManager.resetNotificationListener(); | ||||
|         } | ||||
|       }, | ||||
|       // We have to disable dep checking for "navigate". This is fine, it never changes.
 | ||||
|       // eslint-disable-next-line
 | ||||
|       []); | ||||
| }; | ||||
| 
 | ||||
| export const useAutoSubscribe = (subscriptions, selected) => { | ||||
|   const [hasRun, setHasRun] = useState(false); | ||||
|   const params = useParams(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     const loaded = subscriptions !== null && subscriptions !== undefined; | ||||
|     if (!loaded || hasRun) { | ||||
|       return; | ||||
|     } | ||||
|     setHasRun(true); | ||||
|     const eligible = params.topic && !selected && !disallowedTopic(params.topic); | ||||
|     if (eligible) { | ||||
|       const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin; | ||||
|       console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); | ||||
|       (async () => { | ||||
|         const subscription = await subscriptionManager.add(baseUrl, params.topic); | ||||
|         poller.pollInBackground(subscription); // Dangle!
 | ||||
|       })(); | ||||
|     } | ||||
|   }, [params, subscriptions, selected, hasRun]); | ||||
| }; | ||||
							
								
								
									
										16
									
								
								web/src/components/routes.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								web/src/components/routes.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| import config from "../app/config"; | ||||
| import {shortUrl} from "../app/utils"; | ||||
| 
 | ||||
| const routes = { | ||||
|     root: config.appRoot, | ||||
|     settings: "/settings", | ||||
|     subscription: "/:topic", | ||||
|     subscriptionExternal: "/:baseUrl/:topic", | ||||
|     forSubscription: (subscription) => { | ||||
|         if (subscription.baseUrl !== window.location.origin) { | ||||
|             return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; | ||||
|         } | ||||
|         return `/${subscription.topic}`; | ||||
|     } | ||||
| }; | ||||
| export default routes; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue