| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429 |
- import {
- Drawer,
- ListItemButton,
- ListItemIcon,
- ListItemText,
- Toolbar,
- Divider,
- List,
- Alert,
- AlertTitle,
- Badge,
- CircularProgress,
- Link,
- ListSubheader,
- Portal,
- Tooltip,
- Typography,
- Box,
- IconButton,
- } from "@mui/material";
- import * as React from "react";
- import { useContext, useState } from "react";
- import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
- import Person from "@mui/icons-material/Person";
- import SettingsIcon from "@mui/icons-material/Settings";
- import AddIcon from "@mui/icons-material/Add";
- import { useLocation, useNavigate } from "react-router-dom";
- import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material";
- import ArticleIcon from "@mui/icons-material/Article";
- import { Trans, useTranslation } from "react-i18next";
- import CelebrationIcon from "@mui/icons-material/Celebration";
- import SubscribeDialog from "./SubscribeDialog";
- import { openUrl, topicDisplayName, topicUrl } from "../app/utils";
- import routes from "./routes";
- import { ConnectionState } from "../app/Connection";
- import subscriptionManager from "../app/SubscriptionManager";
- import notifier from "../app/Notifier";
- import config from "../app/config";
- import session from "../app/Session";
- import accountApi, { Permission, Role } from "../app/AccountApi";
- import UpgradeDialog from "./UpgradeDialog";
- import { AccountContext } from "./App";
- import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
- import { SubscriptionPopup } from "./SubscriptionPopup";
- const navWidth = 280;
- const Navigation = (props) => {
- const navigationList = <NavList {...props} />;
- return (
- <Box component="nav" role="navigation" sx={{ width: { sm: Navigation.width }, flexShrink: { sm: 0 } }}>
- {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
- <Drawer
- variant="temporary"
- role="menubar"
- open={props.mobileDrawerOpen}
- onClose={props.onMobileDrawerToggle}
- ModalProps={{ keepMounted: true }} // Better open performance on mobile.
- sx={{
- display: { xs: "block", sm: "none" },
- "& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
- }}
- >
- {navigationList}
- </Drawer>
- {/* Big screen drawer; persistent, shown if screen is big */}
- <Drawer
- open
- variant="permanent"
- role="menubar"
- sx={{
- display: { xs: "none", sm: "block" },
- "& .MuiDrawer-paper": { boxSizing: "border-box", width: navWidth },
- }}
- >
- {navigationList}
- </Drawer>
- </Box>
- );
- };
- Navigation.width = navWidth;
- const NavList = (props) => {
- const { t } = useTranslation();
- const navigate = useNavigate();
- const location = useLocation();
- const { account } = useContext(AccountContext);
- const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
- const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
- const handleSubscribeReset = () => {
- setSubscribeDialogOpen(false);
- setSubscribeDialogKey((prev) => prev + 1);
- };
- const handleSubscribeSubmit = (subscription) => {
- console.log(`[Navigation] New subscription: ${subscription.id}`, subscription);
- handleSubscribeReset();
- navigate(routes.forSubscription(subscription));
- };
- const handleAccountClick = () => {
- accountApi.sync(); // Dangle!
- navigate(routes.account);
- };
- const isAdmin = account?.role === Role.ADMIN;
- const isPaid = account?.billing?.subscription;
- const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
- const showSubscriptionsList = props.subscriptions?.length > 0;
- const [showNotificationPermissionRequired, setShowNotificationPermissionRequired] = useState(notifier.notRequested());
- const [showNotificationPermissionDenied, setShowNotificationPermissionDenied] = useState(notifier.denied());
- const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired();
- const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported();
- const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
- const refreshPermissions = () => {
- setShowNotificationPermissionRequired(notifier.notRequested());
- setShowNotificationPermissionDenied(notifier.denied());
- };
- const alertVisible =
- showNotificationPermissionRequired ||
- showNotificationPermissionDenied ||
- showNotificationIOSInstallRequired ||
- showNotificationBrowserNotSupportedBox ||
- showNotificationContextNotSupportedBox;
- return (
- <>
- <Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
- <List component="nav" sx={{ paddingTop: alertVisible ? "0" : "" }}>
- {showNotificationPermissionRequired && <NotificationPermissionRequired refreshPermissions={refreshPermissions} />}
- {showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
- {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
- {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />}
- {showNotificationIOSInstallRequired && <NotificationIOSInstallRequiredAlert />}
- {alertVisible && <Divider />}
- {!showSubscriptionsList && (
- <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
- <ListItemIcon>
- <ChatBubble />
- </ListItemIcon>
- <ListItemText primary={t("nav_button_all_notifications")} />
- </ListItemButton>
- )}
- {showSubscriptionsList && (
- <>
- <ListSubheader>{t("nav_topics_title")}</ListSubheader>
- <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
- <ListItemIcon>
- <ChatBubble />
- </ListItemIcon>
- <ListItemText primary={t("nav_button_all_notifications")} />
- </ListItemButton>
- <SubscriptionList subscriptions={props.subscriptions} selectedSubscription={props.selectedSubscription} />
- <Divider sx={{ my: 1 }} />
- </>
- )}
- {session.exists() && (
- <ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}>
- <ListItemIcon>
- <Person />
- </ListItemIcon>
- <ListItemText primary={t("nav_button_account")} />
- </ListItemButton>
- )}
- <ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
- <ListItemIcon>
- <SettingsIcon />
- </ListItemIcon>
- <ListItemText primary={t("nav_button_settings")} />
- </ListItemButton>
- <ListItemButton onClick={() => openUrl("/docs")}>
- <ListItemIcon>
- <ArticleIcon />
- </ListItemIcon>
- <ListItemText primary={t("nav_button_documentation")} />
- </ListItemButton>
- <ListItemButton onClick={() => props.onPublishMessageClick()}>
- <ListItemIcon>
- <Send />
- </ListItemIcon>
- <ListItemText primary={t("nav_button_publish_message")} />
- </ListItemButton>
- <ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
- <ListItemIcon>
- <AddIcon />
- </ListItemIcon>
- <ListItemText primary={t("nav_button_subscribe")} />
- </ListItemButton>
- {showUpgradeBanner && <UpgradeBanner />}
- </List>
- <SubscribeDialog
- key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
- open={subscribeDialogOpen}
- subscriptions={props.subscriptions}
- onCancel={handleSubscribeReset}
- onSuccess={handleSubscribeSubmit}
- />
- </>
- );
- };
- const UpgradeBanner = () => {
- const { t } = useTranslation();
- const [dialogKey, setDialogKey] = useState(0);
- const [dialogOpen, setDialogOpen] = useState(false);
- const handleClick = () => {
- setDialogKey((k) => k + 1);
- setDialogOpen(true);
- };
- return (
- <Box
- sx={{
- position: "fixed",
- width: `${Navigation.width - 1}px`,
- bottom: 0,
- mt: "auto",
- background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
- }}
- >
- <Divider />
- <ListItemButton onClick={handleClick} sx={{ pt: 2, pb: 2 }}>
- <ListItemIcon>
- <CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large" />
- </ListItemIcon>
- <ListItemText
- sx={{ ml: 1 }}
- primary={t("nav_upgrade_banner_label")}
- secondary={t("nav_upgrade_banner_description")}
- primaryTypographyProps={{
- style: {
- fontWeight: 500,
- fontSize: "1.1rem",
- background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
- WebkitBackgroundClip: "text",
- WebkitTextFillColor: "transparent",
- },
- }}
- secondaryTypographyProps={{
- style: {
- fontSize: "1rem",
- },
- }}
- />
- </ListItemButton>
- <UpgradeDialog key={`upgradeDialog${dialogKey}`} open={dialogOpen} onCancel={() => setDialogOpen(false)} />
- </Box>
- );
- };
- const SubscriptionList = (props) => {
- const sortedSubscriptions = props.subscriptions
- .filter((s) => !s.internal)
- .sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1));
- return (
- <>
- {sortedSubscriptions.map((subscription) => (
- <SubscriptionItem
- key={subscription.id}
- subscription={subscription}
- selected={props.selectedSubscription && props.selectedSubscription.id === subscription.id}
- />
- ))}
- </>
- );
- };
- const SubscriptionItem = (props) => {
- const { t } = useTranslation();
- const navigate = useNavigate();
- const [menuAnchorEl, setMenuAnchorEl] = useState(null);
- const { subscription } = props;
- const iconBadge = subscription.new <= 99 ? subscription.new : "99+";
- const displayName = topicDisplayName(subscription);
- const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName;
- const icon =
- subscription.state === ConnectionState.Connecting ? (
- <CircularProgress size="24px" />
- ) : (
- <Badge badgeContent={iconBadge} invisible={subscription.new === 0} color="primary">
- <ChatBubbleOutlineIcon />
- </Badge>
- );
- const handleClick = async () => {
- navigate(routes.forSubscription(subscription));
- await subscriptionManager.markNotificationsRead(subscription.id);
- };
- return (
- <>
- <ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
- <ListItemIcon>{icon}</ListItemIcon>
- <ListItemText
- primary={displayName}
- primaryTypographyProps={{
- style: { overflow: "hidden", textOverflow: "ellipsis" },
- }}
- />
- {subscription.reservation?.everyone && (
- <ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
- {subscription.reservation?.everyone === Permission.READ_WRITE && (
- <Tooltip title={t("prefs_reservations_table_everyone_read_write")}>
- <PermissionReadWrite size="small" />
- </Tooltip>
- )}
- {subscription.reservation?.everyone === Permission.READ_ONLY && (
- <Tooltip title={t("prefs_reservations_table_everyone_read_only")}>
- <PermissionRead size="small" />
- </Tooltip>
- )}
- {subscription.reservation?.everyone === Permission.WRITE_ONLY && (
- <Tooltip title={t("prefs_reservations_table_everyone_write_only")}>
- <PermissionWrite size="small" />
- </Tooltip>
- )}
- {subscription.reservation?.everyone === Permission.DENY_ALL && (
- <Tooltip title={t("prefs_reservations_table_everyone_deny_all")}>
- <PermissionDenyAll size="small" />
- </Tooltip>
- )}
- </ListItemIcon>
- )}
- {subscription.mutedUntil > 0 && (
- <ListItemIcon edge="end" sx={{ minWidth: "26px" }} aria-label={t("nav_button_muted")}>
- <Tooltip title={t("nav_button_muted")}>
- <NotificationsOffOutlined />
- </Tooltip>
- </ListItemIcon>
- )}
- <ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
- <IconButton
- size="small"
- onMouseDown={(e) => e.stopPropagation()}
- onClick={(e) => {
- e.stopPropagation();
- setMenuAnchorEl(e.currentTarget);
- }}
- >
- <MoreVert fontSize="small" />
- </IconButton>
- </ListItemIcon>
- </ListItemButton>
- <Portal>
- <SubscriptionPopup subscription={subscription} anchor={menuAnchorEl} onClose={() => setMenuAnchorEl(null)} />
- </Portal>
- </>
- );
- };
- const NotificationPermissionRequired = ({ refreshPermissions }) => {
- const { t } = useTranslation();
- return (
- <Alert severity="info" sx={{ paddingTop: 2 }}>
- <AlertTitle>{t("alert_notification_permission_required_title")}</AlertTitle>
- <Typography gutterBottom align="left">
- {/* component=Button is not an anchor, false positive */}
- {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
- <Link
- component="button"
- style={{ textAlign: "left" }}
- onClick={async () => {
- await notifier.maybeRequestPermission();
- refreshPermissions();
- }}
- >
- {t("alert_notification_permission_required_description")}
- </Link>
- </Typography>
- </Alert>
- );
- };
- const NotificationPermissionDeniedAlert = () => {
- const { t } = useTranslation();
- return (
- <Alert severity="warning" sx={{ paddingTop: 2 }}>
- <AlertTitle>{t("alert_notification_permission_denied_title")}</AlertTitle>
- <Typography gutterBottom>{t("alert_notification_permission_denied_description")}</Typography>
- </Alert>
- );
- };
- const NotificationIOSInstallRequiredAlert = () => {
- const { t } = useTranslation();
- return (
- <>
- <Alert severity="info" sx={{ paddingTop: 2 }}>
- <AlertTitle>{t("alert_notification_ios_install_required_title")}</AlertTitle>
- <Typography gutterBottom>{t("alert_notification_ios_install_required_description")}</Typography>
- </Alert>
- <Divider />
- </>
- );
- };
- const NotificationBrowserNotSupportedAlert = () => {
- const { t } = useTranslation();
- return (
- <Alert severity="warning" sx={{ paddingTop: 2 }}>
- <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
- <Typography gutterBottom>{t("alert_not_supported_description")}</Typography>
- </Alert>
- );
- };
- const NotificationContextNotSupportedAlert = () => {
- const { t } = useTranslation();
- return (
- <Alert severity="warning" sx={{ paddingTop: 2 }}>
- <AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
- <Typography gutterBottom>
- <Trans
- i18nKey="alert_not_supported_context_description"
- components={{
- mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener" />,
- }}
- />
- </Typography>
- </Alert>
- );
- };
- export default Navigation;
|