Navigation.jsx 15 KB

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