Navigation.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import Drawer from "@mui/material/Drawer";
  2. import * as React from "react";
  3. import {useState} from "react";
  4. import ListItemButton from "@mui/material/ListItemButton";
  5. import ListItemIcon from "@mui/material/ListItemIcon";
  6. import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
  7. import ListItemText from "@mui/material/ListItemText";
  8. import Toolbar from "@mui/material/Toolbar";
  9. import Divider from "@mui/material/Divider";
  10. import List from "@mui/material/List";
  11. import SettingsIcon from "@mui/icons-material/Settings";
  12. import AddIcon from "@mui/icons-material/Add";
  13. import SubscribeDialog from "./SubscribeDialog";
  14. import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader} from "@mui/material";
  15. import Button from "@mui/material/Button";
  16. import Typography from "@mui/material/Typography";
  17. import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
  18. import routes from "./routes";
  19. import {ConnectionState} from "../app/Connection";
  20. import {useLocation, useNavigate} from "react-router-dom";
  21. import subscriptionManager from "../app/SubscriptionManager";
  22. import {ChatBubble, NotificationsOffOutlined, Send} from "@mui/icons-material";
  23. import Box from "@mui/material/Box";
  24. import notifier from "../app/Notifier";
  25. import config from "../app/config";
  26. import ArticleIcon from '@mui/icons-material/Article';
  27. import {Trans, useTranslation} from "react-i18next";
  28. const navWidth = 280;
  29. const Navigation = (props) => {
  30. const navigationList = <NavList {...props}/>;
  31. return (
  32. <Box
  33. component="nav"
  34. role="navigation"
  35. sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}
  36. >
  37. {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
  38. <Drawer
  39. variant="temporary"
  40. role="menubar"
  41. open={props.mobileDrawerOpen}
  42. onClose={props.onMobileDrawerToggle}
  43. ModalProps={{ keepMounted: true }} // Better open performance on mobile.
  44. sx={{
  45. display: { xs: 'block', sm: 'none' },
  46. '& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
  47. }}
  48. >
  49. {navigationList}
  50. </Drawer>
  51. {/* Big screen drawer; persistent, shown if screen is big */}
  52. <Drawer
  53. open
  54. variant="permanent"
  55. role="menubar"
  56. sx={{
  57. display: { xs: 'none', sm: 'block' },
  58. '& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
  59. }}
  60. >
  61. {navigationList}
  62. </Drawer>
  63. </Box>
  64. );
  65. };
  66. Navigation.width = navWidth;
  67. const NavList = (props) => {
  68. const { t } = useTranslation();
  69. const navigate = useNavigate();
  70. const location = useLocation();
  71. const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
  72. const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
  73. const handleSubscribeReset = () => {
  74. setSubscribeDialogOpen(false);
  75. setSubscribeDialogKey(prev => prev+1);
  76. }
  77. const handleSubscribeSubmit = (subscription) => {
  78. console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
  79. handleSubscribeReset();
  80. navigate(routes.forSubscription(subscription));
  81. handleRequestNotificationPermission();
  82. }
  83. const handleRequestNotificationPermission = () => {
  84. notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted))
  85. };
  86. const showSubscriptionsList = props.subscriptions?.length > 0;
  87. const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
  88. const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
  89. const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted;
  90. const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : '';
  91. return (
  92. <>
  93. <Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/>
  94. <List component="nav" sx={{ paddingTop: navListPadding }}>
  95. {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>}
  96. {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
  97. {showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
  98. {!showSubscriptionsList &&
  99. <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.appRoot}>
  100. <ListItemIcon><ChatBubble/></ListItemIcon>
  101. <ListItemText primary={t("nav_button_all_notifications")}/>
  102. </ListItemButton>}
  103. {showSubscriptionsList &&
  104. <>
  105. <ListSubheader>{t("nav_topics_title")}</ListSubheader>
  106. <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.appRoot}>
  107. <ListItemIcon><ChatBubble/></ListItemIcon>
  108. <ListItemText primary={t("nav_button_all_notifications")}/>
  109. </ListItemButton>
  110. <SubscriptionList
  111. subscriptions={props.subscriptions}
  112. selectedSubscription={props.selectedSubscription}
  113. />
  114. <Divider sx={{my: 1}}/>
  115. </>}
  116. <ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
  117. <ListItemIcon><SettingsIcon/></ListItemIcon>
  118. <ListItemText primary={t("nav_button_settings")}/>
  119. </ListItemButton>
  120. <ListItemButton onClick={() => openUrl("/docs")}>
  121. <ListItemIcon><ArticleIcon/></ListItemIcon>
  122. <ListItemText primary={t("nav_button_documentation")}/>
  123. </ListItemButton>
  124. <ListItemButton onClick={() => props.onPublishMessageClick()}>
  125. <ListItemIcon><Send/></ListItemIcon>
  126. <ListItemText primary={t("nav_button_publish_message")}/>
  127. </ListItemButton>
  128. <ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
  129. <ListItemIcon><AddIcon/></ListItemIcon>
  130. <ListItemText primary={t("nav_button_subscribe")}/>
  131. </ListItemButton>
  132. </List>
  133. <SubscribeDialog
  134. key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
  135. open={subscribeDialogOpen}
  136. subscriptions={props.subscriptions}
  137. onCancel={handleSubscribeReset}
  138. onSuccess={handleSubscribeSubmit}
  139. />
  140. </>
  141. );
  142. };
  143. const SubscriptionList = (props) => {
  144. const sortedSubscriptions = props.subscriptions.sort( (a, b) => {
  145. return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;
  146. });
  147. return (
  148. <>
  149. {sortedSubscriptions.map(subscription =>
  150. <SubscriptionItem
  151. key={subscription.id}
  152. subscription={subscription}
  153. selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
  154. />)}
  155. </>
  156. );
  157. }
  158. const SubscriptionItem = (props) => {
  159. const { t } = useTranslation();
  160. const navigate = useNavigate();
  161. const subscription = props.subscription;
  162. const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
  163. const icon = (subscription.state === ConnectionState.Connecting)
  164. ? <CircularProgress size="24px"/>
  165. : <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary"><ChatBubbleOutlineIcon/></Badge>;
  166. const displayName = topicDisplayName(subscription);
  167. const ariaLabel = (subscription.state === ConnectionState.Connecting)
  168. ? `${displayName} (${t("nav_button_connecting")})`
  169. : displayName;
  170. const handleClick = async () => {
  171. navigate(routes.forSubscription(subscription));
  172. await subscriptionManager.markNotificationsRead(subscription.id);
  173. };
  174. return (
  175. <ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
  176. <ListItemIcon>{icon}</ListItemIcon>
  177. <ListItemText primary={displayName}/>
  178. {subscription.mutedUntil > 0 &&
  179. <ListItemIcon edge="end" aria-label={t("nav_button_muted")}><NotificationsOffOutlined /></ListItemIcon>}
  180. </ListItemButton>
  181. );
  182. };
  183. const NotificationGrantAlert = (props) => {
  184. const { t } = useTranslation();
  185. return (
  186. <>
  187. <Alert severity="warning" sx={{paddingTop: 2}}>
  188. <AlertTitle>{t("alert_grant_title")}</AlertTitle>
  189. <Typography gutterBottom>{t("alert_grant_description")}</Typography>
  190. <Button
  191. sx={{float: 'right'}}
  192. color="inherit"
  193. size="small"
  194. onClick={props.onRequestPermissionClick}
  195. >
  196. {t("alert_grant_button")}
  197. </Button>
  198. </Alert>
  199. <Divider/>
  200. </>
  201. );
  202. };
  203. const NotificationBrowserNotSupportedAlert = () => {
  204. const { t } = useTranslation();
  205. return (
  206. <>
  207. <Alert severity="warning" sx={{paddingTop: 2}}>
  208. <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
  209. <Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
  210. </Alert>
  211. <Divider/>
  212. </>
  213. );
  214. };
  215. const NotificationContextNotSupportedAlert = () => {
  216. const { t } = useTranslation();
  217. return (
  218. <>
  219. <Alert severity="warning" sx={{paddingTop: 2}}>
  220. <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
  221. <Typography gutterBottom>
  222. <Trans
  223. i18nKey="alert_not_supported_context_description"
  224. components={{
  225. mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener"/>
  226. }}
  227. />
  228. </Typography>
  229. </Alert>
  230. <Divider/>
  231. </>
  232. );
  233. };
  234. export default Navigation;