Navigation.jsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. import {
  2. Alert,
  3. AlertTitle,
  4. Badge,
  5. Box,
  6. Button,
  7. CircularProgress,
  8. Divider,
  9. Drawer,
  10. IconButton,
  11. Link,
  12. List,
  13. ListItemButton,
  14. ListItemIcon,
  15. ListItemText,
  16. ListSubheader,
  17. Portal,
  18. Toolbar,
  19. Tooltip,
  20. Typography,
  21. useTheme,
  22. } from "@mui/material";
  23. import * as React from "react";
  24. import { useContext, useState } from "react";
  25. import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
  26. import Person from "@mui/icons-material/Person";
  27. import SettingsIcon from "@mui/icons-material/Settings";
  28. import AddIcon from "@mui/icons-material/Add";
  29. import { useLocation, useNavigate } from "react-router-dom";
  30. import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
  31. import ArticleIcon from "@mui/icons-material/Article";
  32. import { Trans, useTranslation } from "react-i18next";
  33. import CelebrationIcon from "@mui/icons-material/Celebration";
  34. import SubscribeDialog from "./SubscribeDialog";
  35. import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
  36. import routes from "./routes";
  37. import { ConnectionState } from "../app/Connection";
  38. import subscriptionManager from "../app/SubscriptionManager";
  39. import notifier from "../app/Notifier";
  40. import config from "../app/config";
  41. import session from "../app/Session";
  42. import accountApi, { Permission, Role } from "../app/AccountApi";
  43. import UpgradeDialog from "./UpgradeDialog";
  44. import { AccountContext } from "./App";
  45. import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
  46. import { SubscriptionPopup } from "./SubscriptionPopup";
  47. import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
  48. const navWidth = 280;
  49. const Navigation = (props) => {
  50. const navigationList = <NavList {...props} />;
  51. return (
  52. <Box component="nav" role="navigation" sx={{ width: { sm: Navigation.width }, flexShrink: { sm: 0 } }}>
  53. {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
  54. <Drawer
  55. variant="temporary"
  56. role="menubar"
  57. open={props.mobileDrawerOpen}
  58. onClose={props.onMobileDrawerToggle}
  59. ModalProps={{ keepMounted: true }} // Better open performance on mobile.
  60. sx={{
  61. display: { xs: "block", sm: "none" },
  62. "& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth, backgroundImage: "none" },
  63. }}
  64. >
  65. {navigationList}
  66. </Drawer>
  67. {/* Big screen drawer; persistent, shown if screen is big */}
  68. <Drawer
  69. open
  70. variant="permanent"
  71. role="menubar"
  72. sx={{
  73. display: { xs: "none", sm: "block" },
  74. "& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
  75. }}
  76. >
  77. {navigationList}
  78. </Drawer>
  79. </Box>
  80. );
  81. };
  82. Navigation.width = navWidth;
  83. const NavList = (props) => {
  84. const theme = useTheme();
  85. const { t } = useTranslation();
  86. const navigate = useNavigate();
  87. const location = useLocation();
  88. const { account } = useContext(AccountContext);
  89. const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
  90. const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
  91. const [versionChanged, setVersionChanged] = useState(false);
  92. const handleVersionChange = () => {
  93. setVersionChanged(true);
  94. };
  95. useVersionChangeListener(handleVersionChange);
  96. const handleSubscribeReset = () => {
  97. setSubscribeDialogOpen(false);
  98. setSubscribeDialogKey((prev) => prev + 1);
  99. };
  100. const handleSubscribeSubmit = (subscription) => {
  101. console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
  102. handleSubscribeReset();
  103. navigate(routes.forSubscription(subscription));
  104. };
  105. const handleAccountClick = () => {
  106. accountApi.sync(); // Dangle!
  107. navigate(routes.account);
  108. };
  109. const isAdmin = account?.role === Role.ADMIN;
  110. const isPaid = account?.billing?.subscription;
  111. const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
  112. const showSubscriptionsList = props.subscriptions?.length > 0;
  113. const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested());
  114. const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied());
  115. const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired();
  116. const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported();
  117. const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
  118. const alertVisible =
  119. versionChanged ||
  120. showNotificationPermissionRequired ||
  121. showNotificationPermissionDenied ||
  122. showNotificationIOSInstallRequired ||
  123. showNotificationBrowserNotSupportedBox ||
  124. showNotificationContextNotSupportedBox;
  125. return (
  126. <>
  127. <Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
  128. <List component="nav" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : "" } }}>
  129. {versionChanged && <VersionUpdateBanner />}
  130. {showNotificationPermissionRequired && <NotificationPermissionRequired />}
  131. {showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
  132. {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
  133. {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
  134. {showNotificationIOSInstallRequired && <NotificationIOSInstallRequiredAlert />}
  135. {alertVisible && <Divider />}
  136. {!showSubscriptionsList && (
  137. <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
  138. <ListItemIcon>
  139. <ChatBubble />
  140. </ListItemIcon>
  141. <ListItemText primary={t("nav_button_all_notifications")} />
  142. </ListItemButton>
  143. )}
  144. {showSubscriptionsList && (
  145. <>
  146. <ListSubheader>{t("nav_topics_title")}</ListSubheader>
  147. <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
  148. <ListItemIcon>
  149. <ChatBubble />
  150. </ListItemIcon>
  151. <ListItemText primary={t("nav_button_all_notifications")} />
  152. </ListItemButton>
  153. <SubscriptionList subscriptions={props.subscriptions} selectedSubscription={props.selectedSubscription} />
  154. <Divider sx={{ my: 1 }} />
  155. </>
  156. )}
  157. {session.exists() && (
  158. <ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}>
  159. <ListItemIcon>
  160. <Person />
  161. </ListItemIcon>
  162. <ListItemText primary={t("nav_button_account")} />
  163. </ListItemButton>
  164. )}
  165. <ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
  166. <ListItemIcon>
  167. <SettingsIcon />
  168. </ListItemIcon>
  169. <ListItemText primary={t("nav_button_settings")} />
  170. </ListItemButton>
  171. <ListItemButton onClick={() => openUrl("/docs")}>
  172. <ListItemIcon>
  173. <ArticleIcon />
  174. </ListItemIcon>
  175. <ListItemText primary={t("nav_button_documentation")} />
  176. </ListItemButton>
  177. <ListItemButton onClick={() => props.onPublishMessageClick()}>
  178. <ListItemIcon>
  179. <Send />
  180. </ListItemIcon>
  181. <ListItemText primary={t("nav_button_publish_message")} />
  182. </ListItemButton>
  183. <ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
  184. <ListItemIcon>
  185. <AddIcon />
  186. </ListItemIcon>
  187. <ListItemText primary={t("nav_button_subscribe")} />
  188. </ListItemButton>
  189. {showUpgradeBanner && (
  190. // The text background gradient didn't seem to do well with switching between light/dark mode,
  191. // So adding a `key` forces React to replace the entire component when the theme changes
  192. <UpgradeBanner key={`upgrade-banner-${theme.palette.mode}`} mode={theme.palette.mode} />
  193. )}
  194. </List>
  195. <SubscribeDialog
  196. key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
  197. open={subscribeDialogOpen}
  198. subscriptions={props.subscriptions}
  199. onCancel={handleSubscribeReset}
  200. onSuccess={handleSubscribeSubmit}
  201. />
  202. </>
  203. );
  204. };
  205. const UpgradeBanner = ({ mode }) => {
  206. const { t } = useTranslation();
  207. const [dialogKey, setDialogKey] = useState(0);
  208. const [dialogOpen, setDialogOpen] = useState(false);
  209. const handleClick = () => {
  210. setDialogKey((k) => k + 1);
  211. setDialogOpen(true);
  212. };
  213. return (
  214. <Box
  215. sx={{
  216. position: "fixed",
  217. width: `${Navigation.width - 1}px`,
  218. bottom: 0,
  219. mt: "auto",
  220. background:
  221. mode === "light"
  222. ? "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)"
  223. : "linear-gradient(150deg, #203631 0%, #2a6e60 100%)",
  224. }}
  225. >
  226. <Divider />
  227. <ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>
  228. <ListItemIcon>
  229. <CelebrationIcon sx={{ color: mode === "light" ? "#55b86e" : "#00ff95" }} fontSize="large" />
  230. </ListItemIcon>
  231. <ListItemText
  232. sx={{ ml: 1 }}
  233. primary={t("nav_upgrade_banner_label")}
  234. secondary={t("nav_upgrade_banner_description")}
  235. primaryTypographyProps={{
  236. style: {
  237. fontWeight: 500,
  238. fontSize: "1.1rem",
  239. background:
  240. mode === "light"
  241. ? "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)"
  242. : "-webkit-linear-gradient(45deg,rgb(255, 255, 255), #00ff95 80%)",
  243. WebkitBackgroundClip: "text",
  244. WebkitTextFillColor: "transparent",
  245. },
  246. }}
  247. secondaryTypographyProps={{
  248. style: {
  249. fontSize: "1rem",
  250. },
  251. }}
  252. />
  253. </ListItemButton>
  254. <UpgradeDialog key={`upgradeDialog${dialogKey}`} open={dialogOpen} onCancel={() => setDialogOpen(false)} />
  255. </Box>
  256. );
  257. };
  258. const SubscriptionList = (props) => {
  259. const sortedSubscriptions = props.subscriptions
  260. .filter((s) => !s.internal)
  261. .sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1));
  262. return (
  263. <>
  264. {sortedSubscriptions.map((subscription) => (
  265. <SubscriptionItem
  266. key={subscription.id}
  267. subscription={subscription}
  268. selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
  269. />
  270. ))}
  271. </>
  272. );
  273. };
  274. const SubscriptionItem = (props) => {
  275. const { t } = useTranslation();
  276. const navigate = useNavigate();
  277. const [menuAnchorEl, setMenuAnchorEl] = useState(null);
  278. const { subscription } = props;
  279. const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
  280. const displayName = topicDisplayName(subscription);
  281. const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName;
  282. const icon =
  283. subscription.state === ConnectionState.Connecting ? (
  284. <CircularProgress size="24px" />
  285. ) : (
  286. <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary">
  287. <ChatBubbleOutlineIcon />
  288. </Badge>
  289. );
  290. const handleClick = async () => {
  291. navigate(routes.forSubscription(subscription));
  292. await subscriptionManager.markNotificationsRead(subscription.id);
  293. };
  294. return (
  295. <>
  296. <ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
  297. <ListItemIcon>{icon}</ListItemIcon>
  298. <ListItemText
  299. primary={displayName}
  300. primaryTypographyProps={{
  301. style: { overflow: "hidden", textOverflow: "ellipsis" },
  302. }}
  303. />
  304. {subscription.reservation?.everyone && (
  305. <ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
  306. {subscription.reservation?.everyone === Permission.READ_WRITE && (
  307. <Tooltip title={t("prefs_reservations_table_everyone_read_write")}>
  308. <PermissionReadWrite size="small" />
  309. </Tooltip>
  310. )}
  311. {subscription.reservation?.everyone === Permission.READ_ONLY && (
  312. <Tooltip title={t("prefs_reservations_table_everyone_read_only")}>
  313. <PermissionRead size="small" />
  314. </Tooltip>
  315. )}
  316. {subscription.reservation?.everyone === Permission.WRITE_ONLY && (
  317. <Tooltip title={t("prefs_reservations_table_everyone_write_only")}>
  318. <PermissionWrite size="small" />
  319. </Tooltip>
  320. )}
  321. {subscription.reservation?.everyone === Permission.DENY_ALL && (
  322. <Tooltip title={t("prefs_reservations_table_everyone_deny_all")}>
  323. <PermissionDenyAll size="small" />
  324. </Tooltip>
  325. )}
  326. </ListItemIcon>
  327. )}
  328. {subscription.mutedUntil > 0 && (
  329. <ListItemIcon edge="end" sx={{ minWidth: "26px" }} aria-label={t("nav_button_muted")}>
  330. <Tooltip title={t("nav_button_muted")}>
  331. <NotificationsOffOutlined />
  332. </Tooltip>
  333. </ListItemIcon>
  334. )}
  335. <ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
  336. <IconButton
  337. size="small"
  338. onMouseDown={(e) => e.stopPropagation()}
  339. onClick={(e) => {
  340. e.stopPropagation();
  341. setMenuAnchorEl(e.currentTarget);
  342. }}
  343. >
  344. <MoreVert fontSize="small" />
  345. </IconButton>
  346. </ListItemIcon>
  347. </ListItemButton>
  348. <Portal>
  349. <SubscriptionPopup subscription={subscription} anchor={menuAnchorEl} onClose={() => setMenuAnchorEl(null)} />
  350. </Portal>
  351. </>
  352. );
  353. };
  354. const NotificationPermissionRequired = () => {
  355. const { t } = useTranslation();
  356. const requestPermission = async () => {
  357. await notifier.maybeRequestPermission();
  358. };
  359. return (
  360. <Alert severity="warning" sx={{ paddingTop: 2 }}>
  361. <AlertTitle>{t("alert_notification_permission_required_title")}</AlertTitle>
  362. <Typography gutterBottom>{t("alert_notification_permission_required_description")}</Typography>
  363. <Button sx={{ float: "right" }} color="inherit" size="small" onClick={requestPermission}>
  364. {t("alert_notification_permission_required_button")}
  365. </Button>
  366. </Alert>
  367. );
  368. };
  369. const NotificationPermissionDeniedAlert = () => {
  370. const { t } = useTranslation();
  371. return (
  372. <Alert severity="warning" sx={{ paddingTop: 2 }}>
  373. <AlertTitle>{t("alert_notification_permission_denied_title")}</AlertTitle>
  374. <Typography gutterBottom>{t("alert_notification_permission_denied_description")}</Typography>
  375. </Alert>
  376. );
  377. };
  378. const NotificationIOSInstallRequiredAlert = () => {
  379. const { t } = useTranslation();
  380. return (
  381. <Alert severity="warning" sx={{ paddingTop: 2 }}>
  382. <AlertTitle>{t("alert_notification_ios_install_required_title")}</AlertTitle>
  383. <Typography gutterBottom>{t("alert_notification_ios_install_required_description")}</Typography>
  384. </Alert>
  385. );
  386. };
  387. const NotificationBrowserNotSupportedAlert = () => {
  388. const { t } = useTranslation();
  389. return (
  390. <Alert severity="warning" sx={{ paddingTop: 2 }}>
  391. <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
  392. <Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
  393. </Alert>
  394. );
  395. };
  396. const NotificationContextNotSupportedAlert = () => {
  397. const { t } = useTranslation();
  398. return (
  399. <Alert severity="warning" sx={{ paddingTop: 2 }}>
  400. <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
  401. <Typography gutterBottom>
  402. <Trans
  403. i18nKey="alert_not_supported_context_description"
  404. components={{
  405. mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener" />,
  406. }}
  407. />
  408. </Typography>
  409. </Alert>
  410. );
  411. };
  412. const VersionUpdateBanner = () => {
  413. const { t } = useTranslation();
  414. const handleRefresh = () => {
  415. window.location.reload();
  416. };
  417. return (
  418. <Alert severity="info" sx={{ paddingTop: 2 }}>
  419. <AlertTitle>{t("version_update_available_title")}</AlertTitle>
  420. <Typography gutterBottom>{t("version_update_available_description")}</Typography>
  421. <Button sx={{ float: "right" }} color="inherit" size="small" onClick={handleRefresh}>
  422. {t("common_refresh")}
  423. </Button>
  424. </Alert>
  425. );
  426. };
  427. export default Navigation;