Philipp Heckel 4 yıl önce
ebeveyn
işleme
b5670d9a71

+ 58 - 0
web/package-lock.json

@@ -15,6 +15,7 @@
         "dexie-react-hooks": "^1.1.1",
         "react": "latest",
         "react-dom": "latest",
+        "react-router-dom": "^6.2.2",
         "react-scripts": "^3.0.1"
       }
     },
@@ -8333,6 +8334,14 @@
       "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz",
       "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ=="
     },
+    "node_modules/history": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
+      "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
+      "dependencies": {
+        "@babel/runtime": "^7.7.6"
+      }
+    },
     "node_modules/hmac-drbg": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@@ -13693,6 +13702,30 @@
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
       "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
     },
+    "node_modules/react-router": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.2.2.tgz",
+      "integrity": "sha512-/MbxyLzd7Q7amp4gDOGaYvXwhEojkJD5BtExkuKmj39VEE0m3l/zipf6h2WIB2jyAO0lI6NGETh4RDcktRm4AQ==",
+      "dependencies": {
+        "history": "^5.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8"
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.2.2.tgz",
+      "integrity": "sha512-AtYEsAST7bDD4dLSQHDnk/qxWLJdad5t1HFa1qJyUrCeGgEuCSw0VB/27ARbF9Fi/W5598ujvJOm3ujUCVzuYQ==",
+      "dependencies": {
+        "history": "^5.2.0",
+        "react-router": "6.2.2"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
     "node_modules/react-scripts": {
       "version": "3.4.4",
       "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-3.4.4.tgz",
@@ -24061,6 +24094,14 @@
       "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz",
       "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ=="
     },
+    "history": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
+      "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
+      "requires": {
+        "@babel/runtime": "^7.7.6"
+      }
+    },
     "hmac-drbg": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@@ -28321,6 +28362,23 @@
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
       "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
     },
+    "react-router": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.2.2.tgz",
+      "integrity": "sha512-/MbxyLzd7Q7amp4gDOGaYvXwhEojkJD5BtExkuKmj39VEE0m3l/zipf6h2WIB2jyAO0lI6NGETh4RDcktRm4AQ==",
+      "requires": {
+        "history": "^5.2.0"
+      }
+    },
+    "react-router-dom": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.2.2.tgz",
+      "integrity": "sha512-AtYEsAST7bDD4dLSQHDnk/qxWLJdad5t1HFa1qJyUrCeGgEuCSw0VB/27ARbF9Fi/W5598ujvJOm3ujUCVzuYQ==",
+      "requires": {
+        "history": "^5.2.0",
+        "react-router": "6.2.2"
+      }
+    },
     "react-scripts": {
       "version": "3.4.4",
       "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-3.4.4.tgz",

+ 1 - 0
web/package.json

@@ -16,6 +16,7 @@
     "dexie-react-hooks": "^1.1.1",
     "react": "latest",
     "react-dom": "latest",
+    "react-router-dom": "^6.2.2",
     "react-scripts": "^3.0.1"
   },
   "browserslist": {

+ 0 - 1
web/src/app/ConnectionManager.js

@@ -3,7 +3,6 @@ import {sha256} from "./utils";
 
 class ConnectionManager {
     constructor() {
-        console.log(`connection manager`)
         this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
         this.stateListener = null; // Fired when connection state changes
         this.notificationListener = null; // Fired when new notifications arrive

+ 4 - 0
web/src/app/utils.js

@@ -114,6 +114,10 @@ export const openUrl = (url) => {
     window.open(url, "_blank", "noopener,noreferrer");
 };
 
+export const subscriptionRoute = (subscription) => {
+    return `/${subscription.topic}`;
+}
+
 // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
 export async function* fetchLinesIterator(fileURL, headers) {
     const utf8Decoder = new TextDecoder('utf-8');

+ 9 - 4
web/src/components/ActionBar.js

@@ -8,11 +8,16 @@ import SubscribeSettings from "./SubscribeSettings";
 import * as React from "react";
 import Box from "@mui/material/Box";
 import {topicShortUrl} from "../app/utils";
+import {useLocation} from "react-router-dom";
 
 const ActionBar = (props) => {
-    const title = (props.selectedSubscription)
-        ? topicShortUrl(props.selectedSubscription.baseUrl, props.selectedSubscription.topic)
-        : "ntfy";
+    const location = useLocation();
+    let title = "ntfy";
+    if (props.selectedSubscription) {
+        title = topicShortUrl(props.selectedSubscription.baseUrl, props.selectedSubscription.topic);
+    } else if (location.pathname === "/settings") {
+        title = "Settings";
+    }
     return (
         <AppBar position="fixed" sx={{
             width: '100%',
@@ -36,7 +41,7 @@ const ActionBar = (props) => {
                 <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
                     {title}
                 </Typography>
-                {props.selectedSubscription !== null && <SubscribeSettings
+                {props.selectedSubscription && <SubscribeSettings
                     subscription={props.selectedSubscription}
                     onUnsubscribe={props.onUnsubscribe}
                 />}

+ 49 - 71
web/src/components/App.js

@@ -6,7 +6,6 @@ import CssBaseline from '@mui/material/CssBaseline';
 import Toolbar from '@mui/material/Toolbar';
 import Notifications from "./Notifications";
 import theme from "./theme";
-import prefs from "../app/Prefs";
 import connectionManager from "../app/ConnectionManager";
 import Navigation from "./Navigation";
 import ActionBar from "./ActionBar";
@@ -18,65 +17,64 @@ import poller from "../app/Poller";
 import pruner from "../app/Pruner";
 import subscriptionManager from "../app/SubscriptionManager";
 import userManager from "../app/UserManager";
+import {BrowserRouter, Route, Routes, useLocation, useNavigate} from "react-router-dom";
+import {subscriptionRoute} from "../app/utils";
 
 // TODO make default server functional
-// TODO routing
 // TODO embed into ntfy server
 // TODO new notification indicator
 
 const App = () => {
+    return (
+        <BrowserRouter>
+            <ThemeProvider theme={theme}>
+                <CssBaseline/>
+                <Root/>
+            </ThemeProvider>
+        </BrowserRouter>
+    );
+}
+
+const Root = () => {
     const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
-    const [prefsOpen, setPrefsOpen] = useState(false);
-    const [selectedSubscription, setSelectedSubscription] = useState(null);
     const [notificationsGranted, setNotificationsGranted] = useState(notificationManager.granted());
-    const subscriptions = useLiveQuery(() => subscriptionManager.all());
+    const navigate = useNavigate();
+    const location = useLocation();
     const users = useLiveQuery(() => userManager.all());
+    const subscriptions = useLiveQuery(() => subscriptionManager.all());
+    const [selectedSubscription] = (subscriptions && location) ? subscriptions.filter(s => location.pathname === subscriptionRoute(s)) : [];
+
     const handleSubscriptionClick = async (subscriptionId) => {
         const subscription = await subscriptionManager.get(subscriptionId);
-        setSelectedSubscription(subscription);
-        setPrefsOpen(false);
+        navigate(subscriptionRoute(subscription));
     }
     const handleSubscribeSubmit = async (subscription) => {
         console.log(`[App] New subscription: ${subscription.id}`, subscription);
-        setSelectedSubscription(subscription);
+        navigate(subscriptionRoute(subscription));
         handleRequestPermission();
     };
     const handleUnsubscribe = async (subscriptionId) => {
         console.log(`[App] Unsubscribing from ${subscriptionId}`);
         const newSelected = await subscriptionManager.first(); // May be undefined
-        setSelectedSubscription(newSelected);
+        if (newSelected) {
+            navigate(subscriptionRoute(newSelected));
+        }
     };
     const handleRequestPermission = () => {
         notificationManager.maybeRequestPermission(granted => setNotificationsGranted(granted));
     };
-    const handlePrefsClick = () => {
-        setPrefsOpen(true);
-        setSelectedSubscription(null);
-    };
     // Define hooks: Note that the order of the hooks is important. The "loading" hooks
     // must be before the "saving" hooks.
     useEffect(() => {
         poller.startWorker();
         pruner.startWorker();
-        const load = async () => {
-            const subs = await subscriptionManager.all();             // FIXME this is broken
-            const selectedSubscriptionId = await prefs.selectedSubscriptionId();
-
-            // Set selected subscription
-            const maybeSelectedSubscription = subs?.filter(s => s.id = selectedSubscriptionId);
-            if (maybeSelectedSubscription.length > 0) {
-                setSelectedSubscription(maybeSelectedSubscription[0]);
-            }
-
-        };
-        setTimeout(() => load(), 5000);
     }, [/* initial render */]);
     useEffect(() => {
         const handleNotification = async (subscriptionId, notification) => {
             try {
                 const added = await subscriptionManager.addNotification(subscriptionId, notification);
                 if (added) {
-                    const defaultClickAction = (subscription) => setSelectedSubscription(subscription);
+                    const defaultClickAction = (subscription) => navigate(subscriptionRoute(subscription));
                     await notificationManager.notify(subscriptionId, notification, defaultClickAction)
                 }
             } catch (e) {
@@ -90,47 +88,37 @@ const App = () => {
             connectionManager.resetNotificationListener();
         }
     }, [/* initial render */]);
-    useEffect(() => {
-        connectionManager.refresh(subscriptions, users); // Dangle
-    }, [subscriptions, users]);
-    useEffect(() => {
-        const subscriptionId = (selectedSubscription) ? selectedSubscription.id : "";
-        prefs.setSelectedSubscriptionId(subscriptionId)
-    }, [selectedSubscription]);
-
+    useEffect(() => { connectionManager.refresh(subscriptions, users) }, [subscriptions, users]); // Dangle!
     return (
-        <ThemeProvider theme={theme}>
+        <Box sx={{display: 'flex'}}>
             <CssBaseline/>
-            <Box sx={{display: 'flex'}}>
-                <CssBaseline/>
-                <ActionBar
+            <ActionBar
+                subscriptions={subscriptions}
+                selectedSubscription={selectedSubscription}
+                onUnsubscribe={handleUnsubscribe}
+                onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
+            />
+            <Box component="nav" sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}>
+                <Navigation
+                    subscriptions={subscriptions}
                     selectedSubscription={selectedSubscription}
-                    onUnsubscribe={handleUnsubscribe}
+                    mobileDrawerOpen={mobileDrawerOpen}
+                    notificationsGranted={notificationsGranted}
                     onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
+                    onSubscriptionClick={handleSubscriptionClick}
+                    onSubscribeSubmit={handleSubscribeSubmit}
+                    onRequestPermissionClick={handleRequestPermission}
                 />
-                <Box component="nav" sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}>
-                    <Navigation
-                        subscriptions={subscriptions}
-                        selectedSubscription={selectedSubscription}
-                        mobileDrawerOpen={mobileDrawerOpen}
-                        notificationsGranted={notificationsGranted}
-                        prefsOpen={prefsOpen}
-                        onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
-                        onSubscriptionClick={handleSubscriptionClick}
-                        onSubscribeSubmit={handleSubscribeSubmit}
-                        onPrefsClick={handlePrefsClick}
-                        onRequestPermissionClick={handleRequestPermission}
-                    />
-                </Box>
-                <Main>
-                    <Toolbar/>
-                    <Content
-                        subscription={selectedSubscription}
-                        prefsOpen={prefsOpen}
-                    />
-                </Main>
             </Box>
-        </ThemeProvider>
+            <Main>
+                <Toolbar/>
+                <Routes>
+                    <Route path="/" element={<NoTopics />} />
+                    <Route path="settings" element={<Preferences />} />
+                    <Route path=":topic" element={<Notifications subscriptions={subscriptions}/>} />
+                </Routes>
+            </Main>
+        </Box>
     );
 }
 
@@ -154,14 +142,4 @@ const Main = (props) => {
     );
 };
 
-const Content = (props) => {
-    if (props.prefsOpen) {
-        return <Preferences/>;
-    }
-    if (props.subscription) {
-        return <Notifications subscription={props.subscription}/>;
-    }
-    return <NoTopics/>;
-};
-
 export default App;

+ 13 - 25
web/src/components/Navigation.js

@@ -14,8 +14,9 @@ import SubscribeDialog from "./SubscribeDialog";
 import {Alert, AlertTitle, CircularProgress, ListSubheader} from "@mui/material";
 import Button from "@mui/material/Button";
 import Typography from "@mui/material/Typography";
-import {topicShortUrl} from "../app/utils";
+import {subscriptionRoute, topicShortUrl} from "../app/utils";
 import {ConnectionState} from "../app/Connection";
+import {useLocation, useNavigate} from "react-router-dom";
 
 const navWidth = 240;
 
@@ -53,6 +54,8 @@ const Navigation = (props) => {
 Navigation.width = navWidth;
 
 const NavList = (props) => {
+    const navigate = useNavigate();
+    const location = useLocation();
     const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
     const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
     const handleSubscribeReset = () => {
@@ -67,39 +70,24 @@ const NavList = (props) => {
     const showGrantPermissionsBox = props.subscriptions?.length > 0 && !props.notificationsGranted;
     return (
         <>
-            <Toolbar sx={{
-                display: { xs: 'none', sm: 'block' }
-            }}/>
-            <List component="nav" sx={{
-                paddingTop: (showGrantPermissionsBox) ? '0' : ''
-            }}>
+            <Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/>
+            <List component="nav" sx={{ paddingTop: (showGrantPermissionsBox) ? '0' : '' }}>
                 {showGrantPermissionsBox && <PermissionAlert onRequestPermissionClick={props.onRequestPermissionClick}/>}
                 {showSubscriptionsList &&
                     <>
-                        <ListSubheader component="div" id="nested-list-subheader">
-                            Subscribed topics
-                        </ListSubheader>
+                        <ListSubheader>Subscribed topics</ListSubheader>
                         <SubscriptionList
                             subscriptions={props.subscriptions}
                             selectedSubscription={props.selectedSubscription}
-                            prefsOpen={props.prefsOpen}
-                            onSubscriptionClick={props.onSubscriptionClick}
                         />
                         <Divider sx={{my: 1}}/>
                     </>}
-                <ListItemButton
-                    onClick={props.onPrefsClick}
-                    selected={props.prefsOpen}
-                >
-                    <ListItemIcon>
-                        <SettingsIcon/>
-                    </ListItemIcon>
+                <ListItemButton onClick={() => navigate("/settings")} selected={location.pathname === "/settings"}>
+                    <ListItemIcon><SettingsIcon/></ListItemIcon>
                     <ListItemText primary="Settings"/>
                 </ListItemButton>
                 <ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
-                    <ListItemIcon>
-                        <AddIcon/>
-                    </ListItemIcon>
+                    <ListItemIcon><AddIcon/></ListItemIcon>
                     <ListItemText primary="Add subscription"/>
                 </ListItemButton>
             </List>
@@ -121,20 +109,20 @@ const SubscriptionList = (props) => {
                 <SubscriptionItem
                     key={subscription.id}
                     subscription={subscription}
-                    selected={props.selectedSubscription && !props.prefsOpen && props.selectedSubscription.id === subscription.id}
-                    onClick={() => props.onSubscriptionClick(subscription.id)}
+                    selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
             />)}
         </>
     );
 }
 
 const SubscriptionItem = (props) => {
+    const navigate = useNavigate();
     const subscription = props.subscription;
     const icon = (subscription.state === ConnectionState.Connecting)
         ? <CircularProgress size="24px"/>
         : <ChatBubbleOutlineIcon/>;
     return (
-        <ListItemButton onClick={props.onClick} selected={props.selected}>
+        <ListItemButton onClick={() => navigate(subscriptionRoute(subscription))} selected={props.selected}>
             <ListItemIcon>{icon}</ListItemIcon>
             <ListItemText primary={topicShortUrl(subscription.baseUrl, subscription.topic)}/>
         </ListItemButton>

+ 14 - 3
web/src/components/Notifications.js

@@ -20,12 +20,23 @@ import {useLiveQuery} from "dexie-react-hooks";
 import Box from "@mui/material/Box";
 import Button from "@mui/material/Button";
 import subscriptionManager from "../app/SubscriptionManager";
+import { useParams } from "react-router-dom";
 
 const Notifications = (props) => {
+    const params = useParams();
+    if (!props.subscriptions) {
+        return null;
+    }
+    const [subscription] = props.subscriptions.filter(s => s.topic === params.topic);
+    if (!subscription) {
+        return null; // FIXME
+    }
+    return <NotificationList subscription={subscription}/>;
+};
+
+const NotificationList = (props) => {
     const subscription = props.subscription;
-    const notifications = useLiveQuery(() => {
-        return subscriptionManager.getNotifications(subscription.id);
-    }, [subscription]);
+    const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
     if (!notifications || notifications.length === 0) {
         return <NothingHereYet subscription={subscription}/>;
     }

+ 1 - 2
web/src/components/Preferences.js

@@ -14,7 +14,6 @@ import {
     useMediaQuery
 } from "@mui/material";
 import Typography from "@mui/material/Typography";
-import Paper from "@mui/material/Paper";
 import prefs from "../app/Prefs";
 import {Paragraph} from "./styles";
 import EditIcon from '@mui/icons-material/Edit';
@@ -33,7 +32,7 @@ import DialogContent from "@mui/material/DialogContent";
 import DialogActions from "@mui/material/DialogActions";
 import userManager from "../app/UserManager";
 
-const Preferences = (props) => {
+const Preferences = () => {
     return (
         <Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
             <Stack spacing={3}>