App.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import * as React from 'react';
  2. import {useEffect, useState} from 'react';
  3. import Box from '@mui/material/Box';
  4. import {ThemeProvider} from '@mui/material/styles';
  5. import CssBaseline from '@mui/material/CssBaseline';
  6. import Toolbar from '@mui/material/Toolbar';
  7. import Notifications from "./Notifications";
  8. import theme from "./theme";
  9. import connectionManager from "../app/ConnectionManager";
  10. import Navigation from "./Navigation";
  11. import ActionBar from "./ActionBar";
  12. import notifier from "../app/Notifier";
  13. import Preferences from "./Preferences";
  14. import {useLiveQuery} from "dexie-react-hooks";
  15. import subscriptionManager from "../app/SubscriptionManager";
  16. import userManager from "../app/UserManager";
  17. import {BrowserRouter, Outlet, Route, Routes, useNavigate, useOutletContext, useParams} from "react-router-dom";
  18. import {expandSecureUrl, expandUrl, subscriptionRoute, topicUrl} from "../app/utils";
  19. // TODO support unsubscribed routes
  20. // TODO "copy url" toast
  21. // TODO "copy link url" button
  22. // TODO races when two tabs are open
  23. // TODO investigate service workers
  24. const App = () => {
  25. return (
  26. <BrowserRouter>
  27. <ThemeProvider theme={theme}>
  28. <CssBaseline/>
  29. <Routes>
  30. <Route element={<Layout/>}>
  31. <Route path="/" element={<AllSubscriptions/>} />
  32. <Route path="settings" element={<Preferences/>} />
  33. <Route path=":topic" element={<SingleSubscription/>} />
  34. <Route path=":baseUrl/:topic" element={<SingleSubscription/>} />
  35. </Route>
  36. </Routes>
  37. </ThemeProvider>
  38. </BrowserRouter>
  39. );
  40. }
  41. const AllSubscriptions = () => {
  42. const { subscriptions } = useOutletContext();
  43. return <Notifications mode="all" subscriptions={subscriptions}/>;
  44. };
  45. const SingleSubscription = () => {
  46. const { subscriptions, selected } = useOutletContext();
  47. const [missingAdded, setMissingAdded] = useState(false);
  48. const params = useParams();
  49. useEffect(() => {
  50. const loaded = subscriptions !== null && subscriptions !== undefined;
  51. const missing = loaded && params.topic && !selected && !missingAdded;
  52. if (missing) {
  53. setMissingAdded(true);
  54. const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin;
  55. console.log(`[App] Adding ephemeral subscription for ${topicUrl(baseUrl, params.topic)}`);
  56. // subscriptionManager.add(baseUrl, params.topic, true); // Dangle!
  57. }
  58. }, [params, subscriptions, selected, missingAdded]);
  59. return <Notifications mode="one" subscription={selected}/>;
  60. };
  61. const Layout = () => {
  62. const params = useParams();
  63. const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
  64. const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
  65. const users = useLiveQuery(() => userManager.all());
  66. const subscriptions = useLiveQuery(() => subscriptionManager.all());
  67. const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
  68. const [selected] = (subscriptions || []).filter(s => {
  69. return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic)
  70. || (window.location.origin === s.baseUrl && params.topic === s.topic)
  71. });
  72. useConnectionListeners();
  73. useEffect(() => connectionManager.refresh(subscriptions, users), [subscriptions, users]);
  74. useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
  75. return (
  76. <Box sx={{display: 'flex'}}>
  77. <CssBaseline/>
  78. <ActionBar
  79. selected={selected}
  80. onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
  81. />
  82. <Navigation
  83. subscriptions={subscriptions}
  84. selectedSubscription={selected}
  85. notificationsGranted={notificationsGranted}
  86. mobileDrawerOpen={mobileDrawerOpen}
  87. onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
  88. onNotificationGranted={setNotificationsGranted}
  89. />
  90. <Main>
  91. <Toolbar/>
  92. <Outlet context={{ subscriptions, selected }}/>
  93. </Main>
  94. </Box>
  95. );
  96. }
  97. const Main = (props) => {
  98. return (
  99. <Box
  100. id="main"
  101. component="main"
  102. sx={{
  103. display: 'flex',
  104. flexGrow: 1,
  105. flexDirection: 'column',
  106. padding: 3,
  107. width: {sm: `calc(100% - ${Navigation.width}px)`},
  108. height: '100vh',
  109. overflow: 'auto',
  110. backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
  111. }}
  112. >
  113. {props.children}
  114. </Box>
  115. );
  116. };
  117. const useConnectionListeners = () => {
  118. const navigate = useNavigate();
  119. useEffect(() => {
  120. const handleNotification = async (subscriptionId, notification) => {
  121. const added = await subscriptionManager.addNotification(subscriptionId, notification);
  122. if (added) {
  123. const defaultClickAction = (subscription) => navigate(subscriptionRoute(subscription));
  124. await notifier.notify(subscriptionId, notification, defaultClickAction)
  125. }
  126. };
  127. connectionManager.registerStateListener(subscriptionManager.updateState);
  128. connectionManager.registerNotificationListener(handleNotification);
  129. return () => {
  130. connectionManager.resetStateListener();
  131. connectionManager.resetNotificationListener();
  132. }
  133. },
  134. // We have to disable dep checking for "navigate". This is fine, it never changes.
  135. // eslint-disable-next-line
  136. []);
  137. };
  138. const updateTitle = (newNotificationsCount) => {
  139. document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy web` : "ntfy web";
  140. }
  141. export default App;